From c19e4e75db567554d64a9d8301072ac4b9979f5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b=C3=A1nd?= =?UTF-8?q?=C3=A9?= Date: Sun, 2 Aug 2026 17:04:06 +0100 Subject: [PATCH 1/4] feat: prepare 0.2.0 release --- .github/workflows/publish.yaml | 135 ++--- CHANGELOG.md | 39 ++ README.md | 173 +++++-- gradle.properties | 25 +- gradle/libs.versions.toml | 5 +- ikokuko/build.gradle.kts | 16 +- ikokuko/gradle.properties | 17 - .../kotlin/com/quantipixels/ikokuko/Field.kt | 14 +- .../kotlin/com/quantipixels/ikokuko/Form.kt | 117 ++++- .../com/quantipixels/ikokuko/FormScope.kt | 196 +++---- .../com/quantipixels/ikokuko/Validator.kt | 324 +++++------- .../ikokuko/FormRecompositionTest.kt | 489 ++++++++++++++++++ .../com/quantipixels/ikokuko/FormStateTest.kt | 159 ++++++ .../com/quantipixels/ikokuko/ValidatorTest.kt | 376 ++++++-------- .../composeApp}/build.gradle.kts | 5 + .../src/androidMain/AndroidManifest.xml | 0 .../ikokuko/sample/MainActivity.kt | 0 .../drawable-v24/ic_launcher_foreground.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../res/mipmap-hdpi/ic_launcher.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../res/mipmap-mdpi/ic_launcher.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../res/mipmap-xxxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../src/androidMain/res/values/strings.xml | 0 .../drawable/compose-multiplatform.xml | 0 .../composeResources/drawable/ikokuko.xml | 0 .../com/quantipixels/ikokuko/sample/App.kt | 9 +- .../quantipixels/ikokuko/sample/FormInput.kt | 26 +- .../quantipixels/ikokuko/sample/SignUpForm.kt | 44 +- .../ikokuko/sample/ComposeAppCommonTest.kt | 0 .../ikokuko/sample/MainViewController.kt | 0 .../iosApp}/Configuration/Config.xcconfig | 0 .../iosApp}/iosApp.xcodeproj/project.pbxproj | 4 +- .../contents.xcworkspacedata | 0 .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../AppIcon.appiconset/app-icon-1024.png | Bin .../iosApp/Assets.xcassets/Contents.json | 0 .../iosApp}/iosApp/ContentView.swift | 0 {iosApp => samples/iosApp}/iosApp/Info.plist | 0 .../Preview Assets.xcassets/Contents.json | 0 .../iosApp}/iosApp/iOSApp.swift | 0 settings.gradle.kts | 2 +- 51 files changed, 1435 insertions(+), 740 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 ikokuko/gradle.properties create mode 100644 ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt create mode 100644 ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt rename {composeApp => samples/composeApp}/build.gradle.kts (94%) rename {composeApp => samples/composeApp}/src/androidMain/AndroidManifest.xml (100%) rename {composeApp => samples/composeApp}/src/androidMain/kotlin/com/quantipixels/ikokuko/sample/MainActivity.kt (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/drawable/ic_launcher_background.xml (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-hdpi/ic_launcher.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-mdpi/ic_launcher.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-xhdpi/ic_launcher.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename {composeApp => samples/composeApp}/src/androidMain/res/values/strings.xml (100%) rename {composeApp => samples/composeApp}/src/commonMain/composeResources/drawable/compose-multiplatform.xml (100%) rename {composeApp => samples/composeApp}/src/commonMain/composeResources/drawable/ikokuko.xml (100%) rename {composeApp => samples/composeApp}/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt (94%) rename {composeApp => samples/composeApp}/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt (93%) rename {composeApp => samples/composeApp}/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt (82%) rename {composeApp => samples/composeApp}/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt (100%) rename {composeApp => samples/composeApp}/src/iosMain/kotlin/com/quantipixels/ikokuko/sample/MainViewController.kt (100%) rename {iosApp => samples/iosApp}/Configuration/Config.xcconfig (100%) rename {iosApp => samples/iosApp}/iosApp.xcodeproj/project.pbxproj (99%) rename {iosApp => samples/iosApp}/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata (100%) rename {iosApp => samples/iosApp}/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {iosApp => samples/iosApp}/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename {iosApp => samples/iosApp}/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png (100%) rename {iosApp => samples/iosApp}/iosApp/Assets.xcassets/Contents.json (100%) rename {iosApp => samples/iosApp}/iosApp/ContentView.swift (100%) rename {iosApp => samples/iosApp}/iosApp/Info.plist (100%) rename {iosApp => samples/iosApp}/iosApp/Preview Content/Preview Assets.xcassets/Contents.json (100%) rename {iosApp => samples/iosApp}/iosApp/iOSApp.swift (100%) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 42fd68c..d934391 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,49 +1,95 @@ -name: Publish Release +name: Publish on: push: tags: - "release-*" +permissions: + contents: read + jobs: - publish: - if: startsWith(github.ref, 'refs/tags/release-') && github.event.base_ref == 'refs/heads/main' - runs-on: ubuntu-latest - permissions: - contents: write # required for GitHub release + verify-x64: + runs-on: macos-15-intel + steps: + - name: Check out source + uses: actions/checkout@v5 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Run Intel simulator checks + run: | + ./gradlew \ + :ikokuko:iosX64Test \ + :samples:composeApp:iosX64Test \ + :samples:composeApp:linkDebugFrameworkIosX64 \ + --stacktrace + + publish: + needs: verify-x64 + runs-on: macos-15 steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Check out source + uses: actions/checkout@v5 with: - fetch-depth: 0 # full history for changelog generation + fetch-depth: 0 + + - name: Verify release commit is on main + run: | + git fetch origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main - - name: Set up JDK - uses: actions/setup-java@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - - name: Cache Gradle - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 - - name: Extract version info - id: vars + - name: Extract version + id: version + shell: bash run: | - TAG=${GITHUB_REF#refs/tags/} - VERSION=${TAG#release-} - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Detected version: $VERSION" + version="${GITHUB_REF_NAME#release-}" + if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Release tag must use release-MAJOR.MINOR.PATCH." >&2 + exit 1 + fi + grep -F "## [$version]" CHANGELOG.md + echo "value=$version" >> "$GITHUB_OUTPUT" + + - name: Run Android and Apple Silicon checks + run: ./gradlew clean check -PVERSION_NAME=${{ steps.version.outputs.value }} --stacktrace - - name: Run Checks & Unit Tests - run: ./gradlew clean check --stacktrace + - name: Build the runnable compatibility showcase + run: | + ./gradlew \ + :samples:composeApp:testDebugUnitTest \ + :samples:composeApp:iosSimulatorArm64Test \ + :samples:composeApp:assembleDebug \ + :samples:composeApp:linkDebugFrameworkIosArm64 \ + :samples:composeApp:linkDebugFrameworkIosSimulatorArm64 \ + --stacktrace + + - name: Build the iOS host app + run: | + xcodebuild \ + -project samples/iosApp/iosApp.xcodeproj \ + -scheme iosApp \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO \ + build - name: Publish to Maven Central env: @@ -52,35 +98,4 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.SIGNING_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }} - run: | - ./gradlew publishAllPublicationsToMavenCentralRepository \ - -PversionName=${{ steps.vars.outputs.version }} - - - name: Install Node + Conventional Changelog - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Generate release changelog - id: changelog - run: | - npm install -g conventional-changelog-cli - conventional-changelog -p angular -r 2 > CHANGELOG.md - echo "body<> $GITHUB_OUTPUT - cat CHANGELOG.md >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - tag_name: ${{ github.ref_name }} - name: "v${{ steps.vars.outputs.version }}" - body: ${{ steps.changelog.outputs.body }} - draft: false - prerelease: false - files: | - build/libs/*.jar - build/outputs/aar/*.aar - build/bin/**/*.klib - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + run: ./gradlew publishAllPublicationsToMavenCentralRepository -PVERSION_NAME=${{ steps.version.outputs.value }} --stacktrace diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6d63d67 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to Ikokuko are recorded in this file. + +## [Unreleased] + +## [0.2.0] + +- Add strict validation state and `Field.shouldDisplayError`. +- Add dependency-aware cross-field validation with `FieldEqualsValidator`. +- Add `CheckedValidator` and `SelectionRangeValidator`. +- Add `rememberSaveableFormState()` and custom value-saver support. +- Change `Validator` to use a read-only `ValidationScope` and declared field dependencies. +- Make built-in validators data classes with structural equality. +- Put `errorMessage` first in every built-in validator constructor. +- Make numeric validators parse integer text without transform lambdas. +- Make pattern validators accept pattern strings instead of `Regex` objects. +- Make `Field` non-nullable and make `Field.isDirty` writable. +- Make field and form validity independent from error visibility. +- Reset values, errors, dirty state, and error visibility before revalidation. +- Limit published and documented support to Android and iOS. +- Remove fixed email and phone validators. Use `MatchPatternValidator` with an application pattern. +- Remove `EqualsValidator`, `NotEqualsValidator`, and validator lambda APIs. +- Remove specialized selection-size validators. Use `SelectionRangeValidator`. +- Remove `Field.component1()` and the `Int`, `Long`, and `Double` field factories. +- Verify Android, Apple Silicon, and Intel iOS targets before publication. + +## [0.1.0] - 2025-11-13 + +- Add the initial typed `Field`, `FormState`, `FormScope`, and `Form` APIs. +- Add reactive validation through `ValidationEffect` and `FormField`. +- Add text, numeric, pattern, equality, membership, and selection validators. +- Add dirty state, error visibility, submit, and reset behavior. +- Add the Compose Multiplatform sample application. +- Publish the library to Maven Central. + +[Unreleased]: https://github.com/quantipixels/ikokuko/compare/release-0.2.0...HEAD +[0.2.0]: https://github.com/quantipixels/ikokuko/releases/tag/release-0.2.0 +[0.1.0]: https://github.com/quantipixels/ikokuko/releases/tag/release-0.1.0 diff --git a/README.md b/README.md index cc867af..4cabddc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **Reactive validation** – runs automatically when field values change. - **Type-safe fields** – `Field` enforces consistent types. - **Composable DSL** – define forms and validators declaratively. -- **Platform-agnostic** – works on Android, Desktop, iOS, JS, and Wasm. +- **Multiplatform** – supports Android and iOS. - **Built-in validators** – text, numeric, pattern, equality, selection. - **Extendable** – implement your own `Validator` easily. @@ -36,7 +36,7 @@ repositories { } dependencies { - implementation("com.quantipixels:ikokuko:0.1.0") + implementation("com.quantipixels:ikokuko:0.2.0") } ``` @@ -46,33 +46,49 @@ dependencies { `FormState` manages all field values, validation errors, and visibility flags for a form. It’s the single source of truth for the form’s current state. -> Optionally pass shouldShowErrors when creating the state to control its initial error visibility behavior. +`FormState.isValid` is strict. It is `false` whenever any field has a stored error. Error visibility does not change validity. + +> Optionally pass `shouldShowErrors` when creating the state to control its initial error visibility behavior. ```kotlin -// Default: errors hidden until submit or manual toggle +// Default: dirty-field errors are hidden until submit or a manual toggle val formState = remember { FormState() } -// Errors become visible after submit or as fields change (dirty) +// Dirty-field errors can be displayed immediately val formState = remember { FormState(shouldShowErrors = true) } ``` +Use `rememberSaveableFormState()` when field values must survive supported platform state restoration: + +```kotlin +val formState = rememberSaveableFormState() +``` + +The default saver supports values accepted by the platform save registry. `Form` keeps its default +state in memory because `Field` can contain any non-null type. For custom field values, pass a +complete `Saver`. You can also use `FormState.saver(valuesSaver)` to provide a +saver for the complete `Map` of field names and values while Ikokuko saves the remaining +form state. + #### shouldShowErrors -Controls when validation errors are globally visible. +Controls whether stored errors can be displayed for dirty fields. A pristine field does not display +its error until it becomes dirty. `submit()` marks all initialized fields as dirty. + |Value|Behaviour|Typical Use Case| |---|---|---| -|`false` (default)|Validation runs continuously, but errors are hidden until submit() or manual toggle.|Most common — errors appear only after first submit.| -|`true`|Errors become visible once a field value changes (becomes dirty) or after submit.|Used when you want validation messages to show immediately upon interaction.| +|`false` (default)|Validation runs continuously, but dirty-field errors remain hidden.|Use when errors must first appear after `submit()` or a manual toggle.| +|`true`|Stored errors are visible for dirty fields. Pristine-field errors remain hidden.|Use when validation messages must appear as a field changes.| You can toggle this flag at any time from either the FormState or inside the FormScope. ```kotlin // From FormState -formState.shouldShowErrors = true // Show all validation errors +formState.shouldShowErrors = true // Permit dirty-field errors formState.shouldShowErrors = false // Hide errors again // From FormScope Form(onSubmit = {}) { // ... - shouldShowErrors = true // Show all validation errors + shouldShowErrors = true // Permit dirty-field errors shouldShowErrors = false // Hide errors again } ``` @@ -129,6 +145,7 @@ fun DemoForm() { #### How fields work - `Field` instances are identified by their name, not by object identity. +- `Field` requires a non-null `T`. Use an explicit value such as an empty string or list to represent no input. - You can safely recreate them on each composition — their state in the form will persist as long as the name stays the same. - `Field` objects are cheap to construct; there’s no need to remember them unless you prefer stable references. @@ -136,11 +153,12 @@ fun DemoForm() { |Case|Behaviour| |---|---| |Same name, same type|Fields share the same value in the FormState. Updating one updates them all.| -|Same name, different type|Causes a crash when FormScope tries to cast the stored value back to the wrong type.| +|Same name, different type|Fields share one map entry because generic types do not affect field equality. Reading the entry through the wrong type can cause a runtime cast error.| |Different names|Fields maintain independent values and validation states.| #### Recommended -> Always ensure that all form fields have unique names within a single `FormScope`. +> Each field name must be unique within a `FormScope`. `ValidationEffect` does not check for +> duplicate names because it cannot distinguish a recreated logical field from a second declaration. --- @@ -148,22 +166,24 @@ fun DemoForm() { You can connect fields to your FormState and enable validation in two ways: +Use one `ValidationEffect` or `FormField` for each field in a form. + - **Manual setup** — call [ValidationEffect](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt) directly to register and validate a field. ```kotlin Form(onSubmit={ println("Email: ${EmailField.value}") }) { ValidationEffect( field = EmailField, - default = "", + initialValue = "", validators = listOf( RequiredValidator("Email required"), - EmailValidator("Invalid email") + MatchPatternValidator("Invalid email", "[^@\\s]+@[^@\\s]+\\.[^@\\s]+") ) ) OutlinedTextField( value = EmailField.value, - isError = !EmailField.isValid, + isError = EmailField.shouldDisplayError, label = { Text("Email") }, - supportingText = EmailField.error?.let { + supportingText = EmailField.error.takeIf { EmailField.shouldDisplayError }?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, onValueChange = { EmailField.value = it } @@ -174,17 +194,17 @@ Form(onSubmit={ println("Email: ${EmailField.value}") }) { ```kotlin FormField( field = EmailField, - default = "", + initialValue = "", validators = listOf( RequiredValidator("Email required"), - EmailValidator("Invalid email") + MatchPatternValidator("Invalid email", "[^@\\s]+@[^@\\s]+\\.[^@\\s]+") ) ) { OutlinedTextField( value = EmailField.value, - isError = !EmailField.isValid, + isError = EmailField.shouldDisplayError, label = { Text("Email") }, - supportingText = EmailField.error?.let { + supportingText = EmailField.error.takeIf { EmailField.shouldDisplayError }?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, onValueChange = { EmailField.value = it } @@ -196,7 +216,7 @@ FormField( ### 5. Overriding Errors Manually -Each `Field` exposes an `error` property that represents its current validation error message, and it can be set or cleared manually at any time. +Each `Field` exposes a raw `error` property that represents its current validation error message. It can be set or cleared manually at any time. Use `shouldDisplayError` to decide whether to render it. ```kotlin var Field<*>.error: String? @@ -245,7 +265,7 @@ fun FormScope.TextInput( Column(modifier = modifier) { OutlinedTextField( value = field.value, - isError = !field.isValid, + isError = field.shouldDisplayError, label = { Text(label) }, placeholder = { Text( @@ -253,7 +273,7 @@ fun FormScope.TextInput( color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f) ) }, - supportingText = field.error?.let { { Text(it) } }, + supportingText = field.error.takeIf { field.shouldDisplayError }?.let { { Text(it) } }, onValueChange = { field.value = it }, singleLine = true, modifier = Modifier.fillMaxWidth() @@ -266,8 +286,9 @@ fun FormScope.TextInput( #### How it works - `ValidationEffect` attaches validators and ensures the field’s value and errors stay reactive. - `field.value` binds the text input to the form state. -- `field.error` provides the active error message when visible. -- `field.isValid` drives the error styling (isError = !field.isValid). +- `field.error` provides the raw active error message. +- `field.isValid` reports strict validation state. +- `field.shouldDisplayError` drives error styling and message visibility. All form logic is encapsulated inside the FormScope, so the field automatically integrates with `submit()`, `reset()`, and global validation visibility. @@ -285,7 +306,7 @@ each input is already wired to its corresponding Field and validation logic. ```kotlin val EmailField = Field.Text("email") val PasswordField = Field.Text("password") -val ConfirmPasswordField = Field.Text("password") +val ConfirmPasswordField = Field.Text("confirm_password") @Composable fun SignUpForm() { @@ -302,7 +323,7 @@ fun SignUpForm() { label = "Email", validators = listOf( RequiredValidator("Email required"), - EmailValidator("Invalid email") + MatchPatternValidator("Invalid email", "[^@\\s]+@[^@\\s]+\\.[^@\\s]+") ) ) TextInput( @@ -315,13 +336,13 @@ fun SignUpForm() { ) // Cross-field validation - // The EqualsValidator references PasswordField.value to ensure both match. + // FieldEqualsValidator declares PasswordField as a dependency. TextInput( field = ConfirmPasswordField, label = "Password Confirmation", validators = listOf( RequiredValidator("password confirmation is required"), - EqualsValidator("passwords must match") { PasswordField.value } + FieldEqualsValidator("passwords must match", PasswordField) ) ) Button(onClick = ::submit, enabled = isValid) { @@ -336,8 +357,9 @@ fun SignUpForm() { - Uses the `TextInput` reusable component defined in the previous section. - `FormState` tracks and validates all registered fields automatically. - The `onSubmit` callback executes only when all validations pass. -- Cross-field validation allows validators to reference other field values dynamically (e.g., matching fields or dependent ranges). +- Cross-field validators declare the fields they read. A dependency value change revalidates the target field. - The `isValid` property enables you to toggle UI elements like buttons based on current form validity. +- `submit()` uses the latest completed reactive validation cycle. Do not assign a field value and call `submit()` synchronously in the same callback. --- ### 8. Built-in [Validators](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt) @@ -360,43 +382,37 @@ fun SignUpForm() { #### Pattern | Validator | Description | |------------|--------------| -| `MatchPatternValidator` | Entire string matches regex | -| `ContainsPatternValidator` | Regex occurs anywhere | -| `EmailValidator` | Standard email format | -| `PhoneNumberValidator` | E.164 phone format | - -#### Equality -| Validator | Description | -|------------------|-------------| -| `InValidator` | Value must be in the allowed set | -| `NotInValidator` | Value must not be in the disallowed set | +| `MatchPatternValidator` | Entire string matches a pattern string | +| `ContainsPatternValidator` | Pattern string occurs anywhere | -#### Membership +#### Equality and membership | Validator | Description | |------------|-------------| -| `EqualsValidator` | Must equal expected value | -| `NotEqualsValidator` | Must differ from unwanted value | +| `FieldEqualsValidator` | Must equal another field value | +| `InValidator` | Value must be in the allowed set | +| `NotInValidator` | Value must not be in the disallowed set | #### Selection / Lists | Validator | Description | |------------|-------------| -| `NonEmptySelectionValidator` | Selection not empty | -| `MinSelectionValidator` | At least N items | -| `MaxSelectionValidator` | At most N items | -| `ExactSelectionValidator` | Exactly N items | -| `SelectionRangeValidator` | Between min and max items | +| `SelectionRangeValidator` | Minimum and optional maximum item count | | `SelectionInValidator` | Ensures all selected values are within the allowed options | +#### Boolean +| Validator | Description | +|------------|-------------| +| `CheckedValidator` | Must be `true` | + #### Custom Validators Implement the `Validator` interface: ```kotlin -class StartsWithValidator( +data class StartsWithValidator( override val errorMessage: String, private val prefix: String ) : Validator { - override fun validate(value: String) = value.startsWith(prefix) + override fun ValidationScope.validate(value: String) = value.startsWith(prefix) } ``` Use it normally: @@ -404,14 +420,67 @@ Use it normally: ```kotlin ValidationEffect( field = UsernameField, - default = "", + initialValue = "", validators = listOf(StartsWithValidator("Must start with @", "@")) ) ``` +`ValidationEffect` uses the validator list as an effect key. Built-in validators are data +classes so an equivalent inline validator remains equal across recompositions. This prevents +validation from restarting when no validation rule changed. + +Custom validators used inline should also have structural equality. A data class is the +simplest option. Avoid lambda-backed validators. A new lambda normally has a new identity on +each recomposition, even when it has the same behavior. This can restart validation and replace +an external error without a field or dependency value change. + +If a custom validator must contain a lambda, remember the validator instance: + +```kotlin +val validator = remember { + CustomValidator("Invalid value") { value -> checkValue(value) } +} +``` + +Declare every field read during validation in `dependencies`. + +--- + +## Migrating from 0.1.0 + +- Change nullable field types to non-null types. `Field` now requires `T : Any`. +- Remove field destructuring and replace the removed `Field.Int`, `Field.Long`, and `Field.Double` + factories with the generic `Field(name)` constructor when required. +- Give each field a unique name within its `FormScope`. Same-name fields share one stored value. +- Rename the `default` argument of `ValidationEffect` and `FormField` to `initialValue`. +- Replace `field.markAsDirty()` with `field.isDirty = true`. The `isDirty` property is now writable. +- Remove explicit `null` arguments from `submit(onInvalid)`. Its fallback is now a non-null no-op + callback. +- Use `field.shouldDisplayError` for rendering. `field.error`, `field.isValid`, and + `FormState.isValid` now expose strict stored state. +- Update custom validators to implement `fun ValidationScope.validate(value: T)`. Declare each + field read by validation in `dependencies`. +- Use stable structural equality for inline custom validators. Prefer data classes, and remember + validators that contain lambdas. +- Replace numeric transform lambdas with the string-backed integer `MinValidator`, `MaxValidator`, + and `RangeValidator` constructors. These validators parse values with `toIntOrNull()`. +- Pass pattern strings instead of `Regex` objects to `MatchPatternValidator` and + `ContainsPatternValidator`. +- Replace `EmailValidator` and `PhoneNumberValidator` with `MatchPatternValidator` and an + application-owned pattern. +- Replace `EqualsValidator` with `FieldEqualsValidator` for field comparison. Implement fixed-value + equality, inequality, and other uncommon rules as custom validators. +- Replace `NonEmptySelectionValidator`, `MinSelectionValidator`, `MaxSelectionValidator`, and + `ExactSelectionValidator` with `SelectionRangeValidator`. Its `max` argument can be `null` for an + unbounded maximum. +- Use `CheckedValidator` for required Boolean fields. Use `InValidator`, `NotInValidator`, and + `SelectionInValidator` for membership rules. +- Account for reset behavior. `reset()` now clears values, errors, dirty state, and error visibility, + then revalidates initialized effects. + --- -## Demo - [Sample App](/composeApp) +## Demo - [Sample App](/samples/composeApp) See **Ikokuko** — the reactive, type-safe form validation library for Compose Multiplatform (Android & iOS) — in action: diff --git a/gradle.properties b/gradle.properties index 6f8e6ea..cffce08 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,4 +9,27 @@ org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true + +# Project and publication identity. +GROUP=com.quantipixels +VERSION_NAME=0.2.0-SNAPSHOT +POM_ARTIFACT_ID=ikokuko + +POM_NAME=ikokuko +POM_DESCRIPTION=Reactive, type-safe form validation for Compose Multiplatform (Android & iOS) +POM_INCEPTION_YEAR=2025 +POM_URL=https://github.com/quantipixels/ikokuko + +POM_LICENSE_NAME=Apache License 2.0 +POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENSE_DIST=repo + +POM_SCM_URL=https://github.com/quantipixels/ikokuko +POM_SCM_CONNECTION=scm:git:git://github.com/quantipixels/ikokuko.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/quantipixels/ikokuko.git + +POM_DEVELOPER_ID=eosobande +POM_DEVELOPER_NAME=Oluwaseun Sobande +POM_DEVELOPER_EMAIL=eosobande@gmail.com +POM_DEVELOPER_URL=https://github.com/eosobande/ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 34f9e31..b25c48e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,12 +9,15 @@ androidx-testExt = "1.3.0" composeHotReload = "1.0.0-rc02" composeMultiplatform = "1.9.1" kotlin = "2.2.20" +kotlinx-coroutines = "1.10.2" runner = "1.7.0" core = "1.7.0" vanniktechMavenPublish = "0.35.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +compose-runtime-saveable = { module = "org.jetbrains.compose.runtime:runtime-saveable", version.ref = "composeMultiplatform" } androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } @@ -31,4 +34,4 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } androidLint = { id = "com.android.lint", version.ref = "agp" } -vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } \ No newline at end of file +vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } diff --git a/ikokuko/build.gradle.kts b/ikokuko/build.gradle.kts index f528158..8ca1846 100644 --- a/ikokuko/build.gradle.kts +++ b/ikokuko/build.gradle.kts @@ -11,8 +11,8 @@ plugins { alias(libs.plugins.vanniktechMavenPublish) } -group = "com.quantipixels" -version = findProperty("versionName") ?: "0.0.0-SNAPSHOT" +group = providers.gradleProperty("GROUP").get() +version = providers.gradleProperty("VERSION_NAME").get() kotlin { androidTarget { @@ -37,9 +37,11 @@ kotlin { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) + api(libs.compose.runtime.saveable) } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } } } @@ -47,15 +49,15 @@ kotlin { android { namespace = "com.quantipixels" compileSdk = libs.versions.android.compileSdk.get().toInt() + testOptions { + unitTests.isReturnDefaultValues = true + } defaultConfig { minSdk = libs.versions.android.minSdk.get().toInt() } } mavenPublishing { - publishToMavenCentral( - automaticRelease = true, - validateDeployment = true - ) + publishToMavenCentral(automaticRelease = true) signAllPublications() -} \ No newline at end of file +} diff --git a/ikokuko/gradle.properties b/ikokuko/gradle.properties deleted file mode 100644 index 302b212..0000000 --- a/ikokuko/gradle.properties +++ /dev/null @@ -1,17 +0,0 @@ -POM_NAME=ikokuko -POM_DESCRIPTION=Reactive, type-safe form validation for Compose Multiplatform (Android & iOS) -POM_INCEPTION_YEAR=2025 -POM_URL=https://github.com/quantipixels/ikokuko - -POM_LICENSE_NAME=Apache License 2.0 -POM_LICENSE_URL="https://github.com/quantipixels/ikokuko/LICENSE -POM_LICENSE_DIST=repo - -POM_SCM_URL=https://github.com/quantipixels/ikokuko -POM_SCM_CONNECTION=scm:git:git://github.com/quantipixels/ikokuko.git -POM_SCM_DEV_CONNECTION=scm:git:ssh://github.com:quantipixels/ikokuko.git - -POM_DEVELOPER_ID=eosobande -POM_DEVELOPER_NAME=Oluwaseun Sobande -POM_DEVELOPER_EMAIL=eosobande@gmail.com -POM_DEVELOPER_URL=https://github.com/eosobande/ \ No newline at end of file diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Field.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Field.kt index a828387..93d3f76 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Field.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Field.kt @@ -7,22 +7,18 @@ import kotlin.jvm.JvmInline * Logical identifier for a form field of type [T]. * * The [name] must be unique within a form. - * Two fields with the same name are considered the same field for storage/validation + * Two fields with the same name are considered the same field for storage and validation, + * even when their declared generic types differ. Generic types do not affect value-class equality. */ @JvmInline @Immutable -value class Field(val name: String) { - operator fun component1() = name - +value class Field(val name: String) { @Suppress("FunctionName") companion object { fun Text(name: String) = Field(name) fun Boolean(name: String) = Field(name) fun Range(name: String) = Field>(name) - fun Int(name: String) = Field(name) fun Float(name: String) = Field(name) - fun Long(name: String) = Field(name) - fun Double(name: String) = Field(name) - fun List(name: String) = Field>(name) + fun List(name: String) = Field>(name) } -} \ No newline at end of file +} diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt index b6f321f..bb5525a 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt @@ -8,6 +8,9 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue /** @@ -54,7 +57,8 @@ class FormState(shouldShowErrors: Boolean = false) { * the same name will point to the same stored value. * * Warning: If two fields with the same name are defined with different types, - * a type cast error will occur when retrieving the value through [FormScope.value]. + * they share one map entry. A type cast error can occur when retrieving the value through + * [FormScope.value]. [ValidationEffect] does not detect duplicate names. * * @throws ClassCastException if multiple fields share the same [Field.name] but declare * different types, causing a type mismatch on value retrieval via [FormScope.value]. @@ -64,7 +68,7 @@ class FormState(shouldShowErrors: Boolean = false) { /** * Tracks all [Field]s that have been modified since form initialization or reset. * - * A field is added to this list the first time its value changes from its default. + * A field is added to this list the first time its initialized value changes. * This allows the form to distinguish between fields the user has interacted with * ("dirty" fields) and those that are still untouched. * @@ -86,6 +90,10 @@ class FormState(shouldShowErrors: Boolean = false) { */ internal val errors = mutableStateMapOf() + /** Changes when reset must restart validation for otherwise equal inputs. */ + internal var resetKey by mutableStateOf(0) + private set + /** * Controls when validation errors become visible. * Validation still runs reactively regardless of this flag. @@ -100,20 +108,16 @@ class FormState(shouldShowErrors: Boolean = false) { get() = dirtyFields.isNotEmpty() /** - * Indicates whether the form is currently in a valid (non-error) state. - * - * Validation errors are ignored while [shouldShowErrors] is `false`, - * so this property represents perceived validity according to the - * current error visibility policy rather than strict validation state. + * Indicates whether the form has no stored validation or external errors. */ val isValid: Boolean - get() = !shouldShowErrors || !isDirty || errors.isEmpty() + get() = errors.isEmpty() /** - * Clears all field values and hides validation errors. + * Clears all field values, errors, dirty state, and error visibility. * * After reset, all [ValidationEffect] composables in the form will automatically - * reinitialize their associated [Field]s to their provided default values and mark them + * reinitialize their associated [Field]s to their provided initial values and mark them * as pristine on the next recomposition. * * This fully restores the form to its initial state. @@ -121,6 +125,97 @@ class FormState(shouldShowErrors: Boolean = false) { fun reset() { dirtyFields.clear() values.clear() + errors.clear() shouldShowErrors = false + resetKey++ + } + + companion object { + /** Saves form values supported by the current platform save registry. */ + val Saver: Saver = formStateSaver(DefaultFormValuesSaver) + + /** Creates a form-state saver that transforms the complete field-name-to-value map. */ + fun saver( + valuesSaver: Saver, Saveable> + ): Saver = formStateSaver(valuesSaver) + } +} + +private val DefaultFormValuesSaver: Saver, Any> = listSaver( + save = { values -> + buildList { + add(values.size) + values.forEach { (name, value) -> + add(name) + add(value) + } + } + }, + restore = { saved -> + val size = saved.first() as Int + buildMap { + repeat(size) { index -> + val offset = 1 + index * 2 + put(saved[offset] as String, saved[offset + 1]) + } + } + } +) + +private fun formStateSaver( + valuesSaver: Saver, Saveable> +): Saver = listSaver( + save = { state -> + val savedValues = with(valuesSaver) { + save(state.values.entries.associate { (field, value) -> field.name to value }) + } ?: return@listSaver emptyList() + + buildList { + add(savedValues) + add(state.shouldShowErrors) + add(state.dirtyFields.size) + state.dirtyFields.forEach { add(it.name) } + add(state.errors.size) + state.errors.forEach { (name, error) -> + add(name) + add(error) + } + } + }, + restore = { saved -> + var index = 0 + @Suppress("UNCHECKED_CAST") + val restoredValues = valuesSaver.restore(saved[index++] as Saveable) + ?: return@listSaver null + val state = FormState(shouldShowErrors = saved[index++] as Boolean) + + restoredValues.forEach { (name, value) -> + state.values[Field(name)] = value + } + + val dirtyCount = saved[index++] as Int + repeat(dirtyCount) { + state.dirtyFields.add(Field(saved[index++] as String)) + } + + val errorCount = saved[index++] as Int + repeat(errorCount) { + state.errors[saved[index++] as String] = saved[index++] as String + } + state } -} \ No newline at end of file +) + +/** + * Remembers a [FormState] through compatible platform state restoration. + * + * Every field value must be supported by the platform save registry. For custom field values, + * pass a saver created with [FormState.saver] or replace [saver] completely. + */ +@Composable +fun rememberSaveableFormState( + shouldShowErrors: Boolean = false, + saver: Saver = FormState.Saver +): FormState = rememberSaveable(saver = saver) { + FormState(shouldShowErrors) +} diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt index ff3bdff..b08db68 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt @@ -1,79 +1,79 @@ package com.quantipixels.ikokuko import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect /** - * Provides access to all form-related data and actions within a [Form] composition. - * - * A [FormScope] exposes extension properties on [Field] for reading and writing values, - * checking validation results, and interacting with the form lifecycle through [submit] - * and [reset]. - * - * It’s created automatically by [Form] and passed to both the form body and onSubmit. + * Provides typed field data and form actions inside a [Form] composition. */ @Suppress("UNCHECKED_CAST") class FormScope internal constructor( - private val state: FormState, + internal val state: FormState, private val onSubmit: FormScope.() -> Unit ) { - /** All [Field]s currently registered in this form. */ + internal val validationScope: ValidationScope = StateValidationScope(this) + + /** All initialized fields in this form. */ val fields: Set> get() = state.values.keys - /** True if the [Field] has been initialized and holds a value. */ + /** Returns `true` when this field has an initialized value. */ val Field<*>.isInitialized: Boolean - get() = this in fields + get() = this in state.values /** - * The current value of this [Field]. + * The current value of this field. * - * Fields are initialized with their default value through [ValidationEffect]. - * Reading this property before initialization throws an error. + * A changed value marks an initialized field as dirty. Initial assignment by + * [ValidationEffect] bypasses this setter and keeps the field pristine. */ var Field.value: T get() = state.values[this] as? T - ?: error("Field '$name' accessed before initialization. Call ValidationEffect(...) for this field first.") + ?: error( + "Field '$name' accessed before initialization. " + + "Call ValidationEffect(...) for this field first." + ) set(value) { - state.values[this] = value + val wasInitialized = isInitialized + val previousValue = state.values[this] + if (!wasInitialized || previousValue != value) { + state.values[this] = value + if (wasInitialized) isDirty = true + } } - /** - * Indicates whether this [Field] has been modified since it was initialized or last reset. - * - * A field becomes dirty when its value changes from the default provided in - * [ValidationEffect], or when [markAsDirty] is called manually. - * - * This property is typically used to control when validation errors or visual feedback - * should be displayed for a particular field. - */ - val Field.isDirty: Boolean + /** Controls whether this field is marked as dirty. */ + var Field<*>.isDirty: Boolean get() = this in state.dirtyFields - - /** - * The current validation error message for this [Field], or `null` if none. - * - * Prevents premature error display before user interaction or explicit error visibility. - */ - var Field<*>.error: String? - get() = if (isDirty && state.shouldShowErrors) state.errors[name] else null set(value) { - if (value != null) { - state.errors[name] = value + if (value) { + if (!isDirty) state.dirtyFields.add(this) } else { - state.errors.remove(name) + state.dirtyFields.remove(this) } } - /** Indicates whether this [Field] is currently valid. */ + /** The current stored error. Error visibility does not change this value. */ + var Field<*>.error: String? + get() = state.errors[name] + set(value) { + if (value == null) state.errors.remove(name) else state.errors[name] = value + } + + /** Returns `true` when this field has no stored error. */ val Field<*>.isValid: Boolean - get() = !state.shouldShowErrors || error == null + get() = name !in state.errors + + /** Returns `true` when the UI must display this field's stored error. */ + val Field<*>.shouldDisplayError: Boolean + get() = isDirty && state.shouldShowErrors && !isValid - /** @see [FormState.isValid] */ + /** Returns `true` when the form has no stored errors. */ val isValid: Boolean get() = state.isValid - /** @see [FormState.shouldShowErrors] */ + /** Controls whether dirty field errors can be displayed. */ var shouldShowErrors: Boolean get() = state.shouldShowErrors set(value) { @@ -81,108 +81,64 @@ class FormScope internal constructor( } /** - * Marks this [Field] as dirty, indicating that its value has been modified - * or interacted with by the user. - * - * Normally, fields are automatically marked as dirty when their [FormScope.value] - * is changed, but this can be invoked manually for custom interaction flows - * or programmatic updates. - */ - fun Field<*>.markAsDirty() { - if (!isDirty) state.dirtyFields.add(this) - } - - /** - * Shows errors and triggers form submission. - * - * If the form is valid, [onSubmit] is executed. - * Otherwise, [onInvalid] is invoked (if provided). + * Marks all initialized fields as dirty and enables error display. + * Calls the form submit callback only when the form is valid. */ - fun submit(onInvalid: (() -> Unit)? = null) { - fields.forEach { it.markAsDirty() } + fun submit(onInvalid: () -> Unit = {}) { + fields.forEach { it.isDirty = true } state.shouldShowErrors = true - if (state.isValid) onSubmit() else onInvalid?.invoke() + if (state.isValid) onSubmit() else onInvalid() } - /** @see [FormState.reset] */ + /** Clears all state in this form. */ fun reset() = state.reset() } +private class StateValidationScope( + private val scope: FormScope +) : ValidationScope { + override val Field.value: T + get() = with(scope) { value } +} + /** - * Attaches validation logic to a [field] within a [FormScope]. - * - * This composable reacts to changes in the field’s [FormScope.value] property or in [validators], - * running each validator and updating the field’s [FormScope.error] accordingly. - * - * Validation runs continuously, but errors are only visible when - * [FormState.shouldShowErrors] is `true`. - * - * The field is initialized with [default] if it has no current value. - * - * @param field The [Field] to validate. - * @param default The default value applied when the field is first initialized - * or reinitialized after a form reset. - * @param validators The list of [Validator]s used to validate the field’s value. + * Initializes [field] once and validates it when its value, validators, or declared + * dependency values change. Use one ValidationEffect for each field in a form. */ @Composable fun FormScope.ValidationEffect( field: Field, - default: T, + initialValue: T, validators: List> ) { - // initialize (or reinitialize after reset). - // Must set default before launching validation; if not initialized, - // reading field.value in LaunchedEffect would throw. - if (!field.isInitialized) field.value = default - - // Skip validation until the field value diverges from its default. - if (field.value != default && !field.isDirty) { - LaunchedEffect(Unit) { field.markAsDirty() } + if (!field.isInitialized) state.values[field] = initialValue + + DisposableEffect(field) { + onDispose { field.error = null } } - // Always validate; visibility of the error is controlled by Field.error itself. - LaunchedEffect(field.value, validators) { - field.error = validators.firstOrNull { !it.validate(field.value) }?.errorMessage + val value = field.value + val dependencyValues = validators + .flatMap(Validator<*>::dependencies) + .distinct() + .mapNotNull(state.values::get) + + LaunchedEffect(value, validators, dependencyValues, state.resetKey) { + field.error = validators.firstOrNull { validator -> + validator.dependencies.all { it.isInitialized } && + !with(validator) { validationScope.validate(value) } + }?.errorMessage } } -/** - * A convenience composable that automatically attaches [ValidationEffect] - * to the given [field] before rendering its [content]. - * - * This ensures that validation, dirty-state tracking, and default value - * initialization are always applied without requiring the consumer to call - * [ValidationEffect] manually. - * - * Typical usage wraps an input element that binds to field.value, - * displays field.error, and updates the value as the user types. - * - * Example: - * ``` - * FormField(EmailField, "", listOf(EmailValidator("Invalid email"))) { - * OutlinedTextField( - * value = EmailField.value, - * onValueChange = { EmailField.value = it }, - * isError = !EmailField.isValid, - * supportingText = EmailField.error?.let { { Text(it) } }, - * label = { Text("Email") } - * ) - * } - * ``` - * - * @param field The [Field] managed by this input. - * @param default The default value applied when the field is first initialized - * or reinitialized after a form reset. - * @param validators A list of [Validator]s used to validate the field's value. - * @param content The composable content that displays and interacts with the field. - */ +/** Initializes and validates [field] before composing [content]. */ @Composable fun FormScope.FormField( field: Field, - default: T, + initialValue: T, validators: List> = emptyList(), content: @Composable () -> Unit ) { - ValidationEffect(field, default, validators) + ValidationEffect(field, initialValue, validators) content() -} \ No newline at end of file +} diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt index 814a139..da4e28a 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt @@ -1,273 +1,195 @@ package com.quantipixels.ikokuko -/** Matches standard email formats: local@domain */ -private val EmailRegex = - Regex("[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+") - -/** Matches E.164-style phone numbers (e.g., +14155552671, +2348012345678) */ -private val PhoneNumberRegex = Regex("^\\+[1-9]\\d{0,2}[1-9]\\d{3,11}$") +/** Provides read-only access to values used during validation. */ +interface ValidationScope { + /** The initialized value of this field. */ + val Field.value: T +} /** * Contract for validating values of type [T]. + * + * [ValidationEffect] uses validator equality to decide when validation must restart. + * Custom validators created inline should use structural equality, such as a data class. + * Avoid lambda properties because recreated lambdas use identity equality. */ -interface Validator { - /** The error message returned when [validate] fails. */ +interface Validator { + /** The error message stored when [validate] returns `false`. */ val errorMessage: String - fun validate(value: T): Boolean -} + /** Fields whose value changes must re-run this validator. */ + val dependencies: List> + get() = emptyList() -/** Ensures a string is not blank. */ -class RequiredValidator(override val errorMessage: String) : Validator { - override fun validate(value: String) = value.isNotBlank() + /** Returns `true` when [value] satisfies this validator. */ + fun ValidationScope.validate(value: T): Boolean } -/** - * Base class for numeric validators that operate on [String] input. - * - * This handles the common logic of parsing the input, blank-value handling, - * and delegating the actual numeric comparison to [predicate]. - * - * Typical implementations include validators for minimum, maximum, or range checks. - * - * @param T The numeric type to validate (e.g., [Int], [Float], [Double]). - * - * @see Validator - */ -abstract class NumericValidator : Validator - where T : Number, T : Comparable { - - /** Whether a blank input should be considered valid. */ - abstract val allowEmpty: Boolean - - /** Converts the raw string into the target numeric type, or `null` if parsing fails. */ - abstract val transform: (value: String) -> T? - - override fun validate(value: String): Boolean = - if (value.isBlank()) { - allowEmpty - } else { - transform(value)?.let(::predicate) ?: false - } - - /** Performs the actual numeric comparison or check for the parsed value. */ - protected abstract fun predicate(value: T): Boolean +/** Ensures that a string is not blank. */ +data class RequiredValidator( + override val errorMessage: String +) : Validator { + override fun ValidationScope.validate(value: String) = value.isNotBlank() } -/** Validates that a string represents an integer greater than or equal to [min]. */ -class MinValidator( +/** Validates that an integer string is greater than or equal to [min]. */ +data class MinValidator( override val errorMessage: String, - override val allowEmpty: Boolean = false, - private val min: T, - override val transform: (String) -> T? -) : NumericValidator() where T : Number, T : Comparable { - override fun predicate(value: T) = value >= min + private val min: Int, + private val allowEmpty: Boolean = false +) : Validator { + override fun ValidationScope.validate(value: String): Boolean = + if (value.isBlank()) allowEmpty else value.toIntOrNull()?.let { it >= min } ?: false } -/** Validates that a string represents an integer less than or equal to [max]. */ -class MaxValidator( +/** Validates that an integer string is less than or equal to [max]. */ +data class MaxValidator( override val errorMessage: String, - override val allowEmpty: Boolean = false, - private val max: T, - override val transform: (String) -> T? -) : NumericValidator() where T : Number, T : Comparable { - override fun predicate(value: T) = value <= max + private val max: Int, + private val allowEmpty: Boolean = false +) : Validator { + override fun ValidationScope.validate(value: String): Boolean = + if (value.isBlank()) allowEmpty else value.toIntOrNull()?.let { it <= max } ?: false } -/** - * Validates that a numeric string is within [min]..[max]. - * - * @throws IllegalArgumentException if [min] is greater than [max] or either is negative. - * */ -class RangeValidator( +/** Validates that an integer string is within [min] through [max], inclusive. */ +data class RangeValidator( override val errorMessage: String, - override val allowEmpty: Boolean = false, - private val min: T, - private val max: T, - override val transform: (String) -> T? -) : NumericValidator() where T : Number, T : Comparable { + private val min: Int, + private val max: Int, + private val allowEmpty: Boolean = false +) : Validator { init { require(min <= max) { "min ($min) must not be greater than max ($max)" } } - override fun predicate(value: T) = value in min..max + override fun ValidationScope.validate(value: String): Boolean = + if (value.isBlank()) allowEmpty else value.toIntOrNull()?.let { it in min..max } ?: false } -/** Validates that a string’s length is exactly [length] characters. */ -class LengthValidator( +/** Validates that a string has exactly [length] characters. */ +data class LengthValidator( override val errorMessage: String, private val length: Int ) : Validator { - override fun validate(value: String) = value.length == length + override fun ValidationScope.validate(value: String) = value.length == length } -/** Validates that a string’s length is at least [length] characters. */ -class MinLengthValidator( +/** Validates that a string has at least [length] characters. */ +data class MinLengthValidator( override val errorMessage: String, private val length: Int ) : Validator { - override fun validate(value: String) = value.length >= length + override fun ValidationScope.validate(value: String) = value.length >= length } -/** Validates that a string’s length does not exceed [length] characters. */ -class MaxLengthValidator( +/** Validates that a string has no more than [length] characters. */ +data class MaxLengthValidator( override val errorMessage: String, private val length: Int ) : Validator { - override fun validate(value: String) = value.length <= length + override fun ValidationScope.validate(value: String) = value.length <= length } -/** Validates that the entire string matches the given [pattern]. */ -open class MatchPatternValidator( +/** Validates that the complete string matches [pattern]. */ +data class MatchPatternValidator( override val errorMessage: String, - private val pattern: Regex + private val pattern: String ) : Validator { - override fun validate(value: String) = pattern.matches(value) -} + private val regex = Regex(pattern) -/** Validates that the string contains a match for the given [pattern]. */ -open class ContainsPatternValidator( - override val errorMessage: String, - private val pattern: Regex -) : Validator { - override fun validate(value: String) = pattern.containsMatchIn(value) + override fun ValidationScope.validate(value: String) = regex.matches(value) } -/** Validates that a string matches a standard email pattern. */ -class EmailValidator(errorMessage: String) : MatchPatternValidator(errorMessage, EmailRegex) - -/** Validates that a string matches an international phone number pattern (E.164). */ -class PhoneNumberValidator(errorMessage: String) : - MatchPatternValidator(errorMessage, PhoneNumberRegex) - -/** - * Validates that a value is equal to a given [expected] value. - * - * Useful for confirming matching inputs (e.g., password confirmation). - */ -class EqualsValidator( +/** Validates that a string contains a match for [pattern]. */ +data class ContainsPatternValidator( override val errorMessage: String, - private val expected: () -> T -) : Validator { - override fun validate(value: T) = value == expected() -} + private val pattern: String +) : Validator { + private val regex = Regex(pattern) -/** Validates that a value is not equal to a given [unwanted] value. */ -class NotEqualsValidator( - override val errorMessage: String, - private val unwanted: () -> T -) : Validator { - override fun validate(value: T) = value != unwanted() + override fun ValidationScope.validate(value: String) = regex.containsMatchIn(value) } -/** - * Validates that a value is contained within a set of allowed values. - * - * This validator is useful when restricting input to a predefined list - * of options, such as gender, country codes, or enum-like selections. - * - * @param T The type of the value being validated. - * @property allowed The collection of allowed values. - * @property errorMessage The message returned when validation fails. - */ -class InValidator( - private val allowed: Collection, +/** Validates that a Boolean value is `true`. */ +data class CheckedValidator( override val errorMessage: String -) : Validator { - override fun validate(value: T): Boolean = value in allowed +) : Validator { + override fun ValidationScope.validate(value: Boolean) = value } -/** - * Validates that a value is **not** contained within a collection - * of disallowed or forbidden values. - * - * Use this validator to prevent specific inputs from being accepted, - * such as reserved usernames or blocked keywords. - * - * @param T The type of the value being validated. - * @property disallowed The collection of values that are not permitted. - * @property errorMessage The message returned when validation fails. - */ -class NotInValidator( - private val disallowed: Collection, - override val errorMessage: String +/** Validates that a value equals the value of [field]. */ +data class FieldEqualsValidator( + override val errorMessage: String, + private val field: Field ) : Validator { - override fun validate(value: T): Boolean = value !in disallowed -} + override val dependencies: List> = listOf(field) -/** Validates that a selection is not empty. */ -class NonEmptySelectionValidator( - override val errorMessage: String -) : Validator> { - override fun validate(value: List) = value.isNotEmpty() + override fun ValidationScope.validate(value: T) = value == field.value } -/** Validates that a selection contains at least [min] items. */ -class MinSelectionValidator( +/** Validates that a value is in [allowed]. */ +@ConsistentCopyVisibility +data class InValidator private constructor( override val errorMessage: String, - private val min: Int -) : Validator> { - override fun validate(value: List) = value.size >= min -} + private val allowed: Set +) : Validator { + constructor(errorMessage: String, allowed: Collection) : this( + errorMessage = errorMessage, + allowed = allowed.toSet() + ) -/** Validates that a selection contains at most [max] items. */ -class MaxSelectionValidator( - override val errorMessage: String, - private val max: Int -) : Validator> { - override fun validate(value: List) = value.size <= max + override fun ValidationScope.validate(value: T) = value in allowed } -/** Validates that a selection contains exactly [size] items. */ -class ExactSelectionValidator( +/** Validates that a value is not in [disallowed]. */ +@ConsistentCopyVisibility +data class NotInValidator private constructor( override val errorMessage: String, - private val size: Int -) : Validator> { - override fun validate(value: List) = value.size == size + private val disallowed: Set +) : Validator { + constructor(errorMessage: String, disallowed: Collection) : this( + errorMessage = errorMessage, + disallowed = disallowed.toSet() + ) + + override fun ValidationScope.validate(value: T) = value !in disallowed } -/** - * Validates that a selection contains between [min] and [max] items (inclusive). - * - * Useful for cases where a user must choose within a range, such as "Select 2–5 options". - * - * @throws IllegalArgumentException if [min] is greater than [max] or either is negative. - */ -class SelectionRangeValidator( +/** Validates the inclusive selection size from [min] through [max]. */ +data class SelectionRangeValidator( override val errorMessage: String, - private val min: Int, - private val max: Int -) : Validator> { + private val min: Int = 0, + private val max: Int? = null +) : Validator> { init { require(min >= 0) { "min must not be negative (was $min)" } - require(min <= max) { "min ($min) must not be greater than max ($max)" } + require(max == null || max >= min) { + "min ($min) must not be greater than max ($max)" + } } - override fun validate(value: List) = value.size in min..max + override fun ValidationScope.validate(value: List<*>) = + value.size >= min && (max == null || value.size <= max) } -/** - * Validates that all elements of a list are contained within - * a set of allowed values. - * - * This validator is typically used for validating multi-select inputs - * or lists of user selections, ensuring that each selected item is - * part of the allowed options. - * - * @param T The element type of the collection. - * @property allowed The collection of allowed values. - * @property allowEmpty Whether an empty list should be considered valid. - * @property errorMessage The message returned when validation fails. - */ -class SelectionInValidator( - private val allowed: Collection, - private val allowEmpty: Boolean = false, - override val errorMessage: String +/** Validates that all selected values are in [allowed]. */ +@ConsistentCopyVisibility +data class SelectionInValidator private constructor( + override val errorMessage: String, + private val allowed: Set, + private val allowEmpty: Boolean = false ) : Validator> { - override fun validate(value: List): Boolean = - if (value.isEmpty()) { - allowEmpty - } else { - value.all { it in allowed } - } -} \ No newline at end of file + constructor( + errorMessage: String, + allowed: Collection, + allowEmpty: Boolean = false + ) : this( + errorMessage = errorMessage, + allowed = allowed.toSet(), + allowEmpty = allowEmpty + ) + + override fun ValidationScope.validate(value: List): Boolean = + if (value.isEmpty()) allowEmpty else value.all { it in allowed } +} diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt new file mode 100644 index 0000000..e58d8e5 --- /dev/null +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -0,0 +1,489 @@ +package com.quantipixels.ikokuko + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class FormRecompositionTest { + + @Test + fun equivalent_inline_validator_recreation_preserves_external_error_without_field_change() = + runCompositionTest { + val field = Field.Text("email") + var recompositionTrigger by mutableStateOf(0) + var observedRecomposition = 0 + lateinit var formScope: FormScope + + setContent { + Form(onSubmit = {}) { + val currentRecomposition = recompositionTrigger + + ValidationEffect( + field = field, + initialValue = "", + validators = listOf(RequiredValidator(VALIDATION_ERROR)) + ) + + SideEffect { + formScope = this + observedRecomposition = currentRecomposition + } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + field.isDirty = true + shouldShowErrors = true + field.error = EXTERNAL_ERROR + + assertEquals("", field.value) + assertEquals(EXTERNAL_ERROR, field.error) + } + } + + runOnIdle { recompositionTrigger++ } + waitForIdle() + + runOnIdle { + assertEquals(1, observedRecomposition) + with(formScope) { + assertEquals("", field.value) + assertEquals(EXTERNAL_ERROR, field.error) + } + } + } + + @Test + fun changing_a_dependency_revalidates_the_dependent_field() = runCompositionTest { + val password = Field.Text("password") + val confirmation = Field.Text("confirmation") + lateinit var formScope: FormScope + + setContent { + Form(onSubmit = {}) { + ValidationEffect(password, initialValue = "same", validators = emptyList()) + ValidationEffect( + confirmation, + initialValue = "same", + validators = listOf(FieldEqualsValidator(MISMATCH_ERROR, password)) + ) + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertNull(confirmation.error) + password.value = "different" + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals(MISMATCH_ERROR, confirmation.error) + } + } + } + + @Test + fun validator_waits_for_an_uninitialized_dependency() = runCompositionTest { + val password = Field.Text("password") + val confirmation = Field.Text("confirmation") + var initializePassword by mutableStateOf(false) + lateinit var formScope: FormScope + + setContent { + Form(onSubmit = {}) { + ValidationEffect( + confirmation, + initialValue = "confirmation", + validators = listOf(FieldEqualsValidator(MISMATCH_ERROR, password)) + ) + if (initializePassword) { + ValidationEffect(password, initialValue = "password", validators = emptyList()) + } + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertFalse(password.isInitialized) + assertNull(confirmation.error) + } + initializePassword = true + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertTrue(password.isInitialized) + assertEquals(MISMATCH_ERROR, confirmation.error) + } + } + } + + @Test + fun validator_waits_for_two_dependencies_and_revalidates_when_either_changes() = + runCompositionTest { + val first = Field.Text("first") + val second = Field.Text("second") + val combined = Field.Text("combined") + var initializeFirst by mutableStateOf(false) + var initializeSecond by mutableStateOf(false) + lateinit var formScope: FormScope + val validator = object : Validator { + override val errorMessage = MISMATCH_ERROR + override val dependencies = listOf(first, second) + + override fun ValidationScope.validate(value: String) = + value == first.value + second.value + } + + setContent { + Form(onSubmit = {}) { + ValidationEffect( + combined, + initialValue = "AB", + validators = listOf(validator) + ) + if (initializeFirst) { + ValidationEffect(first, initialValue = "A", validators = emptyList()) + } + if (initializeSecond) { + ValidationEffect(second, initialValue = "B", validators = emptyList()) + } + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { assertNull(combined.error) } + initializeFirst = true + } + waitForIdle() + + runOnIdle { + with(formScope) { assertNull(combined.error) } + initializeSecond = true + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertNull(combined.error) + first.value = "X" + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals(MISMATCH_ERROR, combined.error) + first.value = "A" + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertNull(combined.error) + second.value = "Y" + } + } + waitForIdle() + + runOnIdle { + with(formScope) { assertEquals(MISMATCH_ERROR, combined.error) } + } + } + + @Test + fun reset_applies_the_current_initial_value_without_marking_the_field_dirty() = + runCompositionTest { + val field = Field.Text("name") + val state = FormState() + var initialValue by mutableStateOf("first") + lateinit var formScope: FormScope + + setContent { + Form(state = state, onSubmit = {}) { + ValidationEffect(field, initialValue = initialValue, validators = emptyList()) + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals("first", field.value) + assertFalse(field.isDirty) + field.value = "edited" + assertTrue(field.isDirty) + } + initialValue = "current" + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals("edited", field.value) + reset() + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals("current", field.value) + assertFalse(field.isDirty) + } + } + } + + @Test + fun reset_revalidates_an_invalid_field_with_the_same_initial_value() = runCompositionTest { + val field = Field.Text("name") + lateinit var formScope: FormScope + + setContent { + Form(onSubmit = {}) { + ValidationEffect( + field, + initialValue = "", + validators = listOf(RequiredValidator(VALIDATION_ERROR)) + ) + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals(VALIDATION_ERROR, field.error) + reset() + assertNull(field.error) + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals("", field.value) + assertEquals(VALIDATION_ERROR, field.error) + } + } + } + + @Test + fun a_value_change_replaces_an_external_error_with_validation_result() = runCompositionTest { + val field = Field.Text("email") + lateinit var formScope: FormScope + + setContent { + Form(onSubmit = {}) { + ValidationEffect( + field, + initialValue = "", + validators = listOf(RequiredValidator(VALIDATION_ERROR)) + ) + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + field.error = EXTERNAL_ERROR + field.value = "valid" + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertNull(field.error) + } + } + } + + @Test + fun removing_and_readding_validation_preserves_value_and_dirty_state_but_not_hidden_error() = + runCompositionTest { + val field = Field.Text("conditional") + var showField by mutableStateOf(true) + lateinit var formScope: FormScope + + setContent { + Form(onSubmit = {}) { + if (showField) { + ValidationEffect( + field, + initialValue = "", + validators = listOf(RequiredValidator(VALIDATION_ERROR)) + ) + } + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + field.isDirty = true + assertTrue(field in fields) + assertEquals(VALIDATION_ERROR, field.error) + assertFalse(isValid) + } + showField = false + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertTrue(field in fields) + assertEquals("", field.value) + assertTrue(field.isDirty) + assertNull(field.error) + assertTrue(isValid) + } + showField = true + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertTrue(field in fields) + assertEquals("", field.value) + assertTrue(field.isDirty) + assertEquals(VALIDATION_ERROR, field.error) + assertFalse(isValid) + } + } + } + + @Test + fun validator_receives_a_read_only_scope_instead_of_the_form_scope() = runCompositionTest { + val field = Field.Text("scope") + var receiverWasFormScope: Boolean? = null + val validator = object : Validator { + override val errorMessage = "invalid" + + override fun ValidationScope.validate(value: String): Boolean { + receiverWasFormScope = (this as Any) is FormScope + return true + } + } + + setContent { + Form(onSubmit = {}) { + ValidationEffect(field, initialValue = "value", validators = listOf(validator)) + } + } + waitForIdle() + + runOnIdle { assertEquals(false, receiverWasFormScope) } + } + + private fun runCompositionTest( + block: suspend CompositionFixture.() -> Unit + ): TestResult = runTest { + val frameClock = BroadcastFrameClock() + val effectDispatcher = StandardTestDispatcher(testScheduler) + val recomposer = Recomposer(effectDispatcher + frameClock) + val composition = Composition(UnitApplier(), recomposer) + val recomposerJob = backgroundScope.launch( + UnconfinedTestDispatcher(testScheduler) + frameClock + ) { + recomposer.runRecomposeAndApplyChanges() + } + val fixture = CompositionFixture( + scope = this, + composition = composition, + frameClock = frameClock, + recomposerJob = recomposerJob + ) + + try { + fixture.block() + } finally { + composition.dispose() + recomposer.cancel() + recomposerJob.cancel() + } + } + + private class CompositionFixture( + private val scope: TestScope, + private val composition: Composition, + private val frameClock: BroadcastFrameClock, + @Suppress("unused") private val recomposerJob: Job + ) { + fun setContent(content: @Composable () -> Unit) { + composition.setContent(content) + } + + fun runOnIdle(block: () -> Unit) { + settle() + block() + } + + fun waitForIdle() { + settle() + } + + private fun settle() { + repeat(3) { frame -> + Snapshot.sendApplyNotifications() + frameClock.sendFrame((scope.testScheduler.currentTime + frame) * 1_000_000) + scope.runCurrent() + } + } + } + + private class UnitApplier : AbstractApplier(Unit) { + override fun insertBottomUp(index: Int, instance: Unit) = Unit + + override fun insertTopDown(index: Int, instance: Unit) = Unit + + override fun move(from: Int, to: Int, count: Int) = Unit + + override fun onClear() = Unit + + override fun remove(index: Int, count: Int) = Unit + } + + private companion object { + const val EXTERNAL_ERROR = "Email is already registered" + const val VALIDATION_ERROR = "Email is required" + const val MISMATCH_ERROR = "Values do not match" + } +} diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt new file mode 100644 index 0000000..a6ee471 --- /dev/null +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt @@ -0,0 +1,159 @@ +package com.quantipixels.ikokuko + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FormStateTest { + + private val saveScope = SaverScope { true } + + @Test + fun validity_is_strict_and_error_display_is_separate() { + val field = Field.Text("email") + val state = FormState() + val scope = FormScope(state) {} + state.values[field] = "" + + with(scope) { + field.error = "required" + + assertFalse(field.isValid) + assertFalse(isValid) + assertEquals("required", field.error) + assertFalse(field.shouldDisplayError) + + field.isDirty = true + assertFalse(field.shouldDisplayError) + + shouldShowErrors = true + assertTrue(field.shouldDisplayError) + assertFalse(field.isValid) + assertFalse(isValid) + } + } + + @Test + fun value_setter_marks_only_initialized_value_changes_dirty() { + val field = Field.Text("name") + val state = FormState() + val scope = FormScope(state) {} + state.values[field] = "initial" + + with(scope) { + field.value = "initial" + assertFalse(field.isDirty) + + field.value = "changed" + assertTrue(field.isDirty) + + field.isDirty = false + assertFalse(field.isDirty) + + field.isDirty = true + assertTrue(field.isDirty) + } + } + + @Test + fun reset_clears_values_errors_dirty_state_and_visibility() { + val field = Field.Text("email") + val state = FormState(shouldShowErrors = true) + val scope = FormScope(state) {} + state.values[field] = "value" + + with(scope) { + field.isDirty = true + field.error = "error" + reset() + + assertFalse(field.isInitialized) + assertFalse(field in fields) + assertFalse(field.isDirty) + assertNull(field.error) + assertFalse(shouldShowErrors) + assertTrue(isValid) + } + } + + @Test + fun submit_uses_strict_validity_and_marks_initialized_fields_dirty() { + val field = Field.Text("email") + val state = FormState() + var submitted = false + var invalid = false + val scope = FormScope(state) { submitted = true } + state.values[field] = "" + + with(scope) { + field.error = "required" + submit { invalid = true } + + assertFalse(submitted) + assertTrue(invalid) + assertTrue(field.isDirty) + assertTrue(field.shouldDisplayError) + + field.error = null + submit() + assertTrue(submitted) + } + } + + @Test + fun default_saver_restores_values_dirty_errors_and_visibility() { + val name = Field.Text("name") + val age = Field("age") + val state = FormState(shouldShowErrors = true) + state.values[name] = "Ada" + state.values[age] = 37 + state.dirtyFields.add(name) + state.errors[name.name] = "invalid" + + val restored = FormState.Saver.roundTrip(state) + val scope = FormScope(restored) {} + + with(scope) { + assertEquals("Ada", name.value) + assertEquals(37, age.value) + assertTrue(name.isDirty) + assertEquals("invalid", name.error) + assertTrue(shouldShowErrors) + } + } + + @Test + fun custom_values_saver_restores_non_saveable_field_values() { + val profile = Field("profile") + val state = FormState() + state.values[profile] = Profile("Ada") + val valuesSaver = Saver, List>( + save = { values -> + values.flatMap { (name, value) -> listOf(name, (value as Profile).name) } + }, + restore = { saved -> + saved.chunked(2).associate { (name, value) -> name to Profile(value) } + } + ) + + val restored = FormState.saver(valuesSaver).roundTrip(state) + val scope = FormScope(restored) {} + + with(scope) { + assertEquals(Profile("Ada"), profile.value) + } + } + + private fun Saver.roundTrip( + state: FormState + ): FormState { + val saved = with(this) { saveScope.save(state) } ?: error("State was not saved") + return restore(saved) ?: error("State was not restored") + } + + private data class Profile(val name: String) +} diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/ValidatorTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/ValidatorTest.kt index 3e0e22e..e9569b5 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/ValidatorTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/ValidatorTest.kt @@ -1,300 +1,232 @@ package com.quantipixels.ikokuko import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class ValidatorTest { - private val allowed = listOf("A", "B", "C") - private val disallowed = listOf("X", "Y", "Z") - - @Test - fun requiredValidator_passes_on_non_blank() { - val validator = RequiredValidator("Cannot be blank") - assertTrue(validator.validate("abc")) + private val scope = object : ValidationScope { + override val Field.value: T + get() = error("This validator must not read a field") } @Test - fun requiredValidator_fails_on_blank() { + fun requiredValidator_validates_non_blank_text() { val validator = RequiredValidator("Cannot be blank") - assertFalse(validator.validate(" ")) - } - @Test - fun minValidator_allows_equal_or_greater() { - val validator = MinValidator("too small", min = 5, transform = String::toIntOrNull) - assertTrue(validator.validate("5")) - assertTrue(validator.validate("6")) - assertFalse(validator.validate("4")) + assertTrue(validator.test("abc")) + assertFalse(validator.test(" ")) } @Test - fun maxValidator_allows_equal_or_less() { - val validator = MaxValidator("too large", max = 10.5, transform = String::toDoubleOrNull) - assertTrue(validator.validate("10.5")) - assertTrue(validator.validate("5.6")) - assertFalse(validator.validate("11.34")) - } - - @Test - fun rangeValidator_checks_bounds() { - val validator = RangeValidator( - "out of range", - min = 1f, - max = 5f, - transform = String::toFloatOrNull - ) - assertTrue(validator.validate("1")) - assertTrue(validator.validate("3")) - assertFalse(validator.validate("0")) - assertFalse(validator.validate("6")) - } + fun integer_validators_check_bounds_and_invalid_text() { + val minimum = MinValidator("too small", min = 5) + val maximum = MaxValidator("too large", max = 10) + val range = RangeValidator("out of range", min = 1, max = 5) - @Test - fun rangeValidator_throws_when_min_greater_than_max() { - assertFailsWith { - RangeValidator("Invalid", min = 5, max = 3, transform = String::toIntOrNull) - } - } + assertTrue(minimum.test("5")) + assertTrue(minimum.test("6")) + assertFalse(minimum.test("4")) + assertFalse(minimum.test("5.5")) + assertFalse(minimum.test("999999999999999999999")) - @Test - fun numericValidators_allow_empty_if_configured() { - val validator = MinValidator( - "too small", - min = 5, - allowEmpty = true, - transform = String::toIntOrNull - ) - assertTrue(validator.validate("")) - } + assertTrue(maximum.test("10")) + assertTrue(maximum.test("5")) + assertFalse(maximum.test("11")) - @Test - fun numericValidators_fail_on_invalid_number() { - val validator = MinValidator("too small", min = 5, transform = String::toIntOrNull) - assertFalse(validator.validate("abc")) + assertTrue(range.test("1")) + assertTrue(range.test("3")) + assertTrue(range.test("5")) + assertFalse(range.test("0")) + assertFalse(range.test("6")) } @Test - fun lengthValidator_passes_when_length_matches() { - val validator = LengthValidator("must be 4 chars", 4) - assertTrue(validator.validate("test")) - assertFalse(validator.validate("testing")) + fun integer_validators_allow_blank_only_when_configured() { + assertTrue(MinValidator("too small", min = 5, allowEmpty = true).test("")) + assertFalse(MinValidator("too small", min = 5).test("")) + assertTrue(MaxValidator("too large", max = 5, allowEmpty = true).test(" ")) + assertTrue(RangeValidator("invalid", min = 1, max = 5, allowEmpty = true).test("")) } @Test - fun minLengthValidator_passes_on_longer() { - val validator = MinLengthValidator("too short", 3) - assertTrue(validator.validate("abcd")) - assertFalse(validator.validate("a")) + fun rangeValidator_rejects_reversed_bounds() { + assertFailsWith { + RangeValidator("Invalid", min = 5, max = 3) + } } @Test - fun maxLengthValidator_passes_on_shorter() { - val validator = MaxLengthValidator("too long", 5) - assertTrue(validator.validate("abc")) - assertFalse(validator.validate("abcdef")) + fun length_validators_check_text_length() { + assertTrue(LengthValidator("must be 4 chars", 4).test("test")) + assertFalse(LengthValidator("must be 4 chars", 4).test("testing")) + assertTrue(MinLengthValidator("too short", 3).test("abcd")) + assertFalse(MinLengthValidator("too short", 3).test("a")) + assertTrue(MaxLengthValidator("too long", 5).test("abc")) + assertFalse(MaxLengthValidator("too long", 5).test("abcdef")) } @Test - fun matchPatternValidator_matches_regex() { - val validator = MatchPatternValidator("invalid", Regex("^abc.*")) - assertTrue(validator.validate("abcdef")) - assertFalse(validator.validate("zzz")) - } + fun pattern_validators_use_string_patterns() { + val match = MatchPatternValidator("invalid", "^abc.*") + val contains = ContainsPatternValidator("invalid", "\\d") - @Test - fun containsPatternValidator_contains_regex() { - val validator = ContainsPatternValidator("invalid", Regex("\\d")) - assertTrue(validator.validate("ab1c2")) - assertFalse(validator.validate("abc")) + assertTrue(match.test("abcdef")) + assertFalse(match.test("zzz")) + assertTrue(contains.test("ab1c2")) + assertFalse(contains.test("abc")) } @Test - fun emailValidator_validates_common_emails() { - val validator = EmailValidator("invalid email") - assertTrue(validator.validate("eosobande@ikokuko.dev")) - assertTrue(validator.validate("sample.ikokuko+test@domain.co.uk")) - assertFalse(validator.validate("invalid@")) - } + fun checkedValidator_requires_true() { + val validator = CheckedValidator("must be checked") - @Test - fun phoneValidator_validates_e164_format() { - val validator = PhoneNumberValidator("invalid phone") - assertTrue(validator.validate("+14155552671")) - assertTrue(validator.validate("+2348012345678")) - assertFalse(validator.validate("08012345678")) - assertFalse(validator.validate("+0123456789")) // 0 not allowed after '+' + assertTrue(validator.test(true)) + assertFalse(validator.test(false)) } @Test - fun equalsValidator_compares_values() { - val validator = EqualsValidator("not equal", expected = { "abc" }) - assertTrue(validator.validate("abc")) - assertFalse(validator.validate("def")) - } + fun membership_validators_check_allowed_and_disallowed_values() { + val allowed = InValidator("Value not allowed", listOf("A", "B", "C")) + val disallowed = NotInValidator("Value forbidden", listOf("X", "Y", "Z")) - @Test - fun notEqualsValidator_compares_values() { - val validator = NotEqualsValidator("must differ", unwanted = { "abc" }) - assertTrue(validator.validate("def")) - assertFalse(validator.validate("abc")) + assertTrue(allowed.test("A")) + assertFalse(allowed.test("X")) + assertTrue(disallowed.test("A")) + assertFalse(disallowed.test("X")) } @Test - fun nonEmptySelectionValidator_passes_when_not_empty() { - val validator = NonEmptySelectionValidator("Selection required") - assertTrue(validator.validate(listOf("A")), "Expected non-empty list to be valid") - } + fun selectionRangeValidator_supports_bounded_and_unbounded_ranges() { + val nonempty = SelectionRangeValidator("required", min = 1) + val maximum = SelectionRangeValidator("too many", max = 2) + val exact = SelectionRangeValidator("exactly two", min = 2, max = 2) - @Test - fun nonEmptySelectionValidator_fails_when_empty() { - val validator = NonEmptySelectionValidator("Selection required") - assertFalse(validator.validate(emptyList()), "Expected empty list to be invalid") - } + assertFalse(nonempty.test(emptyList())) + assertTrue(nonempty.test(listOf("A"))) + assertTrue(nonempty.test(List(1_000) { "$it" })) - @Test - fun minSelectionValidator_passes_when_above_min() { - val validator = MinSelectionValidator("At least 2 required", min = 2) - assertTrue( - validator.validate(listOf("A", "B", "C")), - "Expected list with >=2 items to be valid" - ) - } + assertTrue(maximum.test(emptyList())) + assertTrue(maximum.test(listOf("A", "B"))) + assertFalse(maximum.test(listOf("A", "B", "C"))) - @Test - fun minSelectionValidator_fails_when_below_min() { - val validator = MinSelectionValidator("At least 2 required", min = 2) - assertFalse(validator.validate(listOf("A")), "Expected list with <2 items to be invalid") + assertFalse(exact.test(listOf("A"))) + assertTrue(exact.test(listOf("A", "B"))) + assertFalse(exact.test(listOf("A", "B", "C"))) } @Test - fun maxSelectionValidator_passes_when_below_or_equal_max() { - val validator = MaxSelectionValidator("At most 3 allowed", max = 3) - assertTrue(validator.validate(listOf("A", "B")), "Expected list with <=3 items to be valid") - assertTrue( - validator.validate(listOf("A", "B", "C")), - "Expected list with ==3 items to be valid" - ) + fun selectionRangeValidator_rejects_invalid_bounds() { + assertFailsWith { + SelectionRangeValidator("Invalid", min = -1) + } + assertFailsWith { + SelectionRangeValidator("Invalid", min = 5, max = 3) + } } @Test - fun maxSelectionValidator_fails_when_above_max() { - val validator = MaxSelectionValidator("At most 3 allowed", max = 3) - assertFalse( - validator.validate(listOf("A", "B", "C", "D")), - "Expected list with >3 items to be invalid" - ) - } + fun selectionInValidator_checks_every_item_and_empty_policy() { + val allowed = listOf("A", "B", "C") - @Test - fun exactSelectionValidator_passes_when_size_matches() { - val validator = ExactSelectionValidator("Exactly 2 required", size = 2) + assertTrue(SelectionInValidator("invalid", allowed).test(listOf("A", "B"))) + assertFalse(SelectionInValidator("invalid", allowed).test(listOf("A", "Z"))) assertTrue( - validator.validate(listOf("A", "B")), - "Expected list with exactly 2 items to be valid" + SelectionInValidator("invalid", allowed, allowEmpty = true) + .test(emptyList()) ) - } - - @Test - fun exactSelectionValidator_fails_when_size_differs() { - val validator = ExactSelectionValidator("Exactly 2 required", size = 2) - assertFalse(validator.validate(listOf("A")), "Expected list with 1 item to be invalid") assertFalse( - validator.validate(listOf("A", "B", "C")), - "Expected list with 3 items to be invalid" + SelectionInValidator("invalid", allowed, allowEmpty = false) + .test(emptyList()) ) } @Test - fun selectionRangeValidator_passes_within_bounds() { - val validator = SelectionRangeValidator("Select between 2 and 4", min = 2, max = 4) - assertTrue(validator.validate(listOf("A", "B")), "Expected 2 selections to be valid") - assertTrue(validator.validate(listOf("A", "B", "C")), "Expected 3 selections to be valid") - assertTrue( - validator.validate(listOf("A", "B", "C", "D")), - "Expected 4 selections to be valid" + fun first_party_validator_instances_have_structural_equality() { + val field = Field.Text("other") + val first = listOf( + RequiredValidator("required"), + MinValidator("minimum", min = 1), + MaxValidator("maximum", max = 2), + RangeValidator("range", min = 1, max = 2), + LengthValidator("length", length = 2), + MinLengthValidator("minimum length", length = 1), + MaxLengthValidator("maximum length", length = 2), + MatchPatternValidator("match", "\\d"), + ContainsPatternValidator("contains", "\\d"), + CheckedValidator("checked"), + FieldEqualsValidator("equal", field), + InValidator("in", listOf("A")), + NotInValidator("not in", listOf("A")), + SelectionRangeValidator("selection range", min = 1), + SelectionInValidator("selection in", listOf("A")) ) - } - - @Test - fun selectionRangeValidator_fails_outside_bounds() { - val validator = SelectionRangeValidator("Select between 2 and 4", min = 2, max = 4) - assertFalse(validator.validate(listOf("A")), "Expected <2 selections to be invalid") - assertFalse( - validator.validate(listOf("A", "B", "C", "D", "E")), - "Expected >4 selections to be invalid" - ) - } - - @Test - fun selectionRangeValidator_throws_when_min_greater_than_max() { - assertFailsWith { - SelectionRangeValidator("Invalid", min = 5, max = 3) + val second = first.map { validator -> + when (validator) { + is RequiredValidator -> RequiredValidator("required") + is MinValidator -> MinValidator("minimum", min = 1) + is MaxValidator -> MaxValidator("maximum", max = 2) + is RangeValidator -> RangeValidator("range", min = 1, max = 2) + is LengthValidator -> LengthValidator("length", length = 2) + is MinLengthValidator -> MinLengthValidator("minimum length", length = 1) + is MaxLengthValidator -> MaxLengthValidator("maximum length", length = 2) + is MatchPatternValidator -> MatchPatternValidator("match", "\\d") + is ContainsPatternValidator -> ContainsPatternValidator("contains", "\\d") + is CheckedValidator -> CheckedValidator("checked") + is FieldEqualsValidator<*> -> FieldEqualsValidator("equal", field) + is InValidator<*> -> InValidator("in", listOf("A")) + is NotInValidator<*> -> NotInValidator("not in", listOf("A")) + is SelectionRangeValidator -> SelectionRangeValidator("selection range", min = 1) + is SelectionInValidator<*> -> + SelectionInValidator("selection in", listOf("A")) + else -> error("Missing equality fixture for $validator") + } } - } - @Test - fun `InValidator returns true for allowed value`() { - val validator = InValidator(allowed, "Value not allowed") - assertTrue(validator.validate("A")) - assertTrue(validator.validate("B")) + assertEquals(first, second) } @Test - fun `InValidator returns false for disallowed value`() { - val validator = InValidator(allowed, "Value not allowed") - assertFalse(validator.validate("X")) - assertFalse(validator.validate("Z")) - } + fun collection_validators_snapshot_mutable_rules_for_behavior_and_equality() { + val values = mutableListOf("A") + val allowedBeforeMutation = InValidator("invalid", values) + val disallowedBeforeMutation = NotInValidator("invalid", values) + val selectionBeforeMutation = SelectionInValidator("invalid", values) - @Test - fun `InValidator returns false for value not in list`() { - val validator = InValidator(allowed, "Invalid value") - assertFalse(validator.validate("D")) - } + values += "B" - @Test - fun `NotInValidator returns true for allowed value`() { - val validator = NotInValidator(disallowed, "Value forbidden") - assertTrue(validator.validate("A")) - assertTrue(validator.validate("123")) - } + val allowedAfterMutation = InValidator("invalid", values) + val disallowedAfterMutation = NotInValidator("invalid", values) + val selectionAfterMutation = SelectionInValidator("invalid", values) - @Test - fun `NotInValidator returns false for disallowed value`() { - val validator = NotInValidator(disallowed, "Value forbidden") - assertFalse(validator.validate("X")) - assertFalse(validator.validate("Y")) + assertFalse(allowedBeforeMutation.test("B")) + assertTrue(allowedAfterMutation.test("B")) + assertTrue(disallowedBeforeMutation.test("B")) + assertFalse(disallowedAfterMutation.test("B")) + assertFalse(selectionBeforeMutation.test(listOf("B"))) + assertTrue(selectionAfterMutation.test(listOf("B"))) + assertNotEquals(allowedBeforeMutation, allowedAfterMutation) + assertNotEquals(disallowedBeforeMutation, disallowedAfterMutation) + assertNotEquals(selectionBeforeMutation, selectionAfterMutation) } @Test - fun `SelectionInValidator returns true when all values allowed`() { - val validator = SelectionInValidator(allowed, errorMessage = "Invalid selection") - val result = validator.validate(listOf("A", "B")) - assertTrue(result) - } + fun validator_is_contravariant() { + val anyValidator = object : Validator { + override val errorMessage = "invalid" - @Test - fun `SelectionInValidator returns false when any value disallowed`() { - val validator = SelectionInValidator(allowed, errorMessage = "Invalid selection") - val result = validator.validate(listOf("A", "Z")) - assertFalse(result) - } + override fun ValidationScope.validate(value: Any) = value.toString().isNotEmpty() + } + val stringValidator: Validator = anyValidator - @Test - fun `SelectionInValidator allows empty list when allowEmpty true`() { - val validator = SelectionInValidator(allowed, allowEmpty = true, errorMessage = "Invalid selection") - val result = validator.validate(emptyList()) - assertTrue(result) + assertTrue(stringValidator.test("value")) } - @Test - fun `SelectionInValidator rejects empty list when allowEmpty false`() { - val validator = SelectionInValidator(allowed, allowEmpty = false, errorMessage = "Invalid selection") - val result = validator.validate(emptyList()) - assertFalse(result) + private fun Validator.test(value: T): Boolean = with(this) { + scope.validate(value) } - -} \ No newline at end of file +} diff --git a/composeApp/build.gradle.kts b/samples/composeApp/build.gradle.kts similarity index 94% rename from composeApp/build.gradle.kts rename to samples/composeApp/build.gradle.kts index e34086d..e23264d 100644 --- a/composeApp/build.gradle.kts +++ b/samples/composeApp/build.gradle.kts @@ -16,6 +16,7 @@ kotlin { } listOf( + iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { iosTarget -> @@ -78,3 +79,7 @@ android { dependencies { debugImplementation(compose.uiTooling) } + +compose.resources { + packageOfResClass = "com.quantipixels.ikokuko.sample.generated.resources" +} diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/samples/composeApp/src/androidMain/AndroidManifest.xml similarity index 100% rename from composeApp/src/androidMain/AndroidManifest.xml rename to samples/composeApp/src/androidMain/AndroidManifest.xml diff --git a/composeApp/src/androidMain/kotlin/com/quantipixels/ikokuko/sample/MainActivity.kt b/samples/composeApp/src/androidMain/kotlin/com/quantipixels/ikokuko/sample/MainActivity.kt similarity index 100% rename from composeApp/src/androidMain/kotlin/com/quantipixels/ikokuko/sample/MainActivity.kt rename to samples/composeApp/src/androidMain/kotlin/com/quantipixels/ikokuko/sample/MainActivity.kt diff --git a/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/samples/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml rename to samples/composeApp/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml diff --git a/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml b/samples/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml similarity index 100% rename from composeApp/src/androidMain/res/drawable/ic_launcher_background.xml rename to samples/composeApp/src/androidMain/res/drawable/ic_launcher_background.xml diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml rename to samples/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to samples/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/samples/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png rename to samples/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/samples/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png rename to samples/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/samples/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png rename to samples/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/samples/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png rename to samples/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/samples/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png rename to samples/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/samples/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png rename to samples/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/samples/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png rename to samples/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/samples/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png rename to samples/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/samples/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png rename to samples/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/samples/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png rename to samples/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/composeApp/src/androidMain/res/values/strings.xml b/samples/composeApp/src/androidMain/res/values/strings.xml similarity index 100% rename from composeApp/src/androidMain/res/values/strings.xml rename to samples/composeApp/src/androidMain/res/values/strings.xml diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/samples/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml similarity index 100% rename from composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml rename to samples/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml diff --git a/composeApp/src/commonMain/composeResources/drawable/ikokuko.xml b/samples/composeApp/src/commonMain/composeResources/drawable/ikokuko.xml similarity index 100% rename from composeApp/src/commonMain/composeResources/drawable/ikokuko.xml rename to samples/composeApp/src/commonMain/composeResources/drawable/ikokuko.xml diff --git a/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt similarity index 94% rename from composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt rename to samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt index 29a2805..d9cbe7c 100644 --- a/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,8 +26,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import ikokuko_cmp.composeapp.generated.resources.Res -import ikokuko_cmp.composeapp.generated.resources.ikokuko +import com.quantipixels.ikokuko.sample.generated.resources.Res +import com.quantipixels.ikokuko.sample.generated.resources.ikokuko import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -38,10 +37,6 @@ fun App() { MaterialTheme { var signUpData by remember { mutableStateOf(null) } - LaunchedEffect(signUpData) { - println(signUpData) - } - Column( modifier = Modifier .safeContentPadding() diff --git a/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt rename to samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt index 6ea6401..d6ad72f 100644 --- a/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt @@ -66,7 +66,7 @@ fun FormScope.TextInput( Column(modifier = modifier) { OutlinedTextField( value = field.value, - isError = !field.isValid, + isError = field.shouldDisplayError, label = { Text(label) }, placeholder = { Text( @@ -74,7 +74,11 @@ fun FormScope.TextInput( color = MaterialTheme.colorScheme.secondary.copy(alpha = .7f) ) }, - supportingText = field.error?.let { { Text(it) } }, + supportingText = if (field.shouldDisplayError) { + field.error?.let { { Text(it) } } + } else { + null + }, onValueChange = { field.value = it }, singleLine = true, visualTransformation = if (isPassword) { @@ -110,7 +114,7 @@ fun FormScope.CheckBox( checked = field.value, onCheckedChange = { field.value = it }, colors = CheckboxDefaults.colors( - uncheckedColor = if (!field.isValid) { + uncheckedColor = if (field.shouldDisplayError) { MaterialTheme.colorScheme.error } else { Color.Unspecified @@ -122,7 +126,7 @@ fun FormScope.CheckBox( ) Text(text = label) } - field.error?.let { + field.error.takeIf { field.shouldDisplayError }?.let { Text( text = it, style = MaterialTheme.typography.bodySmall, @@ -174,10 +178,10 @@ fun FormScope.RadioGroup( selected = transform(item) == field.value, onClick = { onItemClick(item) }, colors = RadioButtonDefaults.colors( - unselectedColor = if (field.isValid) { - Color.Unspecified - } else { + unselectedColor = if (field.shouldDisplayError) { MaterialTheme.colorScheme.error + } else { + Color.Unspecified } ) ) @@ -185,7 +189,7 @@ fun FormScope.RadioGroup( } } } - field.error?.let { + field.error.takeIf { field.shouldDisplayError }?.let { Text( text = it, style = MaterialTheme.typography.bodySmall, @@ -242,7 +246,7 @@ fun FormScope.CheckGroup( checked = item in field.value, onCheckedChange = { onItemClick(item) }, colors = CheckboxDefaults.colors( - uncheckedColor = if (!field.isValid) { + uncheckedColor = if (field.shouldDisplayError) { MaterialTheme.colorScheme.error } else { Color.Unspecified @@ -256,7 +260,7 @@ fun FormScope.CheckGroup( } } } - field.error?.let { + field.error.takeIf { field.shouldDisplayError }?.let { Text( text = it, style = MaterialTheme.typography.bodySmall, @@ -267,4 +271,4 @@ fun FormScope.CheckGroup( Spacer(Modifier.height(12.dp)) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt similarity index 82% rename from composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt rename to samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt index 751986e..0a40fc6 100644 --- a/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt @@ -17,15 +17,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.quantipixels.ikokuko.CheckedValidator import com.quantipixels.ikokuko.ContainsPatternValidator -import com.quantipixels.ikokuko.EmailValidator -import com.quantipixels.ikokuko.EqualsValidator -import com.quantipixels.ikokuko.ExactSelectionValidator import com.quantipixels.ikokuko.Field +import com.quantipixels.ikokuko.FieldEqualsValidator import com.quantipixels.ikokuko.Form +import com.quantipixels.ikokuko.MatchPatternValidator import com.quantipixels.ikokuko.MinLengthValidator -import com.quantipixels.ikokuko.PhoneNumberValidator import com.quantipixels.ikokuko.RequiredValidator +import com.quantipixels.ikokuko.SelectionRangeValidator data class SignUpData( val phoneNumber: String, @@ -45,10 +45,12 @@ private val CapacityField = Field.Text("capacity") private val ProjectsField = Field.List("projects") private val TermsField = Field.Boolean("terms") -private val SymbolRegex = Regex("[^A-Za-z0-9 ]") -private val DigitRegex = Regex("\\d") -private val UppercaseRegex = Regex("[A-Z]") -private val LowercaseRegex = Regex("[a-z]") +private const val SymbolPattern = "[^A-Za-z0-9 ]" +private const val DigitPattern = "\\d" +private const val UppercasePattern = "[A-Z]" +private const val LowercasePattern = "[a-z]" +private const val EmailPattern = "[^@\\s]+@[^@\\s]+\\.[^@\\s]+" +private const val PhoneNumberPattern = "\\+[1-9]\\d{1,14}" enum class Capacity { Personal, Professional @@ -89,7 +91,7 @@ fun SignUpForm( placeholder = "+353 85 616 4829", validators = listOf( RequiredValidator("phone number is required"), - PhoneNumberValidator("must be a valid phone number") + MatchPatternValidator("must be a valid phone number", PhoneNumberPattern) ) ) TextInput( @@ -98,7 +100,7 @@ fun SignUpForm( placeholder = "sample@ikokuko.dev", validators = listOf( RequiredValidator("email is required"), - EmailValidator("must be a valid email address") + MatchPatternValidator("must be a valid email address", EmailPattern) ) ) TextInput( @@ -108,10 +110,10 @@ fun SignUpForm( validators = listOf( RequiredValidator("password is required"), MinLengthValidator("must be at least 8 characters", 8), - ContainsPatternValidator("must contain an uppercase character", UppercaseRegex), - ContainsPatternValidator("must contain a lowercase character", LowercaseRegex), - ContainsPatternValidator("must contain a digit", DigitRegex), - ContainsPatternValidator("must contain a symbol", SymbolRegex) + ContainsPatternValidator("must contain an uppercase character", UppercasePattern), + ContainsPatternValidator("must contain a lowercase character", LowercasePattern), + ContainsPatternValidator("must contain a digit", DigitPattern), + ContainsPatternValidator("must contain a symbol", SymbolPattern) ), trailingIcon = { PasswordVisibilityToggle( @@ -126,7 +128,7 @@ fun SignUpForm( isPassword = confirmationHidden, validators = listOf( RequiredValidator("password confirmation is required"), - EqualsValidator("passwords must match") { PasswordField.value } + FieldEqualsValidator("passwords must match", PasswordField) ), trailingIcon = { PasswordVisibilityToggle( @@ -145,13 +147,19 @@ fun SignUpForm( field = ProjectsField, label = "What type of projects do you intend to use this library for?", items = Project.entries, - validators = listOf(ExactSelectionValidator("you must select 2 options", 2)) + validators = listOf( + SelectionRangeValidator( + errorMessage = "you must select 2 options", + min = 2, + max = 2 + ) + ) ) CheckBox( field = TermsField, label = "I agree to the Terms & Conditions", validators = listOf( - EqualsValidator("you must agree with the terms & conditions") { true } + CheckedValidator("you must agree with the terms & conditions") ) ) Spacer(Modifier.height(8.dp)) @@ -178,4 +186,4 @@ fun SignUpForm( } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt b/samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt similarity index 100% rename from composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt rename to samples/composeApp/src/commonTest/kotlin/com/quantipixels/ikokuko/sample/ComposeAppCommonTest.kt diff --git a/composeApp/src/iosMain/kotlin/com/quantipixels/ikokuko/sample/MainViewController.kt b/samples/composeApp/src/iosMain/kotlin/com/quantipixels/ikokuko/sample/MainViewController.kt similarity index 100% rename from composeApp/src/iosMain/kotlin/com/quantipixels/ikokuko/sample/MainViewController.kt rename to samples/composeApp/src/iosMain/kotlin/com/quantipixels/ikokuko/sample/MainViewController.kt diff --git a/iosApp/Configuration/Config.xcconfig b/samples/iosApp/Configuration/Config.xcconfig similarity index 100% rename from iosApp/Configuration/Config.xcconfig rename to samples/iosApp/Configuration/Config.xcconfig diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/samples/iosApp/iosApp.xcodeproj/project.pbxproj similarity index 99% rename from iosApp/iosApp.xcodeproj/project.pbxproj rename to samples/iosApp/iosApp.xcodeproj/project.pbxproj index ec7ee1e..5b3e835 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/samples/iosApp/iosApp.xcodeproj/project.pbxproj @@ -152,7 +152,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/../..\"\n./gradlew :samples:composeApp:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -370,4 +370,4 @@ /* End XCConfigurationList section */ }; rootObject = 58F339CC363E6B17C4C74938 /* Project object */; -} \ No newline at end of file +} diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/samples/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to samples/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/samples/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json rename to samples/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json rename to samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png similarity index 100% rename from iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png rename to samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/samples/iosApp/iosApp/Assets.xcassets/Contents.json similarity index 100% rename from iosApp/iosApp/Assets.xcassets/Contents.json rename to samples/iosApp/iosApp/Assets.xcassets/Contents.json diff --git a/iosApp/iosApp/ContentView.swift b/samples/iosApp/iosApp/ContentView.swift similarity index 100% rename from iosApp/iosApp/ContentView.swift rename to samples/iosApp/iosApp/ContentView.swift diff --git a/iosApp/iosApp/Info.plist b/samples/iosApp/iosApp/Info.plist similarity index 100% rename from iosApp/iosApp/Info.plist rename to samples/iosApp/iosApp/Info.plist diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/samples/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json rename to samples/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/iosApp/iosApp/iOSApp.swift b/samples/iosApp/iosApp/iOSApp.swift similarity index 100% rename from iosApp/iosApp/iOSApp.swift rename to samples/iosApp/iosApp/iOSApp.swift diff --git a/settings.gradle.kts b/settings.gradle.kts index b533f84..0b3dbca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,5 +32,5 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } -include(":composeApp") +include(":samples:composeApp") include(":ikokuko") From 0acf906e282e6bbb6180aee292fa95fbc42ff60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b=C3=A1nd?= =?UTF-8?q?=C3=A9?= Date: Sun, 2 Aug 2026 21:21:31 +0100 Subject: [PATCH 2/4] fix: defer form invalidity until error reporting --- CHANGELOG.md | 4 +-- README.md | 15 ++++---- .../kotlin/com/quantipixels/ikokuko/Form.kt | 11 +++--- .../com/quantipixels/ikokuko/FormScope.kt | 4 +-- .../ikokuko/FormRecompositionTest.kt | 35 +++++++++++++++++-- .../com/quantipixels/ikokuko/FormStateTest.kt | 7 ++-- 6 files changed, 57 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d63d67..1c1fe37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to Ikokuko are recorded in this file. ## [0.2.0] -- Add strict validation state and `Field.shouldDisplayError`. +- Add strict field validation state and `Field.shouldDisplayError`. - Add dependency-aware cross-field validation with `FieldEqualsValidator`. - Add `CheckedValidator` and `SelectionRangeValidator`. - Add `rememberSaveableFormState()` and custom value-saver support. @@ -16,7 +16,7 @@ All notable changes to Ikokuko are recorded in this file. - Make numeric validators parse integer text without transform lambdas. - Make pattern validators accept pattern strings instead of `Regex` objects. - Make `Field` non-nullable and make `Field.isDirty` writable. -- Make field and form validity independent from error visibility. +- Make form validity follow error reporting while field validity remains strict. - Reset values, errors, dirty state, and error visibility before revalidation. - Limit published and documented support to Android and iOS. - Remove fixed email and phone validators. Use `MatchPatternValidator` with an application pattern. diff --git a/README.md b/README.md index 4cabddc..c6c4de8 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ dependencies { `FormState` manages all field values, validation errors, and visibility flags for a form. It’s the single source of truth for the form’s current state. -`FormState.isValid` is strict. It is `false` whenever any field has a stored error. Error visibility does not change validity. +`FormState.isValid` follows the form's error-reporting state. It remains `true` while +`shouldShowErrors` is `false`. After error reporting is enabled, it is `false` while any field has +a stored error. Individual `field.isValid` values remain strict regardless of error visibility. > Optionally pass `shouldShowErrors` when creating the state to control its initial error visibility behavior. @@ -76,8 +78,8 @@ its error until it becomes dirty. `submit()` marks all initialized fields as dir |Value|Behaviour|Typical Use Case| |---|---|---| -|`false` (default)|Validation runs continuously, but dirty-field errors remain hidden.|Use when errors must first appear after `submit()` or a manual toggle.| -|`true`|Stored errors are visible for dirty fields. Pristine-field errors remain hidden.|Use when validation messages must appear as a field changes.| +|`false` (default)|Validation runs continuously, but dirty-field errors remain hidden and `FormState.isValid` remains `true`.|Use when errors must first affect the UI after `submit()` or a manual toggle.| +|`true`|Stored errors affect `FormState.isValid` and are visible for dirty fields. Pristine-field errors remain hidden.|Use when validation must affect the UI as fields change.| You can toggle this flag at any time from either the FormState or inside the FormScope. ```kotlin @@ -358,7 +360,8 @@ fun SignUpForm() { - `FormState` tracks and validates all registered fields automatically. - The `onSubmit` callback executes only when all validations pass. - Cross-field validators declare the fields they read. A dependency value change revalidates the target field. -- The `isValid` property enables you to toggle UI elements like buttons based on current form validity. +- The form `isValid` property remains `true` until error reporting is enabled. After the first + `submit()`, stored errors can disable UI elements such as this button. - `submit()` uses the latest completed reactive validation cycle. Do not assign a field value and call `submit()` synchronously in the same callback. --- @@ -456,8 +459,8 @@ Declare every field read during validation in `dependencies`. - Replace `field.markAsDirty()` with `field.isDirty = true`. The `isDirty` property is now writable. - Remove explicit `null` arguments from `submit(onInvalid)`. Its fallback is now a non-null no-op callback. -- Use `field.shouldDisplayError` for rendering. `field.error`, `field.isValid`, and - `FormState.isValid` now expose strict stored state. +- Use `field.shouldDisplayError` for rendering. `field.error` and `field.isValid` expose strict + stored field state. `FormState.isValid` ignores stored errors until error reporting is enabled. - Update custom validators to implement `fun ValidationScope.validate(value: T)`. Declare each field read by validation in `dependencies`. - Use stable structural equality for inline custom validators. Prefer data classes, and remember diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt index bb5525a..5df4bee 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt @@ -44,7 +44,7 @@ fun Form( * Acts as the single source of truth for all form data and validation state. * Can be remembered in a composable or hoisted to persist across navigation. * - * @param shouldShowErrors Whether validation errors should be visible initially. + * @param shouldShowErrors Whether stored errors should affect form validity and be visible initially. */ @Stable class FormState(shouldShowErrors: Boolean = false) { @@ -95,7 +95,7 @@ class FormState(shouldShowErrors: Boolean = false) { private set /** - * Controls when validation errors become visible. + * Controls whether stored errors affect form validity and can be shown for dirty fields. * Validation still runs reactively regardless of this flag. */ var shouldShowErrors by mutableStateOf(shouldShowErrors) @@ -108,10 +108,13 @@ class FormState(shouldShowErrors: Boolean = false) { get() = dirtyFields.isNotEmpty() /** - * Indicates whether the form has no stored validation or external errors. + * Indicates whether stored errors currently block the form. + * + * Returns `true` while error reporting is disabled. When error reporting is enabled, + * returns `true` only when the form has no stored validation or external errors. */ val isValid: Boolean - get() = errors.isEmpty() + get() = !shouldShowErrors || errors.isEmpty() /** * Clears all field values, errors, dirty state, and error visibility. diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt index b08db68..cef9c4b 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt @@ -69,11 +69,11 @@ class FormScope internal constructor( val Field<*>.shouldDisplayError: Boolean get() = isDirty && state.shouldShowErrors && !isValid - /** Returns `true` when the form has no stored errors. */ + /** Returns `true` when stored errors do not currently block the form. */ val isValid: Boolean get() = state.isValid - /** Controls whether dirty field errors can be displayed. */ + /** Controls whether stored errors affect form validity and can be shown for dirty fields. */ var shouldShowErrors: Boolean get() = state.shouldShowErrors set(value) { diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt index e58d8e5..b70c74c 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -28,6 +28,37 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class FormRecompositionTest { + @Test + fun initial_validation_does_not_block_the_form_until_error_reporting_is_enabled() = + runCompositionTest { + val field = Field.Text("required") + lateinit var formScope: FormScope + + setContent { + Form(onSubmit = {}) { + ValidationEffect( + field = field, + initialValue = "", + validators = listOf(RequiredValidator(VALIDATION_ERROR)) + ) + SideEffect { formScope = this } + } + } + waitForIdle() + + runOnIdle { + with(formScope) { + assertEquals(VALIDATION_ERROR, field.error) + assertFalse(field.isValid) + assertTrue(isValid) + + shouldShowErrors = true + + assertFalse(isValid) + } + } + } + @Test fun equivalent_inline_validator_recreation_preserves_external_error_without_field_change() = runCompositionTest { @@ -361,7 +392,7 @@ class FormRecompositionTest { field.isDirty = true assertTrue(field in fields) assertEquals(VALIDATION_ERROR, field.error) - assertFalse(isValid) + assertTrue(isValid) } showField = false } @@ -385,7 +416,7 @@ class FormRecompositionTest { assertEquals("", field.value) assertTrue(field.isDirty) assertEquals(VALIDATION_ERROR, field.error) - assertFalse(isValid) + assertTrue(isValid) } } } diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt index a6ee471..06cb344 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt @@ -13,7 +13,7 @@ class FormStateTest { private val saveScope = SaverScope { true } @Test - fun validity_is_strict_and_error_display_is_separate() { + fun form_validity_follows_error_reporting_while_field_validity_is_strict() { val field = Field.Text("email") val state = FormState() val scope = FormScope(state) {} @@ -23,7 +23,7 @@ class FormStateTest { field.error = "required" assertFalse(field.isValid) - assertFalse(isValid) + assertTrue(isValid) assertEquals("required", field.error) assertFalse(field.shouldDisplayError) @@ -81,7 +81,7 @@ class FormStateTest { } @Test - fun submit_uses_strict_validity_and_marks_initialized_fields_dirty() { + fun submit_enables_error_reporting_before_checking_validity_and_marks_fields_dirty() { val field = Field.Text("email") val state = FormState() var submitted = false @@ -123,6 +123,7 @@ class FormStateTest { assertTrue(name.isDirty) assertEquals("invalid", name.error) assertTrue(shouldShowErrors) + assertFalse(isValid) } } From 4e46dae8462234947c25e2e8c3d242516623ffc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b=C3=A1nd?= =?UTF-8?q?=C3=A9?= Date: Sun, 2 Aug 2026 22:17:29 +0100 Subject: [PATCH 3/4] fix: recreate form content when state changes --- .../kotlin/com/quantipixels/ikokuko/Form.kt | 8 +++- .../ikokuko/FormRecompositionTest.kt | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt index 5df4bee..84fdb2b 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt @@ -3,6 +3,7 @@ package com.quantipixels.ikokuko import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -21,7 +22,8 @@ import androidx.compose.runtime.setValue * The provided [FormScope] gives access to helper methods like [FormScope.submit] * and [FormScope.reset]. * - * @param state The [FormState] instance backing this form. + * @param state The [FormState] instance backing this form. A different instance replaces the form + * content subtree and disposes effects owned by the previous form. * @param onSubmit Called when [FormScope.submit] is triggered and the form is valid. * @param content The form body, scoped to [FormScope]. */ @@ -35,7 +37,9 @@ fun Form( val scope = remember(state) { FormScope(state) { currentOnSubmit.value(this) } } - scope.content() + key(state) { + scope.content() + } } /** diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt index b70c74c..d9a91c1 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -108,6 +108,43 @@ class FormRecompositionTest { } } + @Test + fun replacing_form_state_disposes_the_old_form_and_validates_the_new_form() = + runCompositionTest { + val field = Field.Text("email") + val firstState = FormState() + val secondState = FormState() + var currentState by mutableStateOf(firstState) + lateinit var currentScope: FormScope + + setContent { + Form(state = currentState, onSubmit = {}) { + ValidationEffect( + field = field, + initialValue = "", + validators = listOf(RequiredValidator(VALIDATION_ERROR)) + ) + SideEffect { currentScope = this } + } + } + waitForIdle() + + val firstScope = currentScope + runOnIdle { + with(firstScope) { + assertEquals(VALIDATION_ERROR, field.error) + field.error = EXTERNAL_ERROR + } + currentState = secondState + } + waitForIdle() + + runOnIdle { + with(firstScope) { assertNull(field.error) } + with(currentScope) { assertEquals(VALIDATION_ERROR, field.error) } + } + } + @Test fun changing_a_dependency_revalidates_the_dependent_field() = runCompositionTest { val password = Field.Text("password") From 7954258850117b2d67f72b1665eb689d227a3d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ol=C3=BAw=C3=A1=E1=B9=A3eun=20=E1=B9=A2=C3=B3b=C3=A1nd?= =?UTF-8?q?=C3=A9?= Date: Sun, 2 Aug 2026 22:24:20 +0100 Subject: [PATCH 4/4] docs: date the 0.2.0 release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1fe37..d8e36a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to Ikokuko are recorded in this file. ## [Unreleased] -## [0.2.0] +## [0.2.0] - 2026-08-02 - Add strict field validation state and `Field.shouldDisplayError`. - Add dependency-aware cross-field validation with `FieldEqualsValidator`.