diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..153d416
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
\ No newline at end of file
diff --git a/README.md b/README.md
index 66a2ad4..0f2d3d9 100644
--- a/README.md
+++ b/README.md
@@ -1,85 +1,208 @@
# Formica
-A Kotlin Compose Multiplatform library for managing form states, validations, and data binding in a
-declarative way. This library aims to simplify form handling in Compose by providing hooks and
-utilities for managing input states, validating fields, and handling errors
+**Formica** is a **Kotlin Compose Multiplatform** library for managing **form state**,
+**validation**, and **data binding** in a fully declarative way.
+It eliminates boilerplate by giving you **reactive fields**, **built-in validators**, and
+**lens-based binding** for immutable form models.
-### Installation
+---
+
+## Features
+
+- **Immutable form data model** — no reflection, no direct mutation.
+- **Reactive fields** — each field tracks value, error, touched, dirty.
+- **Composable API** — `FormicaField` for inline field binding, `FormicaFieldState` for external
+ field state.
+- **Built-in validation rules** — or plug in your own.
+- **Scoped provider** — share the form across nested composables without prop drilling.
+- **Type-safe** — works with your data class directly.
+
+---
+
+## Installation
Add the following dependency to your project:
```gradle
dependencies {
- implementation("dev.voir.formica:1.0.0-alpha01") // Not available on public now, you can publish to mavenLocal.
+ implementation("dev.voir.formica:1.0.0-alpha02") // Not available on public now, you can publish to mavenLocal.
}
```
-### How to use?
+## Getting started
-Create your form schema
+1. Define your form data schema
```kotlin
data class FormSchema(
val firstName: String,
val lastName: String?,
- val email: String
+ val email: String,
+ val subscribe: Boolean
+)
+```
+
+2. Create Field IDs (lenses)
+
+```kotlin
+val FirstName = FormicaFieldId(
+ id = "firstName",
+ get = { it.firstName },
+ set = { data, value -> data.copy(firstName = value) },
+ clear = { data -> data.copy(firstName = "") }
+)
+
+val LastName = FormicaFieldId(
+ id = "lastName",
+ get = { it.lastName },
+ set = { data, value -> data.copy(lastName = value) },
+ clear = { data -> data.copy(lastName = null) }
+)
+
+val Email = FormicaFieldId(
+ id = "email",
+ get = { it.email },
+ set = { data, value -> data.copy(email = value) }
+)
+
+val Subscribe = FormicaFieldId(
+ id = "subscribe",
+ get = { it.subscribe },
+ set = { data, value -> data.copy(subscribe = value) }
)
```
-Initialize form in your ```kotlin @Composable```
+3. Provide the form
```kotlin
@Composable
-fun YourFormComponent() {
+fun YourFormScreen() {
val form = rememberFormica(
- // Pass some initial data if necessary
- initialData = FormSchema(
- firstName = "",
- lastName = null,
- email = ""
- )
+ initialData = FormSchema("", null, "", false),
+ onSubmit = { data ->
+ println("Form submitted: $data")
+ }
)
- // You can access current form data as state
- val formData by form.collectDataAsState()
- // Or you can access current form result as state
- val formResult by form.collectResultAsState()
+ FormicaProvider(form) {
+ YourFormContent()
+ }
+}
+```
+
+4. Build form UI
+
+```kotlin
+@Composable
+fun YourFormContent() {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+
+ // Text field example
+ FormicaField(
+ id = FirstName,
+ validators = setOf(
+ ValidationRules.minLength(2, "First name must be at least 2 characters"),
+ ValidationRules.maxLength(64, "First name must not exceed 64 characters")
+ )
+ ) { field ->
+ TextField(
+ value = field.value.orEmpty(),
+ onValueChange = field.onChange,
+ label = { Text("First Name") },
+ isError = field.error != null
+ )
+ field.error?.let { Text(it, color = Color.Red) }
+ }
+
+ // Checkbox example
+ FormicaField(id = Subscribe) { field ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(
+ checked = field.value ?: false,
+ onCheckedChange = field.onChange
+ )
+ Text("Subscribe to newsletter")
+ }
+ }
- // Pass formica instance
- Column {
- Formica(form) {
+ // Conditional field
+ FormFieldPresence(
+ form = formicaOf(),
+ id = Subscribe,
+ present = formicaOf().data.value.subscribe
+ ) {
FormicaField(
- name = FormSchema::firstName, // Bind property from schema with FormicaField
+ id = Email,
validators = setOf(
- // Pass validation rules
- MinLengthRule(
- 2,
- message = "First name should be at least 2 characters"
- ),
- MaxLengthRule(
- 64,
- message = "First name should not be greater than 64 characters"
+ ValidationRules.validateOnlyIf(
+ active = { formicaOf().data.value.subscribe },
+ rule = ValidationRules.email()
)
)
- ) {
- // Access current field value with field.value
- // Modify value in form using onChange callback
- TextField(text = field.value, onValueChange = onChange(FormSchema::firstName, it))
+ ) { field ->
+ TextField(
+ value = field.value.orEmpty(),
+ onValueChange = field.onChange,
+ label = { Text("Email Address") },
+ isError = field.error != null
+ )
+ field.error?.let { Text(it, color = Color.Red) }
}
- /* Other form fields... */
}
+ Spacer(Modifier.height(16.dp))
+
Button(onClick = {
- // Validate form on submit
- val result = form.validate()
+ val result = formicaOf().submit()
if (result is FormicaResult.Valid) {
- // Form is valid, do some action...
- } else if (result is FormicaResult.Error) {
- // Form is not valid, check validation errors
+ println("Form valid, submitting...")
}
}) {
- Text("Submit form")
+ Text("Submit")
}
}
}
```
+
+## How to?
+
+#### 1. Reading Field State Outside the Field
+
+Sometimes you want to react to a field’s state elsewhere in the UI:
+
+```kotlin
+val subscribeState = rememberFormicaFieldState(Subscribe)
+
+if (subscribeState?.value == true) {
+ Text("Thanks for subscribing!")
+}
+```
+
+#### 2. Use validation Rules
+
+Formica ships with a set of ready-to-use rules:
+
+```kotlin
+ValidationRules.required()
+ValidationRules.email()
+ValidationRules.minLength(3)
+ValidationRules.maxLength(50)
+ValidationRules.range(0, 100)
+ValidationRules.checked()
+ValidationRules.validateOnlyIf({ condition }, ValidationRules.required())
+```
+
+Or create your own:
+
+```kotlin
+val mustBeFoo = ValidationRule { v ->
+ if (v == "foo") FormicaFieldResult.Success
+ else FormicaFieldResult.Error("Must be 'foo'")
+}
+```
+
+## License
+
+This project is licensed under the GNU Lesser General Public License v3.0.
+
+See the full license at https://www.gnu.org/licenses/lgpl-3.0.txt
diff --git a/formica/build.gradle.kts b/formica/build.gradle.kts
index c2c7fe8..bebc9c3 100644
--- a/formica/build.gradle.kts
+++ b/formica/build.gradle.kts
@@ -8,7 +8,7 @@ plugins {
}
group = "dev.voir"
-version = "1.0.0-alpha01"
+version = "1.0.0-alpha02"
kotlin {
androidTarget {
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt
index 75c1ca4..b390c12 100644
--- a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt
@@ -1,40 +1,144 @@
package dev.voir.formica
-import dev.voir.formica.rules.ValidationRule
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlin.reflect.KMutableProperty1
-sealed class FormicaResult {
- data object NoInput : FormicaResult()
- data object Valid : FormicaResult()
- data class Error(val message: String) : FormicaResult()
-}
+/**
+ * A form state container that:
+ * - Holds a reactive [data] snapshot (immutable [Data])
+ * - Manages a registry of fields (each field keeps its own reactive state & validation)
+ * - Provides typed updates via [onChange], and form-level [validate]/[submit] workflows
+ * - Supports clearing & syncing from external data sources
+ *
+ * ### Key ideas
+ * - **No reflection:** fields are registered with [FormicaFieldId] (lens-like accessors).
+ * - **Immutable data:** every write returns a new [Data], updating [_data] so UI can react.
+ * - **Field state is separate:** each field maintains `value/error/result/touched/dirty`, but the
+ * canonical committed model is always [data].
+ * - **Validation pipeline:** `FormicaField` handles required/validators/custom. Here we just call `validate()`
+ * across all registered fields and aggregate errors.
+ */
class Formica(val initialData: Data, private val onSubmit: ((Data) -> Unit)? = null) {
- private val fields: MutableMap, FormicaField> = mutableMapOf()
+ /**
+ * Registry of fields by their [FormicaFieldId.id].
+ * Pair = (lens, field-state).
+ *
+ * NOTE: We use `Any?` erasure behind the scenes; public APIs remain strongly typed.
+ */
+ private val fields =
+ mutableMapOf, FormicaField>>()
+
+ /**
+ * Reactive, immutable snapshot of the entire form data model.
+ * Every successful [onChange]/[clear] produces a new instance.
+ */
+ private val _data = MutableStateFlow(initialData)
+ val data: StateFlow get() = _data
+
+ /**
+ * Reactive result of the last form-level validation/submit attempt.
+ * - Starts as [FormicaResult.NoInput]
+ * - Changes to [FormicaResult.Valid] or [FormicaResult.Error] after [validate]/[submit]
+ */
+ private val _result = MutableStateFlow(FormicaResult.NoInput)
+ val result: StateFlow get() = _result
+
+ /**
+ * Register a field for this form. Must be called once per field you plan to use.
+ *
+ * @param id Lens describing how to read/write the field on [Data].
+ * @param validators ORDERED set of validators for the field (first failure short-circuits).
+ * (Kotlin's default `setOf(...)` is insertion-ordered / LinkedHashSet.)
+ * @param customValidation Optional rule run *after* all validators.
+ * @param validateOnChange If true, field validates automatically on value changes.
+ *
+ * @return The created [FormicaField] (reactive value/error/etc.).
+ *
+ * You can conditionally *render* inputs, but you should *register* fields once to keep
+ * state stable across composition toggles.
+ */
+ fun registerField(
+ id: FormicaFieldId,
+ validators: Set>,
+ customValidation: ((Value?) -> FormicaFieldResult)? = null,
+ validateOnChange: Boolean = true,
+ ): FormicaField {
+ val field = FormicaField(
+ initialValue = id.get(_data.value), // seed from current data snapshot
+ validators = validators,
+ customValidation = customValidation,
+ validateOnChange = validateOnChange
+ )
+ // Store in registry with erased generics
+ fields[id.id] = (id as FormicaFieldId) to (field as FormicaField)
+
+ return field
+ }
- private val _data: MutableStateFlow = MutableStateFlow(initialData)
- val data: StateFlow
- get() = _data
+ /**
+ * Retrieve a previously registered field (for reading value/error/touched/dirty externally).
+ * Returns null if the field hasn't been registered in this form.
+ */
+ fun getRegisteredField(id: FormicaFieldId): FormicaField? {
+ val pair = fields[id.id] ?: return null
+ @Suppress("UNCHECKED_CAST")
+ return pair.second as? FormicaField
+ }
+
+ /**
+ * Apply a single-field update and propagate it to both:
+ * 1) The field's reactive state (value/touched/dirty and optional per-change validation)
+ * 2) The immutable [data] snapshot via lens set/clear
+ *
+ * @param id Lens for the field
+ * @param value New value, or `null` to indicate "clear". If `null` and [FormicaFieldId.clear] is not set,
+ * the data snapshot remains unchanged (field state still updates).
+ */
+ fun onChange(id: FormicaFieldId, value: V?) {
+ val pair = fields[id.id] ?: return
+ val (lens, field) = pair
- private val _result: MutableStateFlow = MutableStateFlow(FormicaResult.NoInput)
- val result: StateFlow
- get() = _result
+ // 1) Update field reactive state (value/touched/dirty + maybe validate)
+ @Suppress("UNCHECKED_CAST")
+ (field as FormicaField).onChange(value)
+
+ // 2) Update immutable data snapshot via lens
+ @Suppress("UNCHECKED_CAST")
+ val l = lens as FormicaFieldId
+ _data.value = if (value == null) {
+ l.clear?.invoke(_data.value) ?: _data.value
+ } else {
+ l.set(_data.value, value)
+ }
+ }
/**
- * Validate all fields in the form
- * @return Result of the form validation
+ * Validate all registered fields and update [result] accordingly.
+ *
+ * @return [FormicaResult.Valid] if all fields are valid, otherwise [FormicaResult.Error]
+ * with a map of `fieldId -> message`.
+ *
+ * Field-level validation order is handled inside each [FormicaField].
*/
fun validate(): FormicaResult {
- val errors = fields.map { field ->
- field.value.isValid()
+ val errors = mutableMapOf()
+
+ for ((key, pair) in fields) {
+ val (_, field) = pair
+ val res = field.validate()
+ if (res is FormicaFieldResult.Error) {
+ errors[key] = res.message
+ }
}
- val newState = if (errors.all { it }) {
+
+ val newState = if (errors.isEmpty()) {
FormicaResult.Valid
} else {
- // TODO Pass information about invalid fields
- FormicaResult.Error(message = "Some fields not valid")
+ FormicaResult.Error(
+ message = "Some fields are not valid", // TODO This message can be changed
+ fieldErrors = errors.toMap()
+ )
}
_result.value = newState
@@ -42,48 +146,42 @@ class Formica(val initialData: Data, private val onSubmit: ((Data) -> Unit
}
/**
- * Validate all form fields and if form is valid call onSubmit callback
+ * Bring all registered fields' initial/value state back in sync with the current [data] snapshot.
+ *
+ * Useful after you changed [_data] externally (e.g., loaded a draft, applied a server patch).
+ * Resets each field to "pristine" (NoInput, not dirty/touched).
*/
- fun submit() {
- val result = validate()
- if (result is FormicaResult.Valid) {
- onSubmit?.let { it(data.value) }
+ fun syncFromData() {
+ for ((_, pair) in fields) {
+ val (lens, field) = pair
+ val v = lens.get(_data.value)
+ field.reset(v)
}
+ _result.value = FormicaResult.NoInput
}
/**
- * Change form value by property name
- * @param property Field name, e.g FormScheme::firstName
- * @param value New value of the field
+ * Validate and, if successful, invoke [onSubmit] with the latest [data] snapshot.
+ *
+ * @return the validation result so callers can branch on it.
*/
- fun onChange(property: KMutableProperty1, value: Value) {
- val newData = data.value
- property.setValue(newData, property, value)
- _data.value = newData
- fields[property]?.onChange(value)
+ fun submit(): FormicaResult {
+ val r = validate()
+ if (r is FormicaResult.Valid) {
+ onSubmit?.invoke(_data.value)
+ }
+ return r
}
/**
- * Register field in the form
+ * Explicitly clear a field on the immutable [data] snapshot using its [FormicaFieldId.clear]
+ * (if provided). Does *not* touch the field's reactive value; combine with
+ * `getRegisteredField(...).reset(...)` or `onChange(id, null)` if you also want to
+ * update the field state.
*/
- fun registerField(
- name: KMutableProperty1,
- required: Boolean,
- validators: Set>,
- customValidation: ((Value?) -> FormicaFieldResult)? = null,
- validateOnChange: Boolean = true,
- requiredError: String? = null,
- ): FormicaField {
- val field = FormicaField(
- initialValue = name.get(data.value),
- required = required,
- requiredError = requiredError,
- validators = validators,
- customValidation = customValidation,
- validateOnChange = validateOnChange
- )
- fields[name] = field as FormicaField
-
- return field
+ fun clear(id: FormicaFieldId) {
+ val pair = fields[id.id] ?: return
+ val lens = pair.first
+ (lens.clear ?: return)(_data.value).also { _data.value = it }
}
}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt
index 6c70436..8041320 100644
--- a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt
@@ -1,89 +1,164 @@
package dev.voir.formica
-import dev.voir.formica.rules.ValidationRule
import kotlinx.coroutines.flow.MutableStateFlow
-sealed class FormicaFieldResult {
- data object Success : FormicaFieldResult()
-
- data class Error(val message: String) : FormicaFieldResult()
-
- data object NoInput : FormicaFieldResult()
-}
-
-class FormicaField(
+/**
+ * Represents a single field inside a form.
+ *
+ * This class encapsulates:
+ * - The field's current value
+ * - Validation rules and results
+ * - UI-friendly state flags (touched, dirty)
+ * - Optional enable/disable (presence) flag
+ *
+ * Generic type [Value] can be nullable or non-null, but internally we always store it
+ * as `Value?` in [value] to allow temporarily "empty" states.
+ */
+class FormicaField(
initialValue: Value,
- private val required: Boolean,
- private val validators: Set> = emptySet(),
+ /**
+ * A set of built-in or attached validation rules to run in order.
+ * Each rule is a [ValidationRule] applied before [customValidation].
+ * Order matters: the first failing rule will short-circuit validation.
+ */
+ private val validators: Set> = emptySet(),
+
+ /**
+ * A custom validation function run *after* all [validators].
+ * Allows for cross-field or complex validation logic.
+ */
private val customValidation: ((Value?) -> FormicaFieldResult)? = null,
+
+ /**
+ * Whether the field should validate itself automatically every time its value changes.
+ * If false, you must call [validate] manually (e.g., on form submit).
+ */
private val validateOnChange: Boolean = true,
- private val requiredError: String? = null,
) {
- val value: MutableStateFlow = MutableStateFlow(initialValue)
- val error: MutableStateFlow = MutableStateFlow(null)
- private val result: MutableStateFlow =
- MutableStateFlow(FormicaFieldResult.NoInput)
+ /**
+ * Current value of the field (nullable so that "empty" can be represented).
+ * Observed by UI to display current input.
+ */
+ val value = MutableStateFlow(initialValue)
+
+ /**
+ * The last error message for this field, or null if valid.
+ * Updated during validation. Observed by UI to show error messages.
+ */
+ val error = MutableStateFlow(null)
+
+ /**
+ * The last validation result: Success, Error, or NoInput.
+ */
+ val result = MutableStateFlow(FormicaFieldResult.NoInput)
+
+ /**
+ * Whether the field has been interacted with (first change made).
+ * Useful for deciding when to show validation errors (e.g., on blur).
+ */
+ val touched = MutableStateFlow(false)
+
+ /**
+ * Whether the value has changed from its initial value.
+ * Often used to enable/disable a "Save" button.
+ */
+ val dirty = MutableStateFlow(false)
+
+ /**
+ * Snapshot of the initial value for dirty checking and reset logic.
+ */
+ private var initial = initialValue
/**
- * Update field value with new one
+ * Whether the field is "enabled" (present in form) for validation purposes.
+ * If disabled, validation will always return Success and skip validators.
+ */
+ private val enabled = MutableStateFlow(true)
+
+ /**
+ * Enable or disable the field for validation.
+ * When disabled, validators and customValidation will be skipped.
+ */
+ fun setEnabled(v: Boolean) {
+ enabled.value = v
+ }
+
+
+ /**
+ * Called when the user changes the value.
+ *
+ * Updates:
+ * - [touched]: true after the first change
+ * - [dirty]: true if value != initial
+ * - [value]: set to new input
*
- * @param input New field value
+ * Optionally triggers validation immediately if [validateOnChange] is true.
*/
fun onChange(input: Value?) {
+ if (!touched.value) touched.value = true
+ dirty.value = (input != initial)
value.value = input
+ if (validateOnChange) validate()
+ }
- if (validateOnChange) {
- validate(input)
- }
+
+ /**
+ * Reset the field to a new initial value.
+ * Clears errors and flags, resets result to NoInput.
+ */
+ fun reset(newInitial: Value) {
+ initial = newInitial
+ value.value = newInitial
+ error.value = null
+ result.value = FormicaFieldResult.NoInput
+ touched.value = false
+ dirty.value = false
}
/**
- * Validate field
- *
- * @return true if field is valid, otherwise false
- */
- fun isValid(): Boolean = validate(value.value) is FormicaFieldResult.Success
-
- private fun validate(input: Value?): FormicaFieldResult {
- if (customValidation != null) {
- val result = customValidation.invoke(input)
- this.result.value = result
- if (result is FormicaFieldResult.Error) {
- this.error.value = result.message
- } else {
- this.error.value = null
- }
- return result
- }
+ * Convenience: return true if [validate] passes.
+ */
+ fun isValid(): Boolean = validate() is FormicaFieldResult.Success
- // If field is optional and value is null, than everything fine
- // If field is required and value is null, than show required error
- if (!required && input == null) {
- this.result.value = FormicaFieldResult.Success
- this.error.value = null
+
+ /**
+ * Run validation on the current value:
+ *
+ * 1. If disabled ([enabled] == false), skip and mark Success.
+ * 2. Run [validators] in order, short-circuit on first Error.
+ * 3. Run [customValidation] last if present.
+ * 4. Store final result in [result] and update [error] if applicable.
+ */
+ fun validate(): FormicaFieldResult {
+ // Disabled? skip everything and mark as valid
+ if (!enabled.value) {
+ error.value = null
+ result.value = FormicaFieldResult.Success
return FormicaFieldResult.Success
- } else if (required && input == null) {
- val message = requiredError ?: "Field is required"
- val result = FormicaFieldResult.Error(message = message)
+ }
- this.result.value = result
- this.error.value = result.message
+ val v = value.value
- return result
+ // Run built-in/attached validators first (ordered)
+ for (rule in validators) {
+ when (val r = rule.validate(v)) {
+ is FormicaFieldResult.Error -> return setError(r)
+ else -> {}
+ }
}
- // Run Validators
- val validationResult = validators.map { it.validate(input!!) }
- val result =
- validationResult.find { it is FormicaFieldResult.Error } ?: FormicaFieldResult.Success
+ // Custom validation last
+ val r = customValidation?.invoke(v) ?: FormicaFieldResult.Success
+ return setError(r)
+ }
- this.result.value = result
- if (result is FormicaFieldResult.Error) {
- this.error.value = result.message
- } else {
- this.error.value = null
- }
- return result
+ /**
+ * Internal helper to update [result] and [error] flows together.
+ */
+ private fun setError(r: FormicaFieldResult): FormicaFieldResult {
+ result.value = r
+ error.value = (r as? FormicaFieldResult.Error)?.message
+ return r
}
}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt
new file mode 100644
index 0000000..decd208
--- /dev/null
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt
@@ -0,0 +1,36 @@
+package dev.voir.formica
+
+/**
+ * A lightweight, reflection-free *lens* describing how to read/write a single field [V] on a model [Data].
+ *
+ * - [id] must be a stable, unique key (e.g., "firstName"). It's used to store/lookup the field in the form.
+ * - [get] reads the current value of the field from [Data].
+ * - [set] returns a **new** [Data] instance with the field updated (immutability by design).
+ * - [clear] optionally returns a **new** [Data] with the field cleared (e.g., to `null` or default),
+ * used when `onChange(..., value = null)` is invoked. If absent, `null` updates are ignored.
+ *
+ * This avoids kotlin-reflect and works great for KMP. Define them next to your data model:
+ *
+ * ```kotlin
+ * data class Profile(val firstName: String, val note: String?)
+ *
+ * val FirstName = FormicaFieldId(
+ * id = "firstName",
+ * get = { it.firstName },
+ * set = { d, v -> d.copy(firstName = v) }
+ * )
+ *
+ * val Note = FormicaFieldId(
+ * id = "note",
+ * get = { it.note },
+ * set = { d, v -> d.copy(note = v) },
+ * clear = { d -> d.copy(note = null) }
+ * )
+ * ```
+ */
+class FormicaFieldId(
+ val id: String, // Stable key (e.g., "firstName")
+ val get: (Data) -> V, // Read from Data
+ val set: (Data, V) -> Data, // Return a *new* Data with V set (immutable update)
+ val clear: ((Data) -> Data)? = null // Optional immutable "clear" operation
+)
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt
new file mode 100644
index 0000000..c31d6a2
--- /dev/null
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt
@@ -0,0 +1,18 @@
+package dev.voir.formica
+
+sealed class FormicaResult {
+ data object NoInput : FormicaResult()
+ data object Valid : FormicaResult()
+ data class Error(
+ val message: String,
+ val fieldErrors: Map = emptyMap()
+ ) : FormicaResult()
+}
+
+sealed class FormicaFieldResult {
+ data object Success : FormicaFieldResult()
+
+ data class Error(val message: String) : FormicaFieldResult()
+
+ data object NoInput : FormicaFieldResult()
+}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt
new file mode 100644
index 0000000..3353a78
--- /dev/null
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt
@@ -0,0 +1,206 @@
+package dev.voir.formica
+
+fun interface ValidationRule {
+ fun validate(value: V): FormicaFieldResult
+}
+
+object ValidationRules {
+ fun validateOnlyIf(active: () -> Boolean, rule: ValidationRule) =
+ ValidationRule { v -> if (active()) rule.validate(v) else FormicaFieldResult.Success }
+
+ fun required(
+ message: String = "Field is required",
+ isEmpty: (V?) -> Boolean = { v ->
+ when (v) {
+ null -> true
+ is String -> v.isBlank()
+ is Collection<*> -> v.isEmpty()
+ else -> false
+ }
+ }
+ ): ValidationRule = ValidationRule { v ->
+ if (isEmpty(v)) {
+ FormicaFieldResult.Error(message)
+ } else {
+ FormicaFieldResult.Success
+ }
+ }
+
+ /* TODO Maybe useful
+ fun requiredIf(
+ form: Formica,
+ predicate: (D) -> Boolean,
+ message: String
+ ): ValidationRule = ValidationRule { v ->
+ val active = predicate(form.data.value) // read live snapshot
+ if (!active) return@ValidationRule FormicaFieldResult.Success
+
+ val empty = when (v) {
+ null -> true
+ is String -> v.isBlank()
+ is Collection<*> -> v.isEmpty()
+ else -> false
+ }
+ if (empty) FormicaFieldResult.Error(message) else FormicaFieldResult.Success
+ }*/
+
+
+ fun notEmpty(
+ message: String = "This field cannot be empty."
+ ): ValidationRule =
+ ValidationRule { v ->
+ when {
+ v == null -> FormicaFieldResult.NoInput
+ v.isNotEmpty() -> FormicaFieldResult.Success
+ else -> FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun notBlank(
+ message: String = "This field cannot be blank."
+ ): ValidationRule =
+ ValidationRule { v ->
+ when {
+ v == null -> FormicaFieldResult.NoInput
+ v.isNotBlank() -> FormicaFieldResult.Success
+ else -> FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun email(
+ message: String = "Must be a valid email address.",
+ pattern: Regex = EMAIL_PATTERN.toRegex()
+ ): ValidationRule =
+ ValidationRule { v ->
+ when {
+ v == null -> FormicaFieldResult.NoInput
+ v.matches(pattern) -> FormicaFieldResult.Success
+ else -> FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun strongPassword(
+ minLength: Int = 8,
+ lengthMessage: String = "Password must be at least $minLength characters long.",
+ uppercaseMessage: String = "Password must contain at least one uppercase letter.",
+ lowercaseMessage: String = "Password must contain at least one lowercase letter.",
+ digitMessage: String = "Password must contain at least one digit.",
+ specialCharacterMessage: String = "Password must contain at least one special character.",
+ ): ValidationRule =
+ ValidationRule { v ->
+ when {
+ v == null -> FormicaFieldResult.NoInput
+ v.length < minLength -> FormicaFieldResult.Error(lengthMessage)
+ !v.any { it.isUpperCase() } -> FormicaFieldResult.Error(uppercaseMessage)
+ !v.any { it.isLowerCase() } -> FormicaFieldResult.Error(lowercaseMessage)
+ !v.any { it.isDigit() } -> FormicaFieldResult.Error(digitMessage)
+ !v.any { it in "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" } -> FormicaFieldResult.Error(
+ specialCharacterMessage
+ )
+
+ else -> FormicaFieldResult.Success
+ }
+ }
+
+ fun url(
+ protocolRequired: Boolean = false,
+ message: String = "Must be a valid URL."
+ ): ValidationRule = ValidationRule { v ->
+ if (v == null) {
+ return@ValidationRule FormicaFieldResult.NoInput
+ }
+
+ val result = if (protocolRequired) {
+ v.matches(HTTP_URL_PATTERN.toRegex())
+ } else {
+ v.matches(HTTP_URL_PATTERN.toRegex()) || v.matches(DOMAIN_URL_PATTERN.toRegex())
+ }
+
+ if (result) {
+ FormicaFieldResult.Success
+ } else {
+ FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun checked(message: String = "Must be checked"): ValidationRule =
+ ValidationRule { v ->
+ if (v == null) {
+ return@ValidationRule FormicaFieldResult.NoInput
+ }
+
+ if (v) {
+ FormicaFieldResult.Success
+ } else {
+ FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun minLength(
+ option: Int,
+ message: String = "Must be at least $option characters long.",
+ ): ValidationRule = ValidationRule { v ->
+ if (v == null) {
+ return@ValidationRule FormicaFieldResult.NoInput
+ }
+ if (v.count() >= option) {
+ FormicaFieldResult.Success
+ } else {
+ FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun maxLength(
+ option: Int,
+ message: String = "Must not exceed $option characters.",
+ ): ValidationRule = ValidationRule { v ->
+ if (v == null) {
+ return@ValidationRule FormicaFieldResult.NoInput
+ }
+ if (v.count() <= option) {
+ FormicaFieldResult.Success
+ } else {
+ FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun range(
+ min: Float,
+ max: Float,
+ message: String = "Must be a number between $min and $max.",
+ ): ValidationRule = ValidationRule { v ->
+ if (v == null) {
+ return@ValidationRule FormicaFieldResult.NoInput
+ }
+
+ if ((v >= min) && (v <= max)) {
+ FormicaFieldResult.Success
+ } else {
+ FormicaFieldResult.Error(message)
+ }
+ }
+
+ fun range(
+ min: Int,
+ max: Int,
+ message: String = "Must be a number between $min and $max.",
+ ): ValidationRule = ValidationRule { v ->
+ if (v == null) {
+ return@ValidationRule FormicaFieldResult.NoInput
+ }
+
+ if ((v >= min) && (v <= max)) {
+ FormicaFieldResult.Success
+ } else {
+ FormicaFieldResult.Error(message)
+ }
+ }
+}
+
+private const val EMAIL_PATTERN = "[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})+"
+
+private const val DOMAIN_URL_PATTERN =
+ "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$"
+private const val HTTP_URL_PATTERN =
+ "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$"
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rememberFormica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rememberFormica.kt
deleted file mode 100644
index 3e2c1d7..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rememberFormica.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package dev.voir.formica
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.remember
-
-@Composable
-fun rememberFormica(initialData: Data, onSubmit: ((Data) -> Unit)? = null) = remember {
- Formica(
- initialData = initialData,
- onSubmit = onSubmit
- )
-}
-
-@Composable
-fun Formica.collectDataAsState() = this.data.collectAsState()
-
-@Composable
-fun Formica.collectResultAsState() = this.result.collectAsState()
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/EmailRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/EmailRule.kt
deleted file mode 100644
index b4df5c7..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/EmailRule.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class EmailRule(
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return if (value.matches(EMAIL_PATTERN.toRegex())) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "Must be a valid email address.")
- }
- }
-
- companion object {
- private const val EMAIL_PATTERN =
- "[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})+"
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/FloatRangeRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/FloatRangeRule.kt
deleted file mode 100644
index b50c678..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/FloatRangeRule.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class FloatRangeRule(
- private val min: Float,
- private val max: Float,
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: Float?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return if ((value >= min) && (value <= max)) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "Must be a number between $min and $max.")
- }
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IntegerRangeRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/IntegerRangeRule.kt
deleted file mode 100644
index d95aefd..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IntegerRangeRule.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class IntegerRangeRule(
- private val min: Int,
- private val max: Int,
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: Int?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return if ((value >= min) && (value <= max)) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "Must be a number between $min and $max.")
- }
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IsCheckedRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/IsCheckedRule.kt
deleted file mode 100644
index 3284349..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/IsCheckedRule.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class IsCheckedRule(
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: Boolean?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return if (value) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "Must be checked")
- }
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MaxLenghtRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/MaxLenghtRule.kt
deleted file mode 100644
index d2c3172..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MaxLenghtRule.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class MaxLengthRule(
- private val option: Int,
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return if (value.count() <= option) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "Must not exceed $option characters.")
- }
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MinLenghtRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/MinLenghtRule.kt
deleted file mode 100644
index b2b53d8..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/MinLenghtRule.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class MinLengthRule(
- private val option: Int,
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return if (value.count() >= option) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "Must be at least ${this.option} characters long.")
- }
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/NotEmptyRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/NotEmptyRule.kt
deleted file mode 100644
index 7b7ef7f..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/NotEmptyRule.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class NotEmptyRule(
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return if (value.isNotEmpty()) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "This field cannot be empty.")
- }
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/StrongPasswordRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/StrongPasswordRule.kt
deleted file mode 100644
index 1e85c3b..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/StrongPasswordRule.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class StrongPasswordRule(
- private val minLength: Int = 8,
- private val lengthMessage: String? = null,
- private val uppercaseMessage: String? = null,
- private val lowercaseMessage: String? = null,
- private val digitMessage: String? = null,
- private val specialCharacterMessage: String? = null,
-) : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- return when {
- value.length < minLength -> FormicaFieldResult.Error(
- lengthMessage ?: "Password must be at least $minLength characters long."
- )
-
- !value.any { it.isUpperCase() } -> FormicaFieldResult.Error(
- uppercaseMessage ?: "Password must contain at least one uppercase letter."
- )
-
- !value.any { it.isLowerCase() } -> FormicaFieldResult.Error(
- lowercaseMessage ?: "Password must contain at least one lowercase letter."
- )
-
- !value.any { it.isDigit() } -> FormicaFieldResult.Error(
- digitMessage ?: "Password must contain at least one digit."
- )
-
- !value.any { it in "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" } -> FormicaFieldResult.Error(
- specialCharacterMessage ?: "Password must contain at least one special character."
- )
-
- else -> FormicaFieldResult.Success
- }
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/URLRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/URLRule.kt
deleted file mode 100644
index 115467e..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/URLRule.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-class WebUrlRule(
- private val protocolRequired: Boolean = false,
- private val message: String? = null
-) : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- if (value == null) return FormicaFieldResult.NoInput
-
- val result = if (protocolRequired) {
- value.matches(HTTP_URL_PATTERN.toRegex())
- } else {
- value.matches(HTTP_URL_PATTERN.toRegex()) || value.matches(DOMAIN_URL_PATTERN.toRegex())
- }
- return if (result) {
- FormicaFieldResult.Success
- } else {
- FormicaFieldResult.Error(message ?: "Must be a valid URL.")
- }
- }
-
- companion object {
- private const val DOMAIN_URL_PATTERN =
- "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$"
- private const val HTTP_URL_PATTERN =
- "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$"
- }
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/rules/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/rules/ValidationRule.kt
deleted file mode 100644
index bc6f386..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/rules/ValidationRule.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package dev.voir.formica.rules
-
-import dev.voir.formica.FormicaFieldResult
-
-interface ValidationRule {
- fun validate(value: V): FormicaFieldResult
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaFieldScope.kt b/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaFieldScope.kt
deleted file mode 100644
index 39dd957..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaFieldScope.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package dev.voir.formica.scopes
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
-import androidx.compose.runtime.collectAsState
-import dev.voir.formica.FormicaField
-
-class FormicaFieldScope(
- private val formField: FormicaField,
-) {
- val field: State
- @Composable
- get() = this.formField.value.collectAsState()
-
- val error: State
- @Composable
- get() = this.formField.error.collectAsState()
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaScope.kt b/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaScope.kt
deleted file mode 100644
index e9c6fd8..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/scopes/FormicaScope.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package dev.voir.formica.scopes
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
-import androidx.compose.runtime.collectAsState
-import dev.voir.formica.Formica
-import dev.voir.formica.FormicaField
-import dev.voir.formica.FormicaFieldResult
-import dev.voir.formica.FormicaResult
-import dev.voir.formica.rules.ValidationRule
-import kotlin.reflect.KMutableProperty1
-
-class FormicaScope(private val formica: Formica) {
- val data: State
- @Composable
- get() = formica.data.collectAsState(initial = formica.initialData)
-
- val result: State
- @Composable
- get() = formica.result.collectAsState(initial = FormicaResult.NoInput)
-
- fun validate() = formica.validate()
-
- fun submit() = formica.submit()
-
- fun onChange(fieldName: KMutableProperty1, value: Value) =
- formica.onChange(fieldName, value)
-
-
- fun registerField(
- name: KMutableProperty1,
- validators: Set>,
- required: Boolean,
- requiredError: String? = null,
- customValidation: ((Value?) -> FormicaFieldResult)? = null,
- validateOnChange: Boolean = true,
- ): FormicaField =
- formica.registerField(
- name = name,
- required = required,
- requiredError = requiredError,
- validators = validators,
- customValidation = customValidation,
- validateOnChange = validateOnChange
- )
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/Formica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/Formica.kt
deleted file mode 100644
index dcf8a44..0000000
--- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/Formica.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package dev.voir.formica.ui
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.remember
-import dev.voir.formica.Formica
-import dev.voir.formica.scopes.FormicaScope
-
-@Composable
-fun Formica(
- formica: Formica,
- content: @Composable FormicaScope.() -> Unit
-) {
- val scope = remember {
- derivedStateOf { FormicaScope(formica = formica) }
- }
- scope.value.content()
-}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt
index e5b21f4..13a6873 100644
--- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt
@@ -1,38 +1,175 @@
package dev.voir.formica.ui
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
+import dev.voir.formica.Formica
+import dev.voir.formica.FormicaFieldId
import dev.voir.formica.FormicaFieldResult
-import dev.voir.formica.rules.ValidationRule
-import dev.voir.formica.scopes.FormicaFieldScope
-import dev.voir.formica.scopes.FormicaScope
-import kotlin.reflect.KMutableProperty1
+import dev.voir.formica.ValidationRule
+/**
+ * A stable snapshot of a form field's UI-facing state and operations.
+ *
+ * This is what you pass into your composable input components so they have:
+ * - [value] → the current field value (nullable to represent "empty")
+ * - [error] → the last validation error message, or null if valid
+ * - [touched] → whether the user has interacted with the field yet
+ * - [dirty] → whether the value has changed from its initial value
+ * - [onChange] → callback to update the value (also updates form data snapshot)
+ * - [validate] → function to trigger validation manually; returns true if valid
+ *
+ * Marked as @Stable so Compose can optimize recompositions when only internal
+ * values change.
+ */
+@Stable
+data class FieldAdapter(
+ val value: V?,
+ val error: String?,
+ val touched: Boolean,
+ val dirty: Boolean,
+ val onChange: (V?) -> Unit,
+ val validate: () -> Boolean
+)
+
+
+/**
+ * Register a form field with the given [Formica] instance and expose it to UI
+ * via a [FieldAdapter] in a type-safe, reactive way.
+ *
+ * This overload is for when you have an explicit [form] reference.
+ *
+ * Typical usage in Compose:
+ * ```
+ * FormicaField(form, FirstName, validators = setOf(...)) { f ->
+ * BasicTextField(
+ * value = f.value ?: "",
+ * onValueChange = { f.onChange(it) }
+ * )
+ * f.error?.let { Text(it, color = Color.Red) }
+ * }
+ * ```
+ *
+ * @param form The form instance holding all field state and data snapshot.
+ * @param id A [FormicaFieldId] lens for reading/writing this field in the form's data.
+ * @param validators Optional set of ordered validation rules for this field.
+ * @param customValidation Optional extra validation run after [validators].
+ * @param validateOnChange Whether to run validation automatically on every value change.
+ * @param content Composable content lambda that receives a [FieldAdapter] for UI binding.
+ */
@Composable
-fun FormicaScope.FormicaField(
- name: KMutableProperty1,
- required: Boolean = true,
- requiredError: String? = null,
- validators: Set> = emptySet(),
- customValidation: ((Value?) -> FormicaFieldResult)? = null,
+fun FormicaField(
+ form: Formica,
+ id: FormicaFieldId,
+ validators: Set> = emptySet(),
+ customValidation: ((V?) -> FormicaFieldResult)? = null,
validateOnChange: Boolean = true,
- content: @Composable FormicaFieldScope.() -> Unit
+ content: @Composable (FieldAdapter) -> Unit
) {
- val scope = remember {
- derivedStateOf {
- FormicaFieldScope(
- formField = registerField(
- name = name,
- required = required,
- requiredError = requiredError,
- validators = validators,
- customValidation = customValidation,
- validateOnChange = validateOnChange
- )
- )
- }
+ // Register the field once for this form + id combination.
+ // Registration seeds its initial value from the current form data snapshot.
+ val field = remember(form, id) {
+ form.registerField(
+ id = id,
+ validators = validators,
+ customValidation = customValidation,
+ validateOnChange = validateOnChange
+ )
+ }
+
+ // Collect reactive field state for UI binding.
+ // These flows update whenever field state changes (value, error, touched, dirty).
+ val value by field.value.collectAsState(initial = id.get(form.data.value))
+ val error by field.error.collectAsState(initial = null)
+ val touched by field.touched.collectAsState(initial = false)
+ val dirty by field.dirty.collectAsState(initial = false)
+
+ // Optional: Keep field state in sync if form.data changes externally (e.g., loaded draft).
+ // This resets the field's internal state (value/error/touched/dirty) to match the new snapshot.
+ val dataSnapshot by form.data.collectAsState()
+ LaunchedEffect(dataSnapshot) {
+ field.reset(id.get(dataSnapshot))
}
- scope.value.content()
+ // Package the reactive state and callbacks into a stable adapter for the UI.
+ val adapter = remember(value, error, touched, dirty) {
+ FieldAdapter(
+ value = value,
+ error = error,
+ touched = touched,
+ dirty = dirty,
+ onChange = { v -> form.onChange(id, v) },
+ validate = { field.isValid() }
+ )
+ }
+
+ // Render UI content with the adapter.
+ content(adapter)
+}
+
+/**
+ * Overload of [FormicaField] that uses the ambient [LocalFormica] form context
+ * instead of requiring an explicit [form] parameter.
+ *
+ * Allows cleaner usage when you've wrapped your UI in a FormicaProvider:
+ * ```
+ * FormicaProvider(form) {
+ * FormicaField(FirstName) { f ->
+ * BasicTextField(
+ * value = f.value ?: "",
+ * onValueChange = { f.onChange(it) }
+ * )
+ * }
+ * }
+ * ```
+ */
+@Composable
+fun FormicaField(
+ id: FormicaFieldId,
+ validators: Set> = emptySet(),
+ customValidation: ((V?) -> FormicaFieldResult)? = null,
+ validateOnChange: Boolean = true,
+ content: @Composable (FieldAdapter) -> Unit
+) {
+ val form = formicaOf() // Grab the current form from the composition
+ FormicaField(
+ form = form,
+ id = id,
+ validators = validators,
+ customValidation = customValidation,
+ validateOnChange = validateOnChange,
+ content = content
+ )
+}
+
+/**
+ * Convenience for reading a field's committed value from a [Formica] instance
+ * reactively (without registering a field).
+ *
+ * Returns the current value of the field from the immutable form data snapshot.
+ * Will recompose whenever [form.data] changes.
+ */
+@Composable
+fun rememberFormicaFieldValue(form: Formica, id: FormicaFieldId): V? {
+ val data by form.data.collectAsState()
+ return id.get(data)
+}
+
+/**
+ * Overload of [rememberFormicaFieldValue] that uses [LocalFormica] instead of
+ * requiring an explicit [form] parameter.
+ *
+ * Useful inside a [FormicaProvider] scope:
+ * ```
+ * val firstName = rememberFormicaFieldValue(FirstName) ?: ""
+ * ```
+ */
+@Composable
+fun rememberFormicaFieldValue(id: FormicaFieldId): V? {
+ val form = formicaOf()
+ val data by form.data.collectAsState()
+ return id.get(data)
}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt
new file mode 100644
index 0000000..2bb3fa9
--- /dev/null
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt
@@ -0,0 +1,76 @@
+package dev.voir.formica.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import dev.voir.formica.Formica
+import dev.voir.formica.FormicaFieldId
+
+/**
+ * Conditional wrapper for a form field that can be shown/hidden without losing registration.
+ *
+ * This is useful for *conditionally rendered fields* where:
+ * - You still want the field to be part of the form's registry (state preserved across shows/hides)
+ * - You want to enable/disable validation automatically based on visibility
+ * - You optionally want to clear its value when it is hidden
+ *
+ * ### How it works
+ * - Looks up the registered [FormicaField] for [id] (does **not** register it — you must have
+ * registered it beforehand via `FormicaField` or `registerField`).
+ * - Whenever [present] changes:
+ * - Calls `field.setEnabled(present)` so validation short-circuits when hidden.
+ * - If `present == false` and [clearOnHide] is `true`, also calls `form.onChange(id, null)`
+ * to clear the field value in both field state and the form data snapshot.
+ * - Renders [content] only if [present] is `true`.
+ *
+ * ### Example:
+ * ```
+ * // Always register the field
+ * FormicaField(form, AdditionalText) { adapter ->
+ * TextField(
+ * value = adapter.value.orEmpty(),
+ * onValueChange = adapter.onChange
+ * )
+ * }
+ *
+ * // Conditionally render it
+ * FormFieldPresence(
+ * form = form,
+ * id = AdditionalText,
+ * present = isExtraSectionEnabled,
+ * clearOnHide = true
+ * ) {
+ * // UI for AdditionalText here
+ * }
+ * ```
+ *
+ * @param form The form instance holding the registered field.
+ * @param id The lens identifying the field to control.
+ * @param present Whether the field should be visible and validated.
+ * @param clearOnHide If true, clears the field value when it becomes hidden.
+ * @param content UI content to render when the field is present.
+ */
+@Composable
+fun FormFieldPresence(
+ form: Formica,
+ id: FormicaFieldId,
+ present: Boolean,
+ clearOnHide: Boolean = false,
+ content: @Composable () -> Unit
+) {
+ // Cache the registered field instance for the lifetime of this form+id combination.
+ // This avoids re-fetching the field every recomposition.
+ val field = remember(form, id) { form.getRegisteredField(id) }
+
+ // React to changes in the `present` flag.
+ LaunchedEffect(present) {
+ field?.setEnabled(present) // Enable/disable validation
+ if (!present && clearOnHide) {
+ // Clear value in both field state and form data snapshot
+ form.onChange(id, null)
+ }
+ }
+
+ // Render UI only when field is "present".
+ if (present) content()
+}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt
new file mode 100644
index 0000000..e1493b8
--- /dev/null
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt
@@ -0,0 +1,106 @@
+package dev.voir.formica.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import dev.voir.formica.Formica
+import dev.voir.formica.FormicaFieldId
+
+/**
+ * A stable snapshot of a registered field's full reactive state and actions.
+ *
+ * This is very similar to [FieldAdapter], but intended for cases where you want
+ * to **access a field's state outside of its own Composable input**, for example:
+ *
+ * - To conditionally render another UI element based on the field's value or error
+ * - To build composite components that depend on multiple fields
+ * - To trigger validation from somewhere else in the UI
+ *
+ * @param value Current value of the field (nullable to represent "empty").
+ * @param error Current error message if invalid, or `null` if valid.
+ * @param touched Whether the field has been interacted with at least once.
+ * @param dirty Whether the value has changed from its initial value.
+ * @param onChange Callback to update the field's value in both field state and form data.
+ * @param validate Triggers validation immediately; returns `true` if valid.
+ *
+ * Marked @Stable so Compose can optimize recomposition.
+ */
+@Stable
+data class FormicaFieldState(
+ val value: V?,
+ val error: String?,
+ val touched: Boolean,
+ val dirty: Boolean,
+ val onChange: (V?) -> Unit,
+ val validate: () -> Boolean
+)
+
+/**
+ * Retrieve a [FormicaFieldState] for a previously registered field in [form],
+ * observing all of its reactive properties (value, error, touched, dirty).
+ *
+ * @param form The [Formica] instance that holds the registered field.
+ * @param id The [FormicaFieldId] lens identifying the field.
+ *
+ * @return A [FormicaFieldState] bound to this field, or `null` if the field
+ * has not been registered in the form.
+ *
+ * ### Example
+ * ```
+ * val fieldState = rememberFormicaFieldState(form, Email)
+ * if (fieldState?.error != null) {
+ * Text("Invalid email", color = Color.Red)
+ * }
+ * ```
+ *
+ * **Important:** This does *not* register the field. You must register it
+ * beforehand with `FormicaField(...)` or `form.registerField(...)`.
+ */
+@Composable
+fun rememberFormicaFieldState(
+ form: Formica,
+ id: FormicaFieldId
+): FormicaFieldState? {
+ // Remember the registered field instance so we don't look it up every recomposition
+ val field = remember(form, id) { form.getRegisteredField(id) } ?: return null
+
+ // Observe reactive properties from the field's StateFlows
+ val value by field.value.collectAsState()
+ val error by field.error.collectAsState()
+ val touched by field.touched.collectAsState()
+ val dirty by field.dirty.collectAsState()
+
+ // Package everything into a stable snapshot object
+ return remember(value, error, touched, dirty) {
+ FormicaFieldState(
+ value = value,
+ error = error,
+ touched = touched,
+ dirty = dirty,
+ onChange = { form.onChange(id, it) },
+ validate = { field.isValid() }
+ )
+ }
+}
+
+/**
+ * Overload of [rememberFormicaFieldState] that retrieves the current [Formica]
+ * instance from [LocalFormica], so you don't need to pass `form` manually.
+ *
+ * Use inside a [FormicaProvider] scope:
+ * ```
+ * val fieldState = rememberFormicaFieldState(Email)
+ * if (fieldState?.dirty == true) {
+ * SaveButton()
+ * }
+ * ```
+ */
+@Composable
+fun rememberFormicaFieldState(
+ id: FormicaFieldId
+): FormicaFieldState? {
+ val form = formicaOf()
+ return rememberFormicaFieldState(form, id)
+}
diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt
new file mode 100644
index 0000000..00dcc98
--- /dev/null
+++ b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt
@@ -0,0 +1,99 @@
+package dev.voir.formica.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
+import dev.voir.formica.Formica
+
+/**
+ * A CompositionLocal that holds the current [Formica] instance for this UI tree.
+ *
+ * - Typed as `Formica?` internally so it can store any generic form type.
+ * - Accessed via [formicaOf] to get a strongly-typed instance.
+ * - Provided by [FormicaProvider].
+ *
+ * Defaults to `null` when not inside a [FormicaProvider] scope.
+ */
+val LocalFormica = staticCompositionLocalOf?> { null }
+
+/**
+ * Create and remember a [Formica] instance tied to the current composition.
+ *
+ * This is typically called at the top of a screen or form scope.
+ * The instance will survive recompositions, and will only be recreated when
+ * either [initialData] or [onSubmit] changes.
+ *
+ * @param initialData The initial immutable data model for the form.
+ * @param onSubmit Optional callback invoked with the form's current data when [Formica.submit] is called.
+ *
+ * @return A remembered [Formica] instance you can use directly or pass to [FormicaProvider].
+ *
+ * ### Example:
+ * ```
+ * val form = rememberFormica(Profile("", null)) { data ->
+ * saveProfile(data)
+ * }
+ * ```
+ */
+@Composable
+fun rememberFormica(
+ initialData: Data,
+ onSubmit: ((Data) -> Unit)? = null
+): Formica {
+ // Will only recreate the Formica instance when initialData or onSubmit changes
+ return remember(initialData, onSubmit) {
+ Formica(initialData, onSubmit)
+ }
+}
+
+/**
+ * Provide a [Formica] instance to all composables in [content] via [LocalFormica].
+ *
+ * This allows child composables to use [formicaOf] to retrieve the form without
+ * having to pass it down explicitly.
+ *
+ * @param form The form instance to provide in this composition scope.
+ * @param content UI content that should have access to [form] via [LocalFormica].
+ *
+ * ### Example:
+ * ```
+ * val form = rememberFormica(Profile("", null))
+ * FormicaProvider(form) {
+ * // Inside here, you can call formicaOf() to get the form
+ * }
+ * ```
+ */
+@Composable
+fun FormicaProvider(
+ form: Formica,
+ content: @Composable () -> Unit
+) {
+ @Suppress("UNCHECKED_CAST") // Safe cast because we only ever read via formicaOf()
+ CompositionLocalProvider(LocalFormica provides (form as Formica)) {
+ content()
+ }
+}
+
+/**
+ * Retrieve the current [Formica] instance from [LocalFormica] with strong typing.
+ *
+ * This must be called inside a [FormicaProvider] scope, otherwise it will throw an error.
+ *
+ * @return The current [Formica] instance typed as [Formica].
+ *
+ * ### Example:
+ * ```
+ * val form = formicaOf()
+ * form.onChange(FirstName, "Gary")
+ * ```
+ *
+ * @throws IllegalStateException if no [FormicaProvider] is found in the current composition.
+ */
+@Suppress("UNCHECKED_CAST")
+@Composable
+fun formicaOf(): Formica {
+ val f = LocalFormica.current
+ ?: error("No Formica in scope. Wrap with FormicaProvider or pass form explicitly.")
+ return f as Formica
+}
diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicFieldTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicFieldTest.kt
deleted file mode 100644
index 13f0c0c..0000000
--- a/formica/src/commonTest/kotlin/dev/voir/formica/FormicFieldTest.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package dev.voir.formica
-
-import dev.voir.formica.rules.ValidationRule
-import kotlinx.coroutines.flow.first
-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
-
-class FormicaFieldTest {
-
- @Test
- fun `initial state is correct`() = runTest {
- val field = FormicaField(initialValue = null, required = false)
- assertNull(field.value.first())
- assertNull(field.error.first())
- }
-
- @Test
- fun `onChange updates value and validates when validateOnChange is true`() = runTest {
- val field = FormicaField(
- initialValue = null,
- required = true,
- requiredError = "Required field"
- )
-
- field.onChange("New Value")
-
- assertEquals("New Value", field.value.first())
- assertNull(field.error.first())
- }
-
- @Test
- fun `onChange skips validation when validateOnChange is false`() = runTest {
- val field = FormicaField(
- initialValue = null,
- required = true,
- validateOnChange = false,
- requiredError = "Required field"
- )
-
- field.onChange(null)
-
- assertNull(field.error.first()) // No validation should have occurred
- }
-
- @Test
- fun `validate returns error for required field with null value`() = runTest {
- val field = FormicaField(
- initialValue = null,
- required = true,
- requiredError = "Field is required"
- )
-
- assertFalse(field.isValid())
- assertEquals("Field is required", field.error.first())
- }
-
- @Test
- fun `validate passes for non-required field with null value`() = runTest {
- val field = FormicaField(
- initialValue = null,
- required = false
- )
-
- assertTrue(field.isValid())
- assertNull(field.error.first())
- }
-
- @Test
- fun `customValidation is applied correctly`() = runTest {
- val customValidation: (String?) -> FormicaFieldResult = { input ->
- if (input == "valid") FormicaFieldResult.Success
- else FormicaFieldResult.Error("Invalid input")
- }
-
- val field = FormicaField(
- initialValue = null,
- required = false,
- customValidation = customValidation
- )
-
- field.onChange("invalid")
- assertEquals("Invalid input", field.error.first())
- assertFalse(field.isValid())
-
- field.onChange("valid")
- assertNull(field.error.first())
- assertTrue(field.isValid())
- }
-
- @Test
- fun `validators are applied correctly`() = runTest {
- val rule: ValidationRule = object : ValidationRule {
- override fun validate(value: String): FormicaFieldResult {
- return if (value.length > 5) FormicaFieldResult.Success
- else FormicaFieldResult.Error("Too short")
- }
- }
-
- val field = FormicaField(
- initialValue = "",
- required = false,
- validators = setOf(rule)
- )
-
- field.onChange("short")
- assertEquals("Too short", field.error.first())
- assertFalse(field.isValid())
-
- field.onChange("longer text")
- assertNull(field.error.first())
- assertTrue(field.isValid())
- }
-}
diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt
new file mode 100644
index 0000000..8c9ac60
--- /dev/null
+++ b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt
@@ -0,0 +1,224 @@
+package dev.voir.formica
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class FormicaFieldTest {
+
+ // --- helpers -------------------------------------------------------------
+
+ private fun success() = FormicaFieldResult.Success
+ private fun err(msg: String) = FormicaFieldResult.Error(msg)
+
+ private fun rule(block: (V?) -> FormicaFieldResult): ValidationRule =
+ ValidationRule { v -> block(v) }
+
+ // Kotlin's setOf(...) is insertion-ordered (LinkedHashSet), so we can verify short-circuit.
+ private fun orderedRules(vararg rules: ValidationRule): Set> =
+ linkedSetOf(*rules)
+
+ // --- initial state -------------------------------------------------------
+
+ @Test
+ fun initial_state_is_pristine() {
+ val f = FormicaField(
+ initialValue = "foo"
+ )
+
+ assertEquals("foo", f.value.value)
+ assertNull(f.error.value)
+ assertTrue(f.result.value is FormicaFieldResult.NoInput)
+ assertFalse(f.touched.value)
+ assertFalse(f.dirty.value)
+ }
+
+ // --- onChange / touched / dirty -----------------------------------------
+
+ @Test
+ fun onChange_sets_value_and_flags() {
+ val f = FormicaField(initialValue = "a", validateOnChange = false)
+
+ f.onChange("a") // same as initial
+ assertEquals("a", f.value.value)
+ assertTrue(f.touched.value)
+ assertFalse(f.dirty.value)
+
+ f.onChange("b") // different
+ assertEquals("b", f.value.value)
+ assertTrue(f.touched.value)
+ assertTrue(f.dirty.value)
+ }
+
+ // --- validateOnChange behaviour -----------------------------------------
+
+ @Test
+ fun validateOnChange_false_does_not_validate_automatically() {
+ val f = FormicaField(
+ initialValue = "",
+ validators = orderedRules(rule { if (it.isNullOrBlank()) err("x") else success() }),
+ validateOnChange = false
+ )
+
+ // After change, still NoInput because we didn't call validate()
+ f.onChange("")
+ assertTrue(f.result.value is FormicaFieldResult.NoInput)
+ assertNull(f.error.value)
+
+ // Now validate explicitly
+ val r = f.validate()
+ assertTrue(r is FormicaFieldResult.Error)
+ assertEquals("x", f.error.value)
+ }
+
+ @Test
+ fun validateOnChange_true_validates_automatically() {
+ val f = FormicaField(
+ initialValue = "",
+ validators = orderedRules(rule { if (it.isNullOrBlank()) err("empty") else success() }),
+ validateOnChange = true
+ )
+
+ f.onChange("") // triggers validate
+ assertEquals("empty", f.error.value)
+ assertTrue(f.result.value is FormicaFieldResult.Error)
+
+ f.onChange("ok") // triggers validate
+ assertNull(f.error.value)
+ assertTrue(f.result.value is FormicaFieldResult.Success)
+ }
+
+ // --- validators before custom & short-circuit ----------------------------
+
+ @Test
+ fun validators_run_before_custom_and_shortCircuit_on_error() {
+ var customCalled = false
+
+ val v1 = rule { err("fail-1") } // first fails
+ val v2 = rule { error("should not run") } // must never run
+ val custom = { _: String? ->
+ customCalled = true
+ success()
+ }
+
+ val f = FormicaField(
+ initialValue = "x",
+ validators = orderedRules(v1, v2),
+ customValidation = custom,
+ validateOnChange = false
+ )
+
+ val r = f.validate()
+ assertTrue(r is FormicaFieldResult.Error)
+ assertEquals("fail-1", f.error.value)
+ assertFalse(customCalled) // custom not called due to short-circuit
+ }
+
+ @Test
+ fun custom_runs_when_validators_pass_and_can_fail() {
+ var vCalled = false
+ val vOk = rule { vCalled = true; success() }
+ val custom = { _: String? -> err("custom-fail") }
+
+ val f = FormicaField(
+ initialValue = "x",
+ validators = orderedRules(vOk),
+ customValidation = custom,
+ validateOnChange = false
+ )
+
+ val r = f.validate()
+ assertTrue(vCalled)
+ assertTrue(r is FormicaFieldResult.Error)
+ assertEquals("custom-fail", f.error.value)
+ }
+
+ @Test
+ fun success_sets_success_and_clears_error() {
+ val f = FormicaField(
+ initialValue = "x",
+ validators = orderedRules(rule { success() }),
+ customValidation = { success() },
+ validateOnChange = false
+ )
+
+ val r = f.validate()
+ assertTrue(r is FormicaFieldResult.Success)
+ assertNull(f.error.value)
+ assertTrue(f.result.value is FormicaFieldResult.Success)
+ }
+
+ // --- enabled / presence --------------------------------------------------
+
+ @Test
+ fun disabled_field_skips_validation_and_is_always_success() {
+ var vCalls = 0
+ val v = rule { vCalls++; err("nope") }
+
+ val f = FormicaField(
+ initialValue = "",
+ validators = orderedRules(v),
+ validateOnChange = true
+ )
+
+ // disable first
+ f.setEnabled(false)
+
+ // onChange would normally validate, but since disabled, it should stay Success
+ f.onChange("") // validateOnChange triggers validate()
+ assertNull(f.error.value)
+ assertTrue(f.result.value is FormicaFieldResult.Success)
+ assertEquals(0, vCalls, "validator must not be called when disabled")
+
+ // validate() should also short-circuit
+ val r = f.validate()
+ assertTrue(r is FormicaFieldResult.Success)
+ assertEquals(0, vCalls)
+ }
+
+ // --- reset ---------------------------------------------------------------
+
+ @Test
+ fun reset_restores_pristine_state_and_updates_initial() {
+ val f = FormicaField(
+ initialValue = "init",
+ validators = orderedRules(rule { if (it.isNullOrBlank()) err("empty") else success() }),
+ validateOnChange = true
+ )
+
+ f.onChange("") // set error, touched, dirty
+ assertTrue(f.touched.value)
+ assertTrue(f.dirty.value)
+ assertEquals("empty", f.error.value)
+
+ f.reset("fresh")
+ assertEquals("fresh", f.value.value)
+ assertTrue(f.result.value is FormicaFieldResult.NoInput)
+ assertNull(f.error.value)
+ assertFalse(f.touched.value)
+ assertFalse(f.dirty.value)
+
+ // dirty should reflect new initial
+ f.onChange("fresh")
+ assertFalse(f.dirty.value)
+ f.onChange("changed")
+ assertTrue(f.dirty.value)
+ }
+
+ // --- isValid() -----------------------------------------------------------
+
+ @Test
+ fun isValid_calls_validate_and_returns_boolean() {
+ val f = FormicaField(
+ initialValue = "",
+ validators = orderedRules(rule { if (it.isNullOrBlank()) err("x") else success() }),
+ validateOnChange = false
+ )
+
+ assertFalse(f.isValid()) // triggers validate -> error
+ f.onChange("ok")
+ assertTrue(f.isValid())
+ }
+}
diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt
index 7f4bc42..171a4e8 100644
--- a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt
+++ b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt
@@ -1,140 +1,238 @@
package dev.voir.formica
-import dev.voir.formica.rules.ValidationRule
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNotSame
+import kotlin.test.assertNull
+import kotlin.test.assertSame
import kotlin.test.assertTrue
-data class TestData(var field1: String, var field2: Int)
+// ---------- Fixtures ---------------------------------------------------------
-class FormicaTest {
+private data class Profile(
+ val firstName: String,
+ val note: String?,
+ val age: Int
+)
+
+private val FirstName = FormicaFieldId(
+ id = "firstName",
+ get = { it.firstName },
+ set = { d, v -> d.copy(firstName = v) }
+)
+
+private val Note = FormicaFieldId(
+ id = "note",
+ get = { it.note },
+ set = { d, v -> d.copy(note = v) },
+ clear = { d -> d.copy(note = null) }
+)
+
+private val Age = FormicaFieldId(
+ id = "age",
+ get = { it.age },
+ set = { d, v -> d.copy(age = v) }
+ // no clear -> null updates should NOT change data snapshot
+)
+
+// Helpers
+private fun rule(block: (V?) -> FormicaFieldResult): ValidationRule =
+ ValidationRule { v -> block(v) }
+
+// Preserve validator order explicitly for Set
+private fun ordered(vararg r: ValidationRule): Set> = linkedSetOf(*r)
+
+// ---------- Tests ------------------------------------------------------------
+
+class FormicaCoreTest {
@Test
- fun `initial state is correct`() = runTest {
- val testData = TestData(field1 = "initial", field2 = 0)
- val formica = Formica(initialData = testData)
+ fun registerField_seeds_initialValue_from_data() {
+ val form = Formica(Profile("Ann", null, 21))
+ val f = form.registerField(
+ id = FirstName,
+ validators = emptySet()
+ )
+ assertEquals("Ann", f.value.value)
+ assertTrue(f.result.value is FormicaFieldResult.NoInput)
+ }
+
+ @Test
+ fun onChange_updates_field_and_data_immutably() {
+ val form = Formica(Profile("Ann", null, 21))
+ form.registerField(id = FirstName, validators = emptySet())
+
+ val before = form.data.value
+ form.onChange(FirstName, "Bob")
- assertEquals(testData, formica.data.first())
- assertTrue(formica.result.first() is FormicaResult.NoInput)
+ val after = form.data.value
+ assertNotSame(before, after) // new snapshot
+ assertEquals("Bob", after.firstName) // changed
+ assertEquals("Ann", before.firstName) // old unchanged
}
@Test
- fun `registerField adds field and allows validation`() = runTest {
- val testData = TestData(field1 = "initial", field2 = 0)
- val formica = Formica(initialData = testData)
-
- val rule: ValidationRule = object : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty")
- else FormicaFieldResult.Success
- }
- }
-
- val field = formica.registerField(
- name = TestData::field1,
- required = true,
- validators = setOf(rule)
- )
+ fun onChange_null_uses_clear_when_available() {
+ val form = Formica(Profile("Ann", "hello", 21))
+ val field = form.registerField(id = Note, validators = emptySet())
+
+ // sanity: both data and field start with "hello"
+ assertEquals("hello", form.data.value.note)
+ assertEquals("hello", field.value.value)
+
+ form.onChange(Note, null) // should call FormicaFieldId.clear
+ assertNull(form.data.value.note) // data cleared
+ assertNull(field.value.value) // field value updated
+ }
+
+ @Test
+ fun onChange_null_without_clear_does_not_mutate_data_snapshot() {
+ val form = Formica(Profile("Ann", "x", 30))
+ val ageField = form.registerField(id = Age, validators = emptySet())
+
+ val before = form.data.value
+ assertEquals(30, before.age)
- assertEquals("initial", field.value.first())
- assertTrue(field.isValid())
+ form.onChange(Age, null) // no clear provided
+
+ val after = form.data.value
+ assertSame(before, after) // same instance -> no change
+ assertEquals(30, after.age)
+ assertNull(ageField.value.value) // field state still updated to null
}
@Test
- fun `onChange updates data and validates field`() = runTest {
- val testData = TestData(field1 = "initial", field2 = 0)
- val formica = Formica(initialData = testData)
-
- val rule: ValidationRule = object : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty")
- else FormicaFieldResult.Success
- }
- }
-
- formica.registerField(
- name = TestData::field1,
- required = true,
- validators = setOf(rule)
+ fun getRegisteredField_returns_same_instance_and_can_toggle_enabled() {
+ val form = Formica(Profile("Ann", null, 21))
+ form.registerField(
+ id = FirstName,
+ validators = ordered(rule { FormicaFieldResult.Error("fail") }),
+ validateOnChange = true
)
- formica.onChange(TestData::field1, "updated")
- assertEquals("updated", formica.data.first().field1)
- assertEquals(FormicaResult.NoInput, formica.result.first())
+ val f = form.getRegisteredField(FirstName)
+ assertNotNull(f)
+ // Disable -> validation should short-circuit to Success
+ f.setEnabled(false)
+ form.onChange(FirstName, "") // would fail if enabled
+ assertTrue(f.result.value is FormicaFieldResult.Success)
+ assertNull(f.error.value)
}
@Test
- fun `validate updates state based on fields validity`() = runTest {
- val testData = TestData(field1 = "valid", field2 = 0)
- val formica = Formica(initialData = testData)
-
- val rule1: ValidationRule = object : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty")
- else FormicaFieldResult.Success
- }
- }
-
- val rule2: ValidationRule = object : ValidationRule {
- override fun validate(value: Int?): FormicaFieldResult {
- return if (value != null && value >= 0) FormicaFieldResult.Success
- else FormicaFieldResult.Error("Field2 must be non-negative")
- }
- }
-
- formica.registerField(
- name = TestData::field1,
- required = true,
- validators = setOf(rule1)
+ fun validate_aggregates_errors_with_fieldIds() {
+ val form = Formica(Profile("Ann", "", 5))
+ form.registerField(
+ id = FirstName,
+ validators = ordered(rule { if (it.isNullOrBlank()) FormicaFieldResult.Error("first required") else FormicaFieldResult.Success })
+ )
+ form.registerField(
+ id = Note,
+ validators = ordered(rule { FormicaFieldResult.Success }) // note ok
)
- formica.registerField(
- name = TestData::field2,
- required = true,
- validators = setOf(rule2)
+ // Make firstName empty -> should produce an error map entry
+ form.onChange(FirstName, "")
+ val res = form.validate()
+ assertTrue(res is FormicaResult.Error)
+ val err = res as FormicaResult.Error
+ assertEquals(mapOf("firstName" to "first required"), err.fieldErrors)
+ }
+
+ @Test
+ fun validator_order_shortCircuits_and_custom_runs_last() {
+ var v1Called = false
+ var v2Called = false
+ var customCalled = false
+
+ val form = Formica(Profile("Ann", null, 21))
+ form.registerField(
+ id = FirstName,
+ validators = ordered(
+ rule { v1Called = true; FormicaFieldResult.Error("v1") },
+ rule { v2Called = true; error("should not be called") }
+ ),
+ customValidation = { customCalled = true; FormicaFieldResult.Success },
+ validateOnChange = false
)
- assertEquals(FormicaResult.Valid, formica.validate())
- formica.onChange(TestData::field1, "")
- formica.validate()
- assertTrue(formica.result.first() is FormicaResult.Error)
+ val res = form.validate()
+ assertTrue(res is FormicaResult.Error)
+ assertTrue(v1Called)
+ assertFalse(v2Called) // short-circuited
+ assertFalse(customCalled) // skipped because validator failed
+ }
+
+ @Test
+ fun syncFromData_resets_fields_to_match_current_snapshot() {
+ val form = Formica(Profile("Ann", "hello", 21))
+ val noteField =
+ form.registerField(id = Note, validators = emptySet(), validateOnChange = true)
+
+ // Mutate field (and make it dirty)
+ form.onChange(Note, "changed")
+ assertEquals("changed", noteField.value.value)
+ assertTrue(noteField.dirty.value)
+
+ // Mutate DATA without touching field state via clear()
+ form.clear(Note)
+ assertNull(form.data.value.note)
+ // Field value is still "changed" and dirty at this moment
+ assertEquals("changed", noteField.value.value)
+
+ // Now sync field state from DATA snapshot
+ form.syncFromData()
+ assertNull(noteField.value.value)
+ assertTrue(noteField.result.value is FormicaFieldResult.NoInput)
+ assertFalse(noteField.dirty.value)
+ assertFalse(noteField.touched.value)
}
@Test
- fun `validate returns error when fields are invalid`() = runTest {
- val testData = TestData(field1 = "", field2 = -1)
- val formica = Formica(initialData = testData)
-
- val rule1: ValidationRule = object : ValidationRule {
- override fun validate(value: String?): FormicaFieldResult {
- return if (value.isNullOrEmpty()) FormicaFieldResult.Error("Field1 cannot be empty")
- else FormicaFieldResult.Success
- }
- }
-
- val rule2: ValidationRule = object : ValidationRule {
- override fun validate(value: Int?): FormicaFieldResult {
- return if (value != null && value >= 0) FormicaFieldResult.Success
- else FormicaFieldResult.Error("Field2 must be non-negative")
- }
- }
-
- formica.registerField(
- name = TestData::field1,
- required = true,
- validators = setOf(rule1)
+ fun submit_invokes_onSubmit_only_when_valid() {
+ var submitted: Profile? = null
+ val form = Formica(
+ initialData = Profile("Ann", null, 21),
+ onSubmit = { submitted = it }
)
- formica.registerField(
- name = TestData::field2,
- required = true,
- validators = setOf(rule2)
+ // FirstName required
+ form.registerField(
+ id = FirstName,
+ validators = ordered(rule { if (it.isNullOrBlank()) FormicaFieldResult.Error("required") else FormicaFieldResult.Success })
)
- val validationResult = formica.validate()
- assertTrue(validationResult is FormicaResult.Error)
- assertEquals("Some fields not valid", validationResult.message)
+ // Make invalid
+ form.onChange(FirstName, "")
+ val r1 = form.submit()
+ assertTrue(r1 is FormicaResult.Error)
+ assertNull(submitted)
+
+ // Fix and submit again
+ form.onChange(FirstName, "Ok")
+ val r2 = form.submit()
+ assertTrue(r2 is FormicaResult.Valid)
+ assertNotNull(submitted)
+ assertEquals("Ok", submitted!!.firstName)
+ }
+
+ @Test
+ fun clear_updates_data_only_and_leaves_field_state_as_is() {
+ val form = Formica(Profile("Ann", "keep", 21))
+ val noteField =
+ form.registerField(id = Note, validators = emptySet(), validateOnChange = false)
+
+ // Change field & data to "text"
+ form.onChange(Note, "text")
+ assertEquals("text", noteField.value.value)
+ assertEquals("text", form.data.value.note)
+
+ // Clear via form.clear -> data changes, field state remains "text"
+ form.clear(Note)
+ assertNull(form.data.value.note)
+ assertEquals("text", noteField.value.value) // unchanged!
+ // Caller can optionally call noteField.reset(...) or form.syncFromData()
}
}
diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRuleTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRuleTest.kt
new file mode 100644
index 0000000..c1fe1b5
--- /dev/null
+++ b/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRuleTest.kt
@@ -0,0 +1,209 @@
+package dev.voir.formica
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ValidationRulesTest {
+
+ // ---- Helpers ------------------------------------------------------------
+
+ private fun assertSuccess(result: FormicaFieldResult) =
+ assertTrue(result is FormicaFieldResult.Success, "Expected Success, got $result")
+
+ private fun assertErrorMessage(result: FormicaFieldResult, expected: String) {
+ when (result) {
+ is FormicaFieldResult.Error -> assertEquals(expected, result.message)
+ else -> throw AssertionError("Expected Error('$expected'), got $result")
+ }
+ }
+
+ // ---- validateOnlyIf -----------------------------------------------------
+
+ @Test
+ fun validateOnlyIf_runsRuleWhenActive() {
+ var called = false
+ val rule = ValidationRules.validateOnlyIf(active = { true }) { v: String? ->
+ called = true
+ if (v.isNullOrBlank()) FormicaFieldResult.Error("X") else FormicaFieldResult.Success
+ }
+
+ val r1 = rule.validate(null)
+ assertTrue(called)
+ assertErrorMessage(r1, "X")
+
+ called = false
+ val r2 = rule.validate("ok")
+ assertTrue(called)
+ assertSuccess(r2)
+ }
+
+ @Test
+ fun validateOnlyIf_skipsWhenInactive() {
+ var called = false
+ val rule = ValidationRules.validateOnlyIf(active = { false }) { _: String? ->
+ called = true
+ FormicaFieldResult.Error("should never be called")
+ }
+
+ val r = rule.validate(null)
+ assertFalse(called)
+ assertSuccess(r)
+ }
+
+ // ---- required -----------------------------------------------------------
+
+ @Test
+ fun required_handlesNullBlankAndEmpty() {
+ val ruleStr = ValidationRules.required()
+ assertErrorMessage(ruleStr.validate(null), "Field is required")
+ assertErrorMessage(ruleStr.validate(""), "Field is required")
+ assertErrorMessage(ruleStr.validate(" "), "Field is required")
+ assertSuccess(ruleStr.validate("data"))
+
+ val ruleList = ValidationRules.required>()
+ assertErrorMessage(ruleList.validate(null), "Field is required")
+ assertErrorMessage(ruleList.validate(emptyList()), "Field is required")
+ assertSuccess(ruleList.validate(listOf(1)))
+ }
+
+ @Test
+ fun required_customMessageAndIsEmpty() {
+ val rule = ValidationRules.required(
+ message = "Need a positive int",
+ isEmpty = { it == null || it!! <= 0 }
+ )
+ assertErrorMessage(rule.validate(null), "Need a positive int")
+ assertErrorMessage(rule.validate(0), "Need a positive int")
+ assertSuccess(rule.validate(1))
+ }
+
+ // ---- notEmpty / notBlank ------------------------------------------------
+
+ @Test
+ fun notEmpty_works() {
+ val rule = ValidationRules.notEmpty("NE")
+ assertErrorMessage(rule.validate(""), "NE")
+ assertSuccess(rule.validate(" "))
+ assertSuccess(rule.validate("x"))
+ }
+
+ @Test
+ fun notBlank_works() {
+ val rule = ValidationRules.notBlank("NB")
+ assertErrorMessage(rule.validate(""), "NB")
+ assertErrorMessage(rule.validate(" "), "NB")
+ assertSuccess(rule.validate("x"))
+ assertSuccess(rule.validate(" x "))
+ }
+
+ // ---- email --------------------------------------------------------------
+
+ @Test
+ fun email_validAndInvalid() {
+ val rule = ValidationRules.email("Bad email")
+
+ // Valid
+ assertSuccess(rule.validate("a@b.co"))
+ assertSuccess(rule.validate("first.last+tag@sub.domain.io"))
+
+ // Invalid
+ assertErrorMessage(rule.validate("plainaddress"), "Bad email")
+ assertErrorMessage(rule.validate("a@b"), "Bad email")
+ assertErrorMessage(rule.validate("@nope.com"), "Bad email")
+ assertErrorMessage(rule.validate("a@b..com"), "Bad email")
+ }
+
+ // ---- strongPassword -----------------------------------------------------
+
+ @Test
+ fun strongPassword_coversEachFailurePath() {
+ val rule = ValidationRules.strongPassword(
+ minLength = 8,
+ lengthMessage = "LEN",
+ uppercaseMessage = "UC",
+ lowercaseMessage = "LC",
+ digitMessage = "DG",
+ specialCharacterMessage = "SC"
+ )
+
+ assertErrorMessage(rule.validate("A1!a"), "LEN") // too short
+ assertErrorMessage(rule.validate("abcd123!"), "UC") // no uppercase
+ assertErrorMessage(rule.validate("ABCD123!"), "LC") // no lowercase
+ assertErrorMessage(rule.validate("Abcd!!!!"), "DG") // no digit
+ assertErrorMessage(rule.validate("Abcd1234"), "SC") // no special
+
+ assertSuccess(rule.validate("Abcd123!")) // all good
+ }
+
+ // ---- url ----------------------------------------------------------------
+
+ @Test
+ fun url_withOrWithoutProtocol() {
+ val anyUrl = ValidationRules.url(protocolRequired = false, message = "URL")
+ val protoOnly = ValidationRules.url(protocolRequired = true, message = "URL")
+
+ // Valid without protocol
+ assertSuccess(anyUrl.validate("example.com"))
+ assertSuccess(anyUrl.validate("sub.domain.io/path?q=1"))
+
+ // Valid with protocol
+ assertSuccess(anyUrl.validate("http://example.com"))
+ assertSuccess(anyUrl.validate("https://www.example.org/a/b?x=1#frag"))
+ assertSuccess(protoOnly.validate("https://example.com"))
+
+ // Invalids
+ assertErrorMessage(protoOnly.validate("example.com"), "URL")
+ assertErrorMessage(anyUrl.validate("htp://broken.com"), "URL")
+ assertErrorMessage(anyUrl.validate("://nope.com"), "URL")
+ assertErrorMessage(anyUrl.validate(" "), "URL")
+ }
+
+ // ---- checked ------------------------------------------------------------
+
+ @Test
+ fun checked_rule() {
+ val rule = ValidationRules.checked("Must be checked")
+ assertErrorMessage(rule.validate(false), "Must be checked")
+ assertSuccess(rule.validate(true))
+ }
+
+ // ---- minLength / maxLength ---------------------------------------------
+
+ @Test
+ fun minLength_and_maxLength() {
+ val min3 = ValidationRules.minLength(3, message = "MIN3")
+ val max5 = ValidationRules.maxLength(5, message = "MAX5")
+
+ assertErrorMessage(min3.validate("ab"), "MIN3")
+ assertSuccess(min3.validate("abc"))
+ assertSuccess(min3.validate("abcdef"))
+
+ assertSuccess(max5.validate("abc"))
+ assertSuccess(max5.validate("abcde"))
+ assertErrorMessage(max5.validate("abcdef"), "MAX5")
+ }
+
+ // ---- range (Int) & range (Float) ---------------------------------------
+
+ @Test
+ fun range_int_inclusive() {
+ val r = ValidationRules.range(min = 2, max = 4, message = "RANGE")
+ assertErrorMessage(r.validate(1), "RANGE")
+ assertSuccess(r.validate(2)) // min boundary
+ assertSuccess(r.validate(3))
+ assertSuccess(r.validate(4)) // max boundary
+ assertErrorMessage(r.validate(5), "RANGE")
+ }
+
+ @Test
+ fun range_float_inclusive() {
+ val r = ValidationRules.range(min = 0.5f, max = 1.5f, message = "RANGEF")
+ assertErrorMessage(r.validate(0.49f), "RANGEF")
+ assertSuccess(r.validate(0.5f))
+ assertSuccess(r.validate(1.0f))
+ assertSuccess(r.validate(1.5f))
+ assertErrorMessage(r.validate(1.5001f), "RANGEF")
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8c4d4a5..3a21056 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,11 +1,11 @@
[versions]
-agp = "8.7.3"
-android-compileSdk = "35"
+agp = "8.12.0"
+android-compileSdk = "36"
android-minSdk = "24"
-android-targetSdk = "35"
-compose-plugin = "1.8.1" # https://github.com/JetBrains/compose-multiplatform
+android-targetSdk = "36"
+compose-plugin = "1.8.2" # https://github.com/JetBrains/compose-multiplatform
junit = "4.13.2"
-kotlin = "2.1.21" # https://kotlinlang.org/docs/releases.html
+kotlin = "2.2.0" # https://kotlinlang.org/docs/releases.html
kotlin-coroutines = "1.10.2" # https://github.com/Kotlin/kotlinx.coroutines
androidx-activityCompose = "1.10.1" # https://mvnrepository.com/artifact/androidx.activity/activity-compose
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index df97d72..37f853b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt
index 72d7e12..29208e1 100644
--- a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt
+++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt
@@ -7,9 +7,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
+import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -19,52 +22,95 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
+import dev.voir.formica.FormicaFieldId
import dev.voir.formica.FormicaFieldResult
import dev.voir.formica.FormicaResult
-import dev.voir.formica.collectDataAsState
-import dev.voir.formica.rememberFormica
-import dev.voir.formica.rules.NotEmptyRule
+import dev.voir.formica.ValidationRule
+import dev.voir.formica.ValidationRules
import dev.voir.formica.sample.ui.FormFieldWrapper
-import dev.voir.formica.ui.Formica
+import dev.voir.formica.ui.FormFieldPresence
import dev.voir.formica.ui.FormicaField
+import dev.voir.formica.ui.FormicaProvider
+import dev.voir.formica.ui.rememberFormica
+import dev.voir.formica.ui.rememberFormicaFieldValue
data class FormSchema(
var text: String,
+ var number: Int?,
var optionalText: String?,
var activateAdditionalText: Boolean,
var additionalText: String? = null,
)
+val MainText = FormicaFieldId(
+ id = "text",
+ get = { it.text },
+ set = { d, v -> d.copy(text = v) }
+)
+val Number = FormicaFieldId(
+ id = "number",
+ get = { it.number },
+ set = { d, v -> d.copy(number = v) }
+)
+
+val OptionalText = FormicaFieldId(
+ id = "optionalText",
+ get = { it.optionalText },
+ set = { d, v -> d.copy(optionalText = v) }
+)
+
+val ActivateAdditionalText = FormicaFieldId(
+ id = "activateAdditionalText",
+ get = { it.activateAdditionalText },
+ set = { d, v -> d.copy(activateAdditionalText = v) }
+)
+
+val AdditionalText = FormicaFieldId(
+ id = "additionalText",
+ get = { it.additionalText },
+ set = { d, v -> d.copy(additionalText = v) },
+ clear = { d -> d.copy(additionalText = null) }
+)
+
+
@Composable
fun App() {
+ val verticalScroll = rememberScrollState()
+
val formica = rememberFormica(
initialData = FormSchema(
text = "",
+ number = null,
optionalText = null,
activateAdditionalText = false,
additionalText = null,
)
)
- val formData by formica.collectDataAsState()
- var formError by remember { mutableStateOf(null) }
+ var formError by remember { mutableStateOf(null) }
var formResult by remember { mutableStateOf(null) }
- Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
- Formica(formica = formica) {
+ val isActive = rememberFormicaFieldValue(formica, ActivateAdditionalText)
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 48.dp)
+ .verticalScroll(verticalScroll)
+ ) {
+ FormicaProvider(formica) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxWidth()
) {
FormicaField(
- name = FormSchema::text,
- required = true,
- validators = setOf(NotEmptyRule())
- ) {
+ id = MainText,
+ validators = setOf(ValidationRules.required())
+ ) { field ->
FormFieldWrapper {
- androidx.compose.material.TextField(
+ TextField(
modifier = Modifier.fillMaxWidth(),
- value = field.value!!,
+ value = field.value.orEmpty(),
label = {
Text("Required text")
},
@@ -72,20 +118,21 @@ fun App() {
Text("Some required text")
},
onValueChange = {
- onChange(FormSchema::text, it)
+ field.onChange(it)
},
)
- error.value?.let {
- Text(it, color = Color.Red)
+
+ if (field.error != null) {
+ Text(field.error!!, color = Color.Red)
}
}
}
- FormicaField(name = FormSchema::optionalText, required = false) {
+ FormicaField(id = OptionalText) { field ->
FormFieldWrapper {
- androidx.compose.material.TextField(
+ TextField(
modifier = Modifier.fillMaxWidth(),
- value = field.value ?: "",
+ value = field.value.orEmpty(),
label = {
Text("Optional text")
},
@@ -93,19 +140,49 @@ fun App() {
Text("Some optional text")
},
onValueChange = {
- onChange(FormSchema::optionalText, it)
- }
+ field.onChange(it)
+ },
)
- if (error.value != null) {
- Text(error.value!!, color = Color.Red)
+
+ if (field.error != null) {
+ Text(field.error!!, color = Color.Red)
}
}
}
+ // Number (optional, but must be >= 0 if provided)
FormicaField(
- name = FormSchema::activateAdditionalText,
- required = true,
- ) {
+ id = Number,
+ validators = setOf(
+ ValidationRule { v ->
+ if (v == null) FormicaFieldResult.Success
+ else if (v < 0) FormicaFieldResult.Error("Number must be non-negative")
+ else FormicaFieldResult.Success
+ }
+ )
+ ) { field ->
+ FormFieldWrapper {
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = field.value?.toString().orEmpty(),
+ label = {
+ Text("Number text")
+ },
+ placeholder = {
+ Text("1234")
+ },
+ onValueChange = { s ->
+ field.onChange(s.toIntOrNull())
+ }
+ )
+
+ if (field.error != null) {
+ Text(field.error!!, color = Color.Red)
+ }
+ }
+ }
+
+ FormicaField(id = ActivateAdditionalText) { field ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -114,44 +191,49 @@ fun App() {
Checkbox(
checked = field.value ?: false,
onCheckedChange = {
- onChange(FormSchema::activateAdditionalText, it)
+ field.onChange(it)
})
Text("Activate additional text?")
}
}
- // if (form.value.activateAdditionalText) {
FormicaField(
- name = FormSchema::additionalText,
- required = false,
+ id = AdditionalText,
customValidation = { value ->
- if (formData.activateAdditionalText && value.isNullOrBlank()) {
+ if (value.isNullOrBlank()) {
FormicaFieldResult.Error(message = "Field is required")
} else {
FormicaFieldResult.Success
}
}
- ) {
- FormFieldWrapper {
- androidx.compose.material.TextField(
- modifier = Modifier.fillMaxWidth(),
- value = field.value ?: "",
- label = {
- Text("Required text if checkbox activated")
- },
- placeholder = {
- Text("Some additional text")
- },
- onValueChange = {
- onChange(FormSchema::additionalText, it)
- },
- )
- error.value?.let {
- Text(it, color = Color.Red)
+ ) { field ->
+ FormFieldPresence(
+ form = formica,
+ id = AdditionalText,
+ present = isActive == true,
+ clearOnHide = true
+ ) {
+ FormFieldWrapper {
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = field.value.orEmpty(),
+ label = {
+ Text("Required text if checkbox activated")
+ },
+ placeholder = {
+ Text("Some additional text")
+ },
+ onValueChange = {
+ field.onChange(it)
+ },
+ )
+
+ if (field.error != null) {
+ Text(field.error!!, color = Color.Red)
+ }
}
}
}
- //}
}
}
@@ -169,7 +251,7 @@ fun App() {
formError = null
formResult = formica.data.value
} else if (state is FormicaResult.Error) {
- formError = state.message
+ formError = state
formResult = null
}
}) {
@@ -179,7 +261,8 @@ fun App() {
formError?.let {
Column(modifier = Modifier.fillMaxWidth()) {
Text("Form submit error")
- Text(text = it, color = Color.Red)
+ Text(text = it.message, color = Color.Red)
+ Text(text = it.fieldErrors.toString(), color = Color.Red)
}
}