From 38b5d2d5b06629d81501d81fdc42322c8a4841db Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 09:39:57 -0600 Subject: [PATCH 01/12] add checks xml --- .github/workflows/checks.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/checks.yml diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..ff2f514 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,23 @@ +name: Checks +run-name: ${{ github.actor }} is running checks +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + Checks-JS: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Chrome + uses: browser-actions/setup-chrome@v1.7.2 + + - name: Run Js tests + run: ./gradlew jsBrowserTest + - name: Run Wasm tests + run: ./gradlew wasmJsBrowserTest \ No newline at end of file From d3e5c344953a345974710d31991a2d03cb7292f4 Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 09:43:34 -0600 Subject: [PATCH 02/12] add java --- .github/workflows/checks.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ff2f514..30b2615 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,5 +1,4 @@ name: Checks -run-name: ${{ github.actor }} is running checks on: push: branches: @@ -14,10 +13,16 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Java JDK + uses: actions/setup-java@v4.2.1 + with: + distribution: 'zulu' + java-version: '21' + cache: 'gradle' - name: Setup Chrome uses: browser-actions/setup-chrome@v1.7.2 - name: Run Js tests - run: ./gradlew jsBrowserTest + run: ./gradlew jsBrowserTest --warn - name: Run Wasm tests - run: ./gradlew wasmJsBrowserTest \ No newline at end of file + run: ./gradlew wasmJsBrowserTest --warn \ No newline at end of file From f26aef6f88cb08006a8a08b92b5b9a5c7247dab8 Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 09:50:53 -0600 Subject: [PATCH 03/12] fall back to properties for uploading --- library/build.gradle.kts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/library/build.gradle.kts b/library/build.gradle.kts index a5207b4..4848636 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -149,10 +149,9 @@ publishing { maven { name = "githubPackages" url = uri("https://maven.pkg.github.com/chrisjenx/yakcov") - credentials(PasswordCredentials::class) { - username = project.findProperty("githubPackagesUsername") as String - password = project.findProperty("githubPackagesPassword") as String - } + credentials(PasswordCredentials::class) + // username is from: githubPackagesUsername or ORG_GRADLE_PROJECT_githubPackagesUsername + // password is from: githubPackagesPassword or ORG_GRADLE_PROJECT_githubPackagesPassword } } } From 1d2260b3ecf2657ec9868f0b92b96998666198db Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 09:59:17 -0600 Subject: [PATCH 04/12] added jvm checks --- .github/workflows/checks.yml | 19 ++++++++++++++++++- library/build.gradle.kts | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 30b2615..21b3fbe 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -25,4 +25,21 @@ jobs: - name: Run Js tests run: ./gradlew jsBrowserTest --warn - name: Run Wasm tests - run: ./gradlew wasmJsBrowserTest --warn \ No newline at end of file + run: ./gradlew wasmJsBrowserTest --warn + Checks-Jvm: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Java JDK + uses: actions/setup-java@v4.2.1 + with: + distribution: 'zulu' + java-version: '21' + cache: 'gradle' +# - name: Setup Android SDK +# uses: android/setup-android@v2 +# with: +# components: 'platform-tools,build-tools-30.0.3' + - name: Run Android tests + run: ./gradlew :library:testDebugUnitTest :library:jvmTest --warn \ No newline at end of file diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 4848636..c787063 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -150,6 +150,7 @@ publishing { name = "githubPackages" url = uri("https://maven.pkg.github.com/chrisjenx/yakcov") credentials(PasswordCredentials::class) + // https://vanniktech.github.io/gradle-maven-publish-plugin/other/#configuring-the-repository // username is from: githubPackagesUsername or ORG_GRADLE_PROJECT_githubPackagesUsername // password is from: githubPackagesPassword or ORG_GRADLE_PROJECT_githubPackagesPassword } From e783198eacbb39d73d0ba7e91509bf84ddb31eb9 Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 10:11:34 -0600 Subject: [PATCH 05/12] add osx checks --- .github/workflows/checks.yml | 18 +++++++++++++++++- gradle.properties | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 21b3fbe..28b25e4 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -42,4 +42,20 @@ jobs: # with: # components: 'platform-tools,build-tools-30.0.3' - name: Run Android tests - run: ./gradlew :library:testDebugUnitTest :library:jvmTest --warn \ No newline at end of file + run: ./gradlew :library:testDebugUnitTest --warn --continue + - name: Run Jvm tests + run: ./gradlew :library:jvmTest --warn --continue + + Checks-Apple: + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Java JDK + uses: actions/setup-java@v4.2.1 + with: + distribution: 'zulu' + java-version: '21' + cache: 'gradle' + - name: Run Apple tests + run: ./gradlew :library:iosSimulatorArm64Test --warn --continue \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 5329fbd..f2edd65 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,7 @@ android.nonTransitiveRClass=true #KMM kotlin.mpp.androidGradlePluginCompatibility.nowarn=true kotlin.apple.xcodeCompatibility.nowarn=true +kotlin.native.ignoreDisabledTargets=true #Compose org.jetbrains.compose.experimental.jscanvas.enabled=true From 5d3bba8f3fe79626d4b1d94cec07df772ab996f6 Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 10:12:23 -0600 Subject: [PATCH 06/12] clean up --- .github/workflows/checks.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 28b25e4..f272ec1 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -37,15 +37,10 @@ jobs: distribution: 'zulu' java-version: '21' cache: 'gradle' -# - name: Setup Android SDK -# uses: android/setup-android@v2 -# with: -# components: 'platform-tools,build-tools-30.0.3' - name: Run Android tests run: ./gradlew :library:testDebugUnitTest --warn --continue - name: Run Jvm tests run: ./gradlew :library:jvmTest --warn --continue - Checks-Apple: runs-on: macos-latest steps: From b1659ab972bcba704c84a990413e1b2bbe64642d Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 16:23:05 -0600 Subject: [PATCH 07/12] update publishing and checks --- .github/workflows/checks.yml | 25 +++++++++++++++++++++---- library/build.gradle.kts | 12 +++++++++--- sample/build.gradle.kts | 4 +--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f272ec1..8b943e6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -21,11 +21,16 @@ jobs: cache: 'gradle' - name: Setup Chrome uses: browser-actions/setup-chrome@v1.7.2 - - name: Run Js tests - run: ./gradlew jsBrowserTest --warn + run: ./gradlew jsBrowserTest --warn --continue - name: Run Wasm tests - run: ./gradlew wasmJsBrowserTest --warn + run: ./gradlew wasmJsBrowserTest --warn --continue + - name: Publish Test Report + uses: mikepenz/action-junit-report@v4 + if: success() || failure() + with: + report_paths: '**/build/test-results/**/TEST-*.xml' + check_name: 'JS JUnit Test Report' Checks-Jvm: runs-on: ubuntu-latest steps: @@ -41,6 +46,12 @@ jobs: run: ./gradlew :library:testDebugUnitTest --warn --continue - name: Run Jvm tests run: ./gradlew :library:jvmTest --warn --continue + - name: Publish Test Report + uses: mikepenz/action-junit-report@v4 + if: success() || failure() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/**/TEST-*.xml' + check_name: 'JVM JUnit Test Report' Checks-Apple: runs-on: macos-latest steps: @@ -53,4 +64,10 @@ jobs: java-version: '21' cache: 'gradle' - name: Run Apple tests - run: ./gradlew :library:iosSimulatorArm64Test --warn --continue \ No newline at end of file + run: ./gradlew :library:iosSimulatorArm64Test --warn --continue + - name: Publish Test Report + uses: mikepenz/action-junit-report@v4 + if: success() || failure() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/**/TEST-*.xml' + check_name: 'Apple JUnit Test Report' \ No newline at end of file diff --git a/library/build.gradle.kts b/library/build.gradle.kts index c787063..8496c86 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -1,6 +1,7 @@ @file:OptIn(ExperimentalWasmDsl::class) import com.android.build.api.dsl.ManagedVirtualDevice +import com.vanniktech.maven.publish.SonatypeHost import org.jetbrains.compose.ExperimentalComposeLibrary import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -157,10 +158,15 @@ publishing { } } -mavenPublishing { - coordinates("com.chrisjenx.yakcov", "library", "1.0.0-SNAPSHOT") +// get git shortSha for version +@Suppress("UnstableApiUsage") +val gitSha = providers.exec { commandLine("git", "rev-parse", "--short", "HEAD") } + .standardOutput.asText.map { it.trim() } - // the following is optional +mavenPublishing { + coordinates("com.chrisjenx.yakcov", "library", "1.0.0-${gitSha.get()}") + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() pom { name.set("Yakcov") diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 4062071..9f7a0d6 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -72,8 +72,6 @@ repositories { google() maven { url = uri("https://maven.pkg.github.com/chrisjenx/yakcov") - content { - includeGroup("com.chrisjenx.yakcov") - } + content { includeGroup("com.chrisjenx.yakcov") } } } \ No newline at end of file From 6915af6ad4d231fcd87e4af5e269d7a4767bf94b Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 16:39:42 -0600 Subject: [PATCH 08/12] update permission and readme --- .github/workflows/checks.yml | 8 ++++++-- README.MD | 30 ++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8b943e6..9456a68 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -6,7 +6,8 @@ on: pull_request: branches: - main - +permissions: + checks: write jobs: Checks-JS: runs-on: ubuntu-latest @@ -31,6 +32,7 @@ jobs: with: report_paths: '**/build/test-results/**/TEST-*.xml' check_name: 'JS JUnit Test Report' + require_tests: true Checks-Jvm: runs-on: ubuntu-latest steps: @@ -52,6 +54,7 @@ jobs: with: report_paths: '**/build/test-results/**/TEST-*.xml' check_name: 'JVM JUnit Test Report' + require_tests: true Checks-Apple: runs-on: macos-latest steps: @@ -70,4 +73,5 @@ jobs: if: success() || failure() # always run even if the previous step fails with: report_paths: '**/build/test-results/**/TEST-*.xml' - check_name: 'Apple JUnit Test Report' \ No newline at end of file + check_name: 'Apple JUnit Test Report' + require_tests: true \ No newline at end of file diff --git a/README.MD b/README.MD index ab45b85..cdb6206 100644 --- a/README.MD +++ b/README.MD @@ -1,5 +1,7 @@ # Yet Another Kotlin Compose Validation library +![Maven Central Version](https://img.shields.io/maven-central/v/com.chrisjenx.yakcov/library) + `TextField` validation is a pain, hopefully this is a bit easier ```kotlin @@ -20,9 +22,33 @@ with(emailValiator) { } ``` -## Before running! +## Dependencies +By default we publish to maven central: https://central.sonatype.com/artifact/com.chrisjenx.yakcov/library + +We publish all targets (Android, JVM, JS, Wasm, iOS) you can include the common library in your project and +it will pull in the correct target for what ever targets you have specified. +```kotlin +commonMain { + dependencies { + implementation("com.chrisjenx.yakcov:library:${version}") + } +} +``` + +If you only need a specific target you can include that directly, for example for Android: + +```kotlin +dependencies { + implementation("com.chrisjenx.yakcov:library-android:${version}") +} +``` + + + +## Build locally - check your system with [KDoctor](https://github.com/Kotlin/kdoctor) - install JDK 17 or higher on your machine ## Testing -- run tests with `./gradlew test` +- You will need Chrome installed for JS based tests to run +- run tests with `./gradlew :library:test` From cd90509128b08504a4a850c8887483f1b489c0c1 Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 16:51:18 -0600 Subject: [PATCH 09/12] updated sample --- sample/build.gradle.kts | 18 ++---------------- .../chrisjenx/yakcov/sample/SampleActivity.kt | 14 +++++++++++--- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 9f7a0d6..23020a1 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -48,8 +48,8 @@ android { } dependencies { - //implementation(project(":library")) - implementation("com.chrisjenx.yakcov:library:1.0.0-SNAPSHOT") + implementation(project(":library")) +// implementation("com.chrisjenx.yakcov:library:+") implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activityCompose) @@ -58,20 +58,6 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.testManifest) } - -repositories { - mavenCentral() - google() - maven { - url = uri("https://maven.pkg.github.com/chrisjenx/yakcov") - content { includeGroup("com.chrisjenx.yakcov") } - } -} \ No newline at end of file diff --git a/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt b/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt index 3589ba0..a01e70c 100644 --- a/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt +++ b/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt @@ -6,18 +6,19 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.chrisjenx.yakcov.Email import com.chrisjenx.yakcov.Required -import com.chrisjenx.yakcov.TextFieldValueValidator import com.chrisjenx.yakcov.rememberTextFieldValueValidator import com.chrisjenx.yakcov.sample.ui.theme.YakcovTheme @@ -42,9 +43,16 @@ class SampleActivity : ComponentActivity() { OutlinedTextField( value = value, label = { Text(text = "Email") }, - modifier = Modifier.validateFocusChanged(), + modifier = Modifier + .validateFocusChanged() + .fillMaxWidth(), onValueChange = ::onValueChange, isError = isError(), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Email, + ), + singleLine = true, supportingText = supportingText() ) } From ef606414cac6630ffdcb7fc0d195a8bfc09c65cb Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Wed, 24 Jul 2024 16:56:46 -0600 Subject: [PATCH 10/12] add password example --- .../chrisjenx/yakcov/sample/SampleActivity.kt | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt b/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt index a01e70c..e01d3d1 100644 --- a/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt +++ b/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt @@ -9,15 +9,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.chrisjenx.yakcov.Email +import com.chrisjenx.yakcov.MinLength +import com.chrisjenx.yakcov.PasswordMatches import com.chrisjenx.yakcov.Required import com.chrisjenx.yakcov.rememberTextFieldValueValidator import com.chrisjenx.yakcov.sample.ui.theme.YakcovTheme @@ -35,7 +39,7 @@ class SampleActivity : ComponentActivity() { .padding(innerPadding) .padding(16.dp) ) { - + Text(text = "Email", style = MaterialTheme.typography.headlineSmall) val emailValidator = rememberTextFieldValueValidator( rules = listOf(Required, Email), alwaysShowRule = true ) @@ -56,6 +60,58 @@ class SampleActivity : ComponentActivity() { supportingText = supportingText() ) } + + Text(text = "Password", style = MaterialTheme.typography.headlineSmall) + // Password example + val passwordValidator = rememberTextFieldValueValidator( + rules = listOf(Required, MinLength(minLength = 6)), + alwaysShowRule = true + ) + val passwordMatchesValidator = rememberTextFieldValueValidator( + rules = listOf( + Required, + MinLength(minLength = 6), + PasswordMatches(passwordValidator) + ), + alwaysShowRule = true + ) + + with(passwordValidator) { + OutlinedTextField( + value = value, + label = { Text(text = "Password") }, + modifier = Modifier + .validateFocusChanged() + .fillMaxWidth(), + onValueChange = ::onValueChange, + isError = isError(), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + ), + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + supportingText = supportingText() + ) + } + with(passwordMatchesValidator) { + OutlinedTextField( + value = value, + label = { Text(text = "Confirm Password") }, + modifier = Modifier + .validateFocusChanged() + .fillMaxWidth(), + onValueChange = ::onValueChange, + isError = isError(), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + ), + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + supportingText = supportingText() + ) + } } } } From 55cd3cfaa4c9bac7ee0772f221c1efbeab588764 Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Thu, 25 Jul 2024 10:15:10 -0600 Subject: [PATCH 11/12] add shake and updated readme --- .../com/chrisjenx/yakcov/ShakingState.kt | 83 +++++++++++++++++++ .../yakcov/TextFieldValueValidator.kt | 79 ++++++++++++++---- .../chrisjenx/yakcov/sample/SampleActivity.kt | 30 ++++++- 3 files changed, 172 insertions(+), 20 deletions(-) create mode 100644 library/src/commonMain/kotlin/com/chrisjenx/yakcov/ShakingState.kt diff --git a/library/src/commonMain/kotlin/com/chrisjenx/yakcov/ShakingState.kt b/library/src/commonMain/kotlin/com/chrisjenx/yakcov/ShakingState.kt new file mode 100644 index 0000000..5972975 --- /dev/null +++ b/library/src/commonMain/kotlin/com/chrisjenx/yakcov/ShakingState.kt @@ -0,0 +1,83 @@ +package com.chrisjenx.yakcov + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer + +class ShakingState( + private val strength: Strength, + private val direction: Direction +) { + + val xPosition = Animatable(0f) + + suspend fun shake(animationDuration: Int = 50) { + val shakeAnimationSpec: AnimationSpec = tween(animationDuration) + when (direction) { + Direction.LEFT -> shakeToLeft(shakeAnimationSpec) + Direction.RIGHT -> shakeToRight(shakeAnimationSpec) + Direction.LEFT_THEN_RIGHT -> shakeToLeftThenRight(shakeAnimationSpec) + Direction.RIGHT_THEN_LEFT -> shakeToRightThenLeft(shakeAnimationSpec) + } + } + + private suspend fun shakeToLeft(shakeAnimationSpec: AnimationSpec) { + repeat(3) { + xPosition.animateTo(-strength.value, shakeAnimationSpec) + xPosition.animateTo(0f, shakeAnimationSpec) + } + } + + private suspend fun shakeToRight(shakeAnimationSpec: AnimationSpec) { + repeat(3) { + xPosition.animateTo(strength.value, shakeAnimationSpec) + xPosition.animateTo(0f, shakeAnimationSpec) + } + } + + private suspend fun shakeToRightThenLeft(shakeAnimationSpec: AnimationSpec) { + repeat(3) { + xPosition.animateTo(strength.value, shakeAnimationSpec) + xPosition.animateTo(0f, shakeAnimationSpec) + xPosition.animateTo(-strength.value / 2, shakeAnimationSpec) + xPosition.animateTo(0f, shakeAnimationSpec) + } + } + + private suspend fun shakeToLeftThenRight(shakeAnimationSpec: AnimationSpec) { + repeat(3) { + xPosition.animateTo(-strength.value, shakeAnimationSpec) + xPosition.animateTo(0f, shakeAnimationSpec) + xPosition.animateTo(strength.value / 2, shakeAnimationSpec) + xPosition.animateTo(0f, shakeAnimationSpec) + } + } + + sealed class Strength(val value: Float) { + data object Normal : Strength(17f) + data object Strong : Strength(40f) + data class Custom(val strength: Float) : Strength(strength) + } + + enum class Direction { + LEFT, RIGHT, LEFT_THEN_RIGHT, RIGHT_THEN_LEFT + } +} + +@Composable +fun rememberShakingState( + strength: ShakingState.Strength = ShakingState.Strength.Normal, + direction: ShakingState.Direction = ShakingState.Direction.LEFT +): ShakingState { + return remember { ShakingState(strength = strength, direction = direction) } +} + +fun Modifier.shakable(state: ShakingState): Modifier { + return graphicsLayer { + translationX = state.xPosition.value + } +} \ No newline at end of file diff --git a/library/src/commonMain/kotlin/com/chrisjenx/yakcov/TextFieldValueValidator.kt b/library/src/commonMain/kotlin/com/chrisjenx/yakcov/TextFieldValueValidator.kt index 0ac8c62..d155df5 100644 --- a/library/src/commonMain/kotlin/com/chrisjenx/yakcov/TextFieldValueValidator.kt +++ b/library/src/commonMain/kotlin/com/chrisjenx/yakcov/TextFieldValueValidator.kt @@ -8,11 +8,14 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.util.fastJoinToString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * We wrap the [TextFieldValue] to add validation support. @@ -27,12 +30,14 @@ import androidx.compose.ui.util.fastJoinToString * ## Usage * * ``` - * with(model.email) { + * val emailValidator = rememberTextFieldValueValidator(rules = listOf(Required, Email)) + * with(emailValidator) { * TextField( * value = value, * onValueChange = ::onValueChange, - * modifier = Modifier.validateFocusChanged(), // Will validate after losing focus - * // other params + * modifier = Modifier + * .validateFocusChanged() // Will validate after losing focus + * .shakeOnInvalid(), // Will shake the field if invalid and validate() is called * isError = isError(), // Will mark as error if an error is present * supportingText = supportingText(), // Will concat all errors into a composable response * ) @@ -42,19 +47,20 @@ import androidx.compose.ui.util.fastJoinToString * The recommend way to use these in your models is as such: * * ``` - * @Stable // Note these are stable not Immutable, make sure if this is emmbedded that it's parent's are stable too + * @Stable // Note these are stable not Immutable, make sure if this is embedded that it's parent's are stable too * data class MyModel( * // by default marks as required * val email: TextFieldValueValidator = TextFieldValueValidator( * textFieldValue = // optional initial value * rules = listOf(Required, Email), // Pass in the validators you want to use. - * initialValidate = true, // if true, will start validation straight away vs user interaction or calling validate + * initialValidate = true, // if true, will start validation straight away vs user interaction or calling validate() * ), * ) * ``` * - * You can use the helper methods to control other composable state, as these are derived state they will update - * when the user interacts with the field, or if you call [TextFieldValueValidator.validate]. + * You can use the helper methods to control other composable state, as these are derived state + * they will update when the user interacts with the field, or if you + * call [TextFieldValueValidator.validate]. * * ``` * Button( @@ -68,12 +74,11 @@ import androidx.compose.ui.util.fastJoinToString * ``` * val model = _model.asStateFlow() * - * // helper method to check if fields are valid (will also show errors if they are not showing already) + * // We provide a helper method to validate all fields if desired, otherwise call validate on + * // the ones you care about * fun validate(): Boolean { * val model = this.model.value - * val isValid = true - * if(!model.email.validate()) isValid = false - * return isValid + * return listOf(model.email, model.password).validate() * } * * fun save() { @@ -82,7 +87,7 @@ import androidx.compose.ui.util.fastJoinToString * // etc.. * } * ``` - * Upating the model: It would be invalid to copy these values, you need to update the underlying value or call validate: + * Updating the model: It would be invalid to copy these values, you need to update the underlying value or call validate: * * ``` * // CORRECT WAY @@ -107,7 +112,7 @@ import androidx.compose.ui.util.fastJoinToString @Stable class TextFieldValueValidator( private val textFieldValue: MutableState = mutableStateOf(TextFieldValue()), - private val rules: List = listOf(Required), + internal val rules: List = listOf(Required), initialValidate: Boolean = false, private val alwaysShowRule: Boolean = false, private val validationSeparator: String = defaultValidationSeparator, @@ -119,6 +124,7 @@ class TextFieldValueValidator( initialValidate: Boolean = false, ) : this(mutableStateOf(TextFieldValue(text)), rules, initialValidate) + /** * Set to true after first call of validate. */ @@ -133,6 +139,14 @@ class TextFieldValueValidator( validations } + // Lazy create shaking state + private val shakingState by lazy { + ShakingState( + strength = ShakingState.Strength.Custom(20f), + direction = ShakingState.Direction.LEFT_THEN_RIGHT + ) + } + /** * Current Field validation state, this will only show invalid rules, once they are * satisfied these will disappear. I.e. a null list is valid field. @@ -158,9 +172,8 @@ class TextFieldValueValidator( * @see validate */ fun validate(textFieldValue: TextFieldValue? = null): Boolean { - textFieldValue?.also { this.textFieldValue.value = it } - shouldValidate = true - return errorState == null + println("validating $rules") + return validate(textFieldValue = textFieldValue, shake = true) } /** @@ -231,8 +244,35 @@ class TextFieldValueValidator( var hadFocus by mutableStateOf(false) return this.onFocusChanged { focusState -> if (focusState.hasFocus) hadFocus = true - if (!focusState.isFocused && hadFocus) validate(textFieldValue = null) + // Don't shake on loss of focus, as we want to just show the error + if (!focusState.isFocused && hadFocus) validate(textFieldValue = null, shake = false) + } + } + + private var shakeOnInvalidScope: CoroutineScope? = null + + /** + * Adds to the modifier, that when [validate] is called AND the field is invalid it will + * shake the field to draw attention to the error. + */ + @Composable + fun Modifier.shakeOnInvalid(): Modifier { + shakeOnInvalidScope = rememberCoroutineScope() + return this.shakable(shakingState) + } + + // Internal validate method so focus and external validate act correctly + private fun validate(textFieldValue: TextFieldValue? = null, shake: Boolean = false): Boolean { + textFieldValue?.also { this.textFieldValue.value = it } + shouldValidate = true + println("$rules, errorState: $errorState") + if (shake) errorState?.let { + // Only shake if invalid and scope is set + shakeOnInvalidScope?.let { scope -> + scope.launch { shakingState.shake(animationDuration = 20) } + } } + return errorState == null } override fun equals(other: Any?): Boolean { @@ -295,5 +335,8 @@ fun rememberTextFieldValueValidator( * false if any are invalid */ fun List.validate(): Boolean { - return this.all { validator -> validator.validate(textFieldValue = null) } + // Fold to make sure we loop through all validators + return this.fold(true) { valid, validator -> + validator.validate(textFieldValue = null) && valid + } } diff --git a/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt b/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt index e01d3d1..ab06211 100644 --- a/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt +++ b/sample/src/main/java/com/chrisjenx/yakcov/sample/SampleActivity.kt @@ -5,15 +5,19 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -25,6 +29,8 @@ import com.chrisjenx.yakcov.PasswordMatches import com.chrisjenx.yakcov.Required import com.chrisjenx.yakcov.rememberTextFieldValueValidator import com.chrisjenx.yakcov.sample.ui.theme.YakcovTheme +import com.chrisjenx.yakcov.validate +import kotlinx.coroutines.launch class SampleActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -49,6 +55,7 @@ class SampleActivity : ComponentActivity() { label = { Text(text = "Email") }, modifier = Modifier .validateFocusChanged() + .shakeOnInvalid() .fillMaxWidth(), onValueChange = ::onValueChange, isError = isError(), @@ -61,6 +68,8 @@ class SampleActivity : ComponentActivity() { ) } + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Password", style = MaterialTheme.typography.headlineSmall) // Password example val passwordValidator = rememberTextFieldValueValidator( @@ -75,7 +84,6 @@ class SampleActivity : ComponentActivity() { ), alwaysShowRule = true ) - with(passwordValidator) { OutlinedTextField( value = value, @@ -85,13 +93,13 @@ class SampleActivity : ComponentActivity() { .fillMaxWidth(), onValueChange = ::onValueChange, isError = isError(), + supportingText = supportingText(), keyboardOptions = KeyboardOptions( autoCorrect = false, keyboardType = KeyboardType.Password, ), visualTransformation = PasswordVisualTransformation(), singleLine = true, - supportingText = supportingText() ) } with(passwordMatchesValidator) { @@ -112,6 +120,24 @@ class SampleActivity : ComponentActivity() { supportingText = supportingText() ) } + + + // Validate button + val scope = rememberCoroutineScope() + Button( + onClick = { + listOf( + emailValidator, + passwordValidator, + passwordMatchesValidator + ).validate() + }, + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth() + ) { + Text(text = "Validate") + } } } } From 656d6090810a974ff22af73b28e2b598aba58fbf Mon Sep 17 00:00:00 2001 From: Chris Jenkins Date: Thu, 25 Jul 2024 10:19:13 -0600 Subject: [PATCH 12/12] upadate readme --- README.MD | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/README.MD b/README.MD index cdb6206..3a54c56 100644 --- a/README.MD +++ b/README.MD @@ -12,21 +12,28 @@ with(emailValiator) { OutlinedTextField( value = value, onValueChange = ::validate, - modifier = Modifier.validateFocusChanged().fillMaxWidth(), + modifier = Modifier + .validateFocusChanged() // will start validation on loss of focus + .shakeOnInvalid() // will shake the field when invalid and validate() is called + .fillMaxWidth(), label = { Text("Email*") }, placeholder = { Text("Email") }, leadingIcon = { Icon(Icons.Default.Email, contentDescription = "Email") }, - isError = isError(), - supportingText = supportingText(), + isError = isError(), // will mark the field error when validation has started and is invalid + supportingText = supportingText(), // will show the validation message, or error message ) } ``` ## Dependencies -By default we publish to maven central: https://central.sonatype.com/artifact/com.chrisjenx.yakcov/library -We publish all targets (Android, JVM, JS, Wasm, iOS) you can include the common library in your project and +By default we publish +to [Maven Central](https://central.sonatype.com/artifact/com.chrisjenx.yakcov/library). + +We publish all targets (Android, JVM, JS, Wasm, iOS) you can include the common library in your +project and it will pull in the correct target for what ever targets you have specified. + ```kotlin commonMain { dependencies { @@ -43,12 +50,12 @@ dependencies { } ``` - - ## Build locally - - check your system with [KDoctor](https://github.com/Kotlin/kdoctor) - - install JDK 17 or higher on your machine + +- check your system with [KDoctor](https://github.com/Kotlin/kdoctor) +- install JDK 17 or higher on your machine ## Testing + - You will need Chrome installed for JS based tests to run - run tests with `./gradlew :library:test`