Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class Formica<Data>(val initialData: Data, private val onSubmit: ((Data) -> Unit
validateOnChange = validateOnChange
)
// Store in registry with erased generics
@Suppress("UNCHECKED_CAST")
fields[id.id] = (id as FormicaFieldId<Data, Any?>) to (field as FormicaField<Any?>)

return field
Expand Down
44 changes: 32 additions & 12 deletions formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package dev.voir.formica.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
Expand All @@ -10,6 +9,8 @@ import dev.voir.formica.Formica
import dev.voir.formica.FormicaFieldId
import dev.voir.formica.FormicaFieldResult
import dev.voir.formica.ValidationRule
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

/**
* A stable snapshot of a form field's UI-facing state and operations.
Expand Down Expand Up @@ -87,13 +88,6 @@ fun <D, V> FormicaField(
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))
}

// Package the reactive state and callbacks into a stable adapter for the UI.
val adapter = remember(value, error, touched, dirty) {
FieldAdapter(
Expand Down Expand Up @@ -150,12 +144,38 @@ fun <D, V> FormicaField(
* 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 <D, V> rememberFormicaFieldValue(form: Formica<D>, id: FormicaFieldId<D, V>): V? {
val data by form.data.collectAsState()
return id.get(data)
fun <D, V> rememberFormicaFieldValue(
form: Formica<D>,
id: FormicaFieldId<D, V>,
// Optional comparator (useful for floats with epsilon)
areEquivalent: (V?, V?) -> Boolean = { a, b -> a == b }
): V? {
// If the field is registered, prefer its own StateFlow (cheapest & already scoped)
val registered = remember(form, id) { form.getRegisteredField(id) }
val initial = remember(form, id) { id.get(form.data.value) }

return if (registered != null) {
registered.value.collectAsState(initial = initial).value
} else {
// Project the form snapshot to just this field and suppress identical emissions
val projected = remember(form, id, areEquivalent) {
form.data
.map { id.get(it) }
.distinctUntilChanged { old, new -> areEquivalent(old, new) }
}
projected.collectAsState(initial = initial).value
}
}

@Composable
fun <D, V> rememberFormicaFieldValue(
id: FormicaFieldId<D, V>,
areEquivalent: (V?, V?) -> Boolean = { a, b -> a == b }
): V? {
val form = formicaOf<D>()
return rememberFormicaFieldValue(form, id, areEquivalent)
}

/**
Expand Down
53 changes: 45 additions & 8 deletions sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import dev.voir.formica.ui.rememberFormicaFieldValue

data class FormSchema(
var text: String,
var number: Int?,
var numberRequired: Int,
var numberOptional: Int?,
var optionalText: String?,
var activateAdditionalText: Boolean,
var additionalText: String? = null,
Expand All @@ -47,10 +48,16 @@ val MainText = FormicaFieldId<FormSchema, String>(
get = { it.text },
set = { d, v -> d.copy(text = v) }
)
val Number = FormicaFieldId<FormSchema, Int?>(
id = "number",
get = { it.number },
set = { d, v -> d.copy(number = v) }
val NumberRequired = FormicaFieldId<FormSchema, Int>(
id = "numberRequired",
get = { it.numberRequired },
set = { d, v -> d.copy(numberRequired = v) }
)

val NumberOptional = FormicaFieldId<FormSchema, Int?>(
id = "numberOptional",
get = { it.numberOptional },
set = { d, v -> d.copy(numberOptional = v) }
)

val OptionalText = FormicaFieldId<FormSchema, String?>(
Expand Down Expand Up @@ -80,7 +87,8 @@ fun App() {
val formica = rememberFormica(
initialData = FormSchema(
text = "",
number = null,
numberRequired = 0,
numberOptional = null,
optionalText = null,
activateAdditionalText = false,
additionalText = null,
Expand Down Expand Up @@ -150,9 +158,38 @@ fun App() {
}
}

// Number (required and must be >= 10 if provided)
FormicaField(
id = NumberRequired,
validators = setOf(
ValidationRules.required(),
ValidationRules.min(10)
)
) { field ->
FormFieldWrapper {
TextField(
modifier = Modifier.fillMaxWidth(),
value = field.value?.toString().orEmpty(),
label = {
Text("Number (required and >= 10)")
},
placeholder = {
Text("1234")
},
onValueChange = { s ->
field.onChange(s.toIntOrNull())
}
)

if (field.error != null) {
Text(field.error!!, color = Color.Red)
}
}
}

// Number (optional, but must be >= 0 if provided)
FormicaField(
id = Number,
id = NumberOptional,
validators = setOf(
ValidationRule { v ->
if (v == null) FormicaFieldResult.Success
Expand All @@ -166,7 +203,7 @@ fun App() {
modifier = Modifier.fillMaxWidth(),
value = field.value?.toString().orEmpty(),
label = {
Text("Number text")
Text("Number (optional, but >= 0 if provided)")
},
placeholder = {
Text("1234")
Expand Down
Loading