diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..59a2956 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*] +indent_size = 2 +insert_final_newline = true +max_line_length = 120 diff --git a/.github/workflows/test_and_release_snapshot.yml b/.github/workflows/test_and_release_snapshot.yml new file mode 100644 index 0000000..f9226b1 --- /dev/null +++ b/.github/workflows/test_and_release_snapshot.yml @@ -0,0 +1,67 @@ +name: Run tests and publish snapshot + +on: + push: + branches: [ trunk ] + pull_request: + +jobs: + checks: + name: Run tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + lfs: true + - uses: gradle/wrapper-validation-action@v1 + - uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 17 + - uses: gradle/gradle-build-action@v2.4.2 + + - name: Run unit tests + run: ./gradlew check + + - name: Run paparazzi screenshot tests + run: ./gradlew verifyPaparazziDebug + + - name: (Fail-only) Upload paparazzi test reports + if: failure() + uses: actions/upload-artifact@v3 + with: + name: test-failures + path: | + **/build/reports/tests/*/ + **/out/failures/ + paparazzi/paparazzi-gradle-plugin/src/test/projects/**/build/reports/paparazzi/images/ + + deploy-snapshot: + name: Deploy snapshot + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.repository == 'saket/squiggly-slider' && github.ref == 'refs/heads/trunk' + needs: [ checks ] + steps: + - uses: actions/checkout@v3 + - uses: gradle/wrapper-validation-action@v1 + - uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 17 + - uses: gradle/gradle-build-action@v2.4.2 + - uses: oNaiPs/secrets-to-env-action@v1 + with: + secrets: ${{ toJSON(secrets) }} + + - name: Ensure snapshot version + run: ./gradlew library:throwIfVersionIsNotSnapshot + + - name: Publish snapshot + run: ./gradlew clean publish --no-daemon --no-parallel --no-configuration-cache --stacktrace + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SIGNING_PASSWORD }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9042cd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Built application files +*.apk +*.ap_ + +# Files for the Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +build/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log Files +*.log + +# Android Studio stuff +.idea/ +.navigation/ +captures/ +*.iml + +# OS specific ignores +.DS_Store +*~ +*.swp diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5db00dc --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Saket Narayan + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..91ec9f3 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +```groovy +implementation "me.saket.squiggly-slider:squiggly-slider:1.0.0" +``` + +```diff +- Slider( ++ SquigglySlider( + value = sliderValue, + onValueChange = { sliderValue = it }, + ) +``` + +## License + +``` +Copyright 2023 Saket Narayan. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3690930 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,39 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile +import com.android.build.gradle.BaseExtension as AndroidBaseExtension +import com.android.build.gradle.BasePlugin as AndroidBasePlugin + +buildscript { + repositories { + google() + mavenCentral() + } +} + +// Lists all plugins used throughout the project without applying them. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.compose.multiplatform) apply false + alias(libs.plugins.mavenPublish) apply false + alias(libs.plugins.paparazzi) apply false + alias(libs.plugins.dokka) apply false +} + +allprojects { + plugins.withType().configureEach { + configure { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + } + } + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5d17721 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +android.useAndroidX=true +android.enableJetifier=false + +kotlin.mpp.androidSourceSetLayoutVersion=2 +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true +org.jetbrains.compose.experimental.uikit.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..5ed82a1 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,43 @@ +[versions] +minSdk = "21" +compileSdk = "34" +kotlin = "1.9.10" +agp = "8.1.0" +androidx-compose-ui = "1.5.1" # https://developer.android.com/jetpack/androidx/releases/compose-ui +androidx-compose-ui-material3 = "1.1.1" +androidx-compose-compiler = "1.5.3" # https://developer.android.com/jetpack/androidx/releases/compose-compiler +compose-multiplatform = "1.5.1" # https://github.com/JetBrains/compose-multiplatform/releases +androidx-appcompat = "1.6.1" +androidx-activity = "1.7.2" # https://developer.android.com/jetpack/androidx/releases/activity +androidx-savedstate = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/savedstate +androidx-lifecycle = "2.6.2" # https://developer.android.com/jetpack/androidx/releases/lifecycle +accompanist = "0.32.0" # https://github.com/google/accompanist/releases +paparazzi = "1.3.1" # https://github.com/cashapp/paparazzi/releases +mavenPublish = "0.25.3" # https://github.com/vanniktech/gradle-maven-publish-plugin/releases +dokka = "1.9.0" # https://github.com/kotlin/dokka/releases +junit = "4.13.2" + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } + +[libraries] +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose-ui" } +compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose-ui" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-ui-material3" } +compose-materialIcons = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose-ui" } + +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity"} +androidx-savedstate = { module = "androidx.savedstate:savedstate", version.ref = "androidx-savedstate" } +androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle"} + +accompanist-systemUi = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } + +junit = { module = "junit:junit", version.ref = "junit" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1f017e4 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/library/build.gradle.kts b/library/build.gradle.kts new file mode 100644 index 0000000..eaf7876 --- /dev/null +++ b/library/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.mavenPublish) + alias(libs.plugins.paparazzi) +} + +kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + } + } + + val androidUnitTest by getting { + dependencies { + implementation(libs.junit) + implementation(libs.compose.materialIcons) + } + } + } +} + +android { + namespace = "me.saket.squiggles" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + compileSdk = libs.versions.compileSdk.get().toInt() + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } + java { + toolchain.languageVersion.set(JavaLanguageVersion.of(11)) + } + lint { + abortOnError = true + } +} + +// Used on CI to prevent publishing of non-snapshot versions. +tasks.register("throwIfVersionIsNotSnapshot") { + val libraryVersion = properties["VERSION_NAME"] as String + check(libraryVersion.endsWith("SNAPSHOT")) { + "Project isn't using a snapshot version = $libraryVersion" + } +} diff --git a/library/gradle.properties b/library/gradle.properties new file mode 100644 index 0000000..c0dd36f --- /dev/null +++ b/library/gradle.properties @@ -0,0 +1,25 @@ +SONATYPE_HOST=DEFAULT +RELEASE_SIGNING_ENABLED=true +SONATYPE_AUTOMATIC_RELEASE=true + +GROUP=me.saket.squiggly-slider +POM_ARTIFACT_ID=squiggly-slider +VERSION_NAME=1.0.0-SNAPSHOT + +POM_NAME=squiggly-slider +POM_PACKAGING=aar + +POM_DESCRIPTION=Squiggly slider, inspired from Android 13's media player notification. +POM_INCEPTION_YEAR=2023 + +POM_URL=https://github.com/saket/squiggly-slider +POM_SCM_URL=https://github.com/saket/squiggly-slider +POM_SCM_CONNECTION=scm:git@github.com:saket/squiggly-slider.git +POM_SCM_DEV_CONNECTION=scm:git@github.com:saket/squiggly-slider.git + +POM_LICENCE_NAME=The Apache Software License, Version 2.0 +POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo + +POM_DEVELOPER_ID=saketme +POM_DEVELOPER_NAME=Saket Narayan diff --git a/library/src/commonMain/kotlin/me/saket/squiggles/SquigglySlider.kt b/library/src/commonMain/kotlin/me/saket/squiggles/SquigglySlider.kt new file mode 100644 index 0000000..7f80924 --- /dev/null +++ b/library/src/commonMain/kotlin/me/saket/squiggles/SquigglySlider.kt @@ -0,0 +1,234 @@ +package me.saket.appu.screens.video + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import me.saket.appu.screens.video.SquigglySlider.SquigglesAnimator +import me.saket.appu.screens.video.SquigglySlider.SquigglesSpec +import kotlin.math.ceil +import kotlin.math.sin +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Composable +@ExperimentalMaterial3Api +fun SquigglySlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), + squigglesSpec: SquigglesSpec = SquigglesSpec(), + squigglesAnimator: SquigglesAnimator = SquigglySlider.rememberSquigglesAnimator(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + Slider( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + onValueChangeFinished = onValueChangeFinished, + colors = colors, + interactionSource = interactionSource, + thumb = { + SquigglySlider.Thumb( + colors = colors, + enabled = enabled + ) + }, + track = { sliderState -> + SquigglySlider.Track( + colors = colors, + enabled = enabled, + sliderState = sliderState, + squigglesSpec = squigglesSpec, + squigglesAnimator = squigglesAnimator, + ) + }, + valueRange = valueRange + ) +} + +object SquigglySlider { + private const val SegmentsPerWavelength = 10 + private const val TwoPi = 2 * Math.PI.toFloat() + + @Immutable + class SquigglesSpec( + val strokeWidth: Dp = 4.dp, // Same as SliderDefaults.TrackHeight + val wavelength: Dp = 24.dp, + val amplitude: Dp = 2.dp, + ) + + @Stable + class SquigglesAnimator( + val animationProgress: State + ) + + @Composable + fun Thumb( + colors: SliderColors, + modifier: Modifier = Modifier, + enabled: Boolean = true, + thumbSize: DpSize = DpSize(width = 4.dp, height = 16.dp), + shape: Shape = RoundedCornerShape(4.dp), + ) { + Box( + modifier = modifier.size(width = 20.dp, height = 20.dp), // Set by Slider. + contentAlignment = Alignment.Center, + ) { + Spacer( + Modifier + .size(thumbSize) + .background(colors.thumbColor(enabled), shape) + ) + } + } + + // todo: - steps + @Composable + @ExperimentalMaterial3Api + fun Track( + sliderState: SliderState, + colors: SliderColors, + modifier: Modifier = Modifier, + squigglesSpec: SquigglesSpec = SquigglesSpec(), + squigglesAnimator: SquigglesAnimator = rememberSquigglesAnimator(), + enabled: Boolean = true, + ) { + val sliderHeight = (squigglesSpec.amplitude + squigglesSpec.strokeWidth) * 2 + val inactiveTrackColor = colors.trackColor(enabled, active = false) + val activeTrackColor = colors.trackColor(enabled, active = true) + + Spacer( + modifier + .fillMaxWidth() + .height(sliderHeight) + .drawWithCache { + val path = Path() + val pathStyle = Stroke( + width = squigglesSpec.strokeWidth.toPx(), + join = StrokeJoin.Round, + cap = StrokeCap.Round, + pathEffect = PathEffect.cornerPathEffect( + radius = squigglesSpec.wavelength.toPx() // For slightly smoother waves. + ), + ) + onDrawBehind { + val isRtl = layoutDirection == LayoutDirection.Rtl + val sliderLeft = Offset(0f, center.y) + val sliderRight = Offset(size.width, center.y) + val sliderStart = if (isRtl) sliderRight else sliderLeft + val sliderEnd = if (isRtl) sliderLeft else sliderRight + val sliderValueEnd = Offset( + x = sliderStart.x + (sliderEnd.x - sliderStart.x) * sliderState.coercedValueAsFraction, + y = center.y, + ) + drawLine( + color = inactiveTrackColor, + start = sliderValueEnd, + end = sliderEnd, + strokeWidth = squigglesSpec.strokeWidth.toPx(), + cap = StrokeCap.Round + ) + path.rewind() + path.buildSquigglesFor( + squigglesSpec = squigglesSpec, + startOffset = sliderStart, + endOffset = sliderValueEnd, + animationProgress = squigglesAnimator.animationProgress, + ) + drawPath( + path = path, + color = activeTrackColor, + style = pathStyle, + ) + } + } + ) + } + + @Composable + fun rememberSquigglesAnimator(duration: Duration = 4.seconds): SquigglesAnimator { + val animationProgress = rememberInfiniteTransition(label = "Infinite squiggles").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = duration.inWholeMilliseconds.toInt(), easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "Squiggles" + ) + return remember { + SquigglesAnimator(animationProgress) + } + } + + /** + * Maths copied from [squigglyspans](https://github.com/samruston/squigglyspans). + */ + context(DrawScope) + private fun Path.buildSquigglesFor( + squigglesSpec: SquigglesSpec, + startOffset: Offset, + endOffset: Offset, + animationProgress: State, + ) { + val waveStartOffset = startOffset.x + (squigglesSpec.strokeWidth.toPx() / 2) + val waveEndOffset = (endOffset.x - (squigglesSpec.strokeWidth.toPx() / 2)).coerceAtLeast(waveStartOffset) + + val segmentWidth = squigglesSpec.wavelength.toPx() / SegmentsPerWavelength + val numOfPoints = ceil((waveEndOffset - waveStartOffset) / segmentWidth).toInt() + 1 + + var pointX = waveStartOffset + fastMapRange(start = 0, end = numOfPoints) { point -> + val proportionOfWavelength = (pointX - waveStartOffset) / squigglesSpec.wavelength.toPx() + val radiansX = proportionOfWavelength * TwoPi + (TwoPi * animationProgress.value) + val offsetY = center.y + (sin(radiansX) * squigglesSpec.amplitude.toPx()) + + when (point) { + 0 -> moveTo(pointX, offsetY) + else -> lineTo(pointX, offsetY) + } + pointX = (pointX + segmentWidth).coerceAtMost(waveEndOffset) + } + } +} diff --git a/library/src/commonMain/kotlin/me/saket/squiggles/squigglesInternals.kt b/library/src/commonMain/kotlin/me/saket/squiggles/squigglesInternals.kt new file mode 100644 index 0000000..749c995 --- /dev/null +++ b/library/src/commonMain/kotlin/me/saket/squiggles/squigglesInternals.kt @@ -0,0 +1,45 @@ +package me.saket.appu.screens.video + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderState +import androidx.compose.ui.graphics.Color +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +internal inline fun fastMapRange( + start: Int, + end: Int, + transform: (Int) -> R +): List { + contract { callsInPlace(transform) } + val destination = ArrayList(/* initialCapacity = */ end - start + 1) + for (i in start..end) { + destination.add(transform(i)) + } + return destination +} + +internal fun SliderColors.thumbColor(enabled: Boolean): Color = + if (enabled) thumbColor else disabledThumbColor + +internal fun SliderColors.trackColor(enabled: Boolean, active: Boolean): Color = + if (enabled) { + if (active) activeTrackColor else inactiveTrackColor + } else { + if (active) disabledActiveTrackColor else disabledInactiveTrackColor + } + +@ExperimentalMaterial3Api +internal val SliderState.coercedValueAsFraction + get() = calcFraction( + valueRange.start, + valueRange.endInclusive, + value.coerceIn(valueRange.start, valueRange.endInclusive) + ) + +// Calculate the 0..1 fraction that `pos` value represents between `a` and `b` +private fun calcFraction(a: Float, b: Float, pos: Float) = + (if (b - a == 0f) 0f else (pos - a) / (b - a)).coerceIn(0f, 1f) + diff --git a/library/src/test/kotlin/me/saket/squiggles/SquigglySliderTest.kt b/library/src/test/kotlin/me/saket/squiggles/SquigglySliderTest.kt new file mode 100644 index 0000000..185f7cf --- /dev/null +++ b/library/src/test/kotlin/me/saket/squiggles/SquigglySliderTest.kt @@ -0,0 +1,72 @@ +@file:Suppress("TestFunctionName") + +package me.saket.squiggles + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import com.android.ide.common.rendering.api.SessionParams.RenderingMode +import org.junit.Rule +import org.junit.Test + +class SquigglySliderTest { + @get:Rule val paparazzi = Paparazzi( + deviceConfig = DeviceConfig.PIXEL_5, + showSystemUi = false, + renderingMode = RenderingMode.SHRINK, + ) + + @Test fun `zero slider value`() { + paparazzi.snapshot { + Scaffold { + TODO() + } + } + } + + @Test fun `zero amplitude`() { + paparazzi.snapshot { + Scaffold { + TODO() + } + } + } + + @Test fun `non-zero amplitude`() { + paparazzi.snapshot { + Scaffold { + TODO() + } + } + } + + @Composable + private fun Scaffold(content: @Composable BoxScope.() -> Unit) { + MaterialTheme( + colorScheme = dynamicDarkColorScheme(LocalContext.current) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface), + content = content, + contentAlignment = Alignment.Center + ) + } + } +} + +val Color.Companion.Whisper get() = Color(0XFFF8F5FA) +val Color.Companion.SeaBuckthorn get() = Color(0xFFF9A825) +val Color.Companion.Perfume get() = Color(0xFFD0BCFF) +val Color.Companion.GraySuit get() = Color(0xFFC1BAC9) diff --git a/sample/.gitignore b/sample/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/sample/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts new file mode 100644 index 0000000..5bb90aa --- /dev/null +++ b/sample/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "me.saket.squiggles.sample" + + defaultConfig { + applicationId = namespace + minSdk = 31 + compileSdk = libs.versions.compileSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } + java { + toolchain.languageVersion.set(JavaLanguageVersion.of(11)) + } + lint { + abortOnError = true + } +} + +dependencies { + implementation(projects.library) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.compose.materialIcons) + implementation(libs.accompanist.systemUi) +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5a31753 --- /dev/null +++ b/sample/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/sample/src/main/kotlin/me/saket/squiggles/sample/SampleActivity.kt b/sample/src/main/kotlin/me/saket/squiggles/sample/SampleActivity.kt new file mode 100644 index 0000000..56e8866 --- /dev/null +++ b/sample/src/main/kotlin/me/saket/squiggles/sample/SampleActivity.kt @@ -0,0 +1,47 @@ +package me.saket.squiggles.sample + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.core.view.WindowCompat +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@OptIn(ExperimentalMaterial3Api::class) +class SampleActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val uiController = rememberSystemUiController() + val systemInDarkTheme = isSystemInDarkTheme() + LaunchedEffect(Unit) { + WindowCompat.setDecorFitsSystemWindows(window, false) + uiController.setSystemBarsColor(Color.Transparent, darkIcons = !systemInDarkTheme) + uiController.setNavigationBarColor(Color.Transparent) + } + + val colors = if (systemInDarkTheme) dynamicDarkColorScheme(this) else dynamicLightColorScheme(this) + + MaterialTheme(colors) { + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(R.string.app_name)) }) + } + ) { contentPadding -> + TODO() + } + } + } + } +} diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/drawable/ic_launcher_foreground.xml b/sample/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..da1dcd9 --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml new file mode 100644 index 0000000..0b1d037 --- /dev/null +++ b/sample/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Squiggles + diff --git a/sample/src/main/res/values/styles.xml b/sample/src/main/res/values/styles.xml new file mode 100644 index 0000000..1cbb903 --- /dev/null +++ b/sample/src/main/res/values/styles.xml @@ -0,0 +1,13 @@ + + #356859 + #356859 + #B5D2C3 + + + diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..9abec11 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +include ':library' +include ':sample' + +rootProject.name = "squiggly-slider" +enableFeaturePreview('TYPESAFE_PROJECT_ACCESSORS')