diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..9456a68 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,77 @@ +name: Checks +on: + push: + branches: + - main + pull_request: + branches: + - main +permissions: + checks: write +jobs: + Checks-JS: + 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 Chrome + uses: browser-actions/setup-chrome@v1.7.2 + - name: Run Js tests + run: ./gradlew jsBrowserTest --warn --continue + - name: Run Wasm tests + 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' + require_tests: true + 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: Run Android tests + 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' + require_tests: true + 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 + - 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' + require_tests: true \ No newline at end of file diff --git a/README.MD b/README.MD index ab45b85..3a54c56 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 @@ -10,19 +12,50 @@ 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 ) } ``` -## Before running! - - check your system with [KDoctor](https://github.com/Kotlin/kdoctor) - - install JDK 17 or higher on your machine +## 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` 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 diff --git a/library/build.gradle.kts b/library/build.gradle.kts index a5207b4..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 @@ -149,18 +150,23 @@ 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) + // 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 } } } -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/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/build.gradle.kts b/sample/build.gradle.kts index 4062071..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,22 +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..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,21 +5,32 @@ 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.remember +import androidx.compose.runtime.rememberCoroutineScope 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.TextFieldValueValidator 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?) { @@ -34,7 +45,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 ) @@ -42,12 +53,91 @@ class SampleActivity : ComponentActivity() { OutlinedTextField( value = value, label = { Text(text = "Email") }, - modifier = Modifier.validateFocusChanged(), + modifier = Modifier + .validateFocusChanged() + .shakeOnInvalid() + .fillMaxWidth(), onValueChange = ::onValueChange, isError = isError(), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Email, + ), + singleLine = true, supportingText = supportingText() ) } + + Spacer(modifier = Modifier.height(16.dp)) + + 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(), + supportingText = supportingText(), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + ), + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + ) + } + 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() + ) + } + + + // Validate button + val scope = rememberCoroutineScope() + Button( + onClick = { + listOf( + emailValidator, + passwordValidator, + passwordMatchesValidator + ).validate() + }, + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth() + ) { + Text(text = "Validate") + } } } }