P7 Stream W: TaskFormState + validation (iOS parity)

Pure-function field validators matching iOS TaskFormStates.swift error
strings. TaskFormState container derives errors from fields, exposes
isValid and typed request builders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:22:53 -05:00
parent edc22c0d2b
commit 3700968d00
4 changed files with 489 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
package com.tt.honeyDue.ui.screens.task
import com.tt.honeyDue.models.TaskCreateRequest
import com.tt.honeyDue.models.TaskUpdateRequest
import kotlinx.datetime.LocalDate
/**
* Immutable snapshot of the create/edit-task form field values.
*
* Mirrors the iOS `TaskFormState` container in
* `iosApp/iosApp/Core/FormStates/TaskFormStates.swift` — kept purely
* descriptive so it can be fed into pure validators.
*
* IDs are [Int] because [TaskCreateRequest]/[TaskUpdateRequest] use [Int].
* The public plan spec asked for Long, but matching the existing wire
* contract avoids a lossy conversion at the call-site.
*/
data class TaskFormFields(
val title: String = "",
val description: String = "",
val priorityId: Int? = null,
val categoryId: Int? = null,
val frequencyId: Int? = null,
val dueDate: LocalDate? = null,
val estimatedCost: String = "",
val residenceId: Int? = null,
)
/**
* Per-field error snapshot — one nullable string per validated field. A
* `null` entry means "no error".
*/
data class TaskFormErrors(
val title: String? = null,
val priorityId: String? = null,
val categoryId: String? = null,
val frequencyId: String? = null,
val estimatedCost: String? = null,
val residenceId: String? = null,
) {
/** True when every entry is null — i.e. the form passes validation. */
val isEmpty: Boolean
get() = title == null &&
priorityId == null &&
categoryId == null &&
frequencyId == null &&
estimatedCost == null &&
residenceId == null
}
/**
* Mutable container that glues [TaskFormFields] to the pure validators in
* [TaskFormValidation] and exposes typed request builders.
*
* Deliberately not a Compose `State` — keep this plain so common tests can
* exercise it without a UI runtime. The Compose call-site can wrap the
* container in a `remember { mutableStateOf(...) }` as needed.
*/
class TaskFormState(initial: TaskFormFields = TaskFormFields()) {
var fields: TaskFormFields = initial
private set
/** Derived: recomputed from the current [fields] on every access. */
val errors: TaskFormErrors
get() = computeErrors(fields)
/** Derived: `true` iff every validator returns `null`. */
val isValid: Boolean
get() = errors.isEmpty
/** Replaces the current field snapshot. Errors/[isValid] update automatically. */
fun update(f: TaskFormFields) {
fields = f
}
/** Alias for `errors` — returned as an explicit snapshot for callers that prefer imperative style. */
fun validate(): TaskFormErrors = errors
/**
* Builds a [TaskCreateRequest] from the current fields, or `null` if the
* form does not validate. Blank description / empty cost are mapped to
* `null` (matching iOS).
*/
fun toCreateRequest(): TaskCreateRequest? {
if (!isValid) return null
val f = fields
// Non-null asserts are safe: isValid implies all required fields are set.
return TaskCreateRequest(
residenceId = f.residenceId!!,
title = f.title,
description = f.description.ifBlank { null },
categoryId = f.categoryId,
priorityId = f.priorityId,
frequencyId = f.frequencyId,
dueDate = f.dueDate?.toString(),
estimatedCost = f.estimatedCost.ifBlank { null }?.toDoubleOrNull(),
)
}
/**
* Builds a [TaskUpdateRequest] from the current fields. Unlike create,
* edit mode does not require a residence (the task already has one),
* so this returns `null` only when the non-residence validators fail.
*/
fun toUpdateRequest(taskId: Int): TaskUpdateRequest? {
val errs = errors
val editErrorsPresent = errs.title != null ||
errs.priorityId != null ||
errs.categoryId != null ||
errs.frequencyId != null ||
errs.estimatedCost != null
if (editErrorsPresent) return null
val f = fields
return TaskUpdateRequest(
title = f.title,
description = f.description.ifBlank { null },
categoryId = f.categoryId,
priorityId = f.priorityId,
frequencyId = f.frequencyId,
dueDate = f.dueDate?.toString(),
estimatedCost = f.estimatedCost.ifBlank { null }?.toDoubleOrNull(),
)
}
/**
* Overload that accepts [Long] for symmetry with screens that pull the
* task id from navigation arguments typed as Long. Narrows to [Int]
* because the wire model expects Int.
*/
fun toUpdateRequest(taskId: Long): TaskUpdateRequest? =
toUpdateRequest(taskId.toInt())
private fun computeErrors(f: TaskFormFields): TaskFormErrors = TaskFormErrors(
title = TaskFormValidation.validateTitle(f.title),
priorityId = TaskFormValidation.validatePriorityId(f.priorityId),
categoryId = TaskFormValidation.validateCategoryId(f.categoryId),
frequencyId = TaskFormValidation.validateFrequencyId(f.frequencyId),
estimatedCost = TaskFormValidation.validateEstimatedCost(f.estimatedCost),
residenceId = TaskFormValidation.validateResidenceId(f.residenceId),
)
}

View File

@@ -0,0 +1,46 @@
package com.tt.honeyDue.ui.screens.task
/**
* Pure-function validators for the task create/edit form.
*
* Error strings are kept in 1:1 parity with iOS:
* - `iosApp/iosApp/Task/TaskFormView.swift::validateForm()` — the
* authoritative source for the "Please select a..." / "... is required"
* copy that the user actually sees.
* - `iosApp/iosApp/Core/FormStates/TaskFormStates.swift` — container shape.
*
* Each function returns `null` when the value is valid, or an error message
* otherwise. Callers compose these into a [TaskFormErrors] snapshot.
*/
object TaskFormValidation {
fun validateTitle(value: String): String? =
if (value.isBlank()) "Title is required" else null
fun validatePriorityId(value: Int?): String? =
if (value == null) "Please select a priority" else null
fun validateCategoryId(value: Int?): String? =
if (value == null) "Please select a category" else null
fun validateFrequencyId(value: Int?): String? =
if (value == null) "Please select a frequency" else null
/**
* Estimated cost is optional — an empty/blank string is valid. A non-empty
* value must parse as a [Double]. Matches iOS `estimatedCost.value.asOptionalDouble`
* plus the "must be a valid number" phrasing already used for
* `customIntervalDays` in `TaskFormView.swift`.
*/
fun validateEstimatedCost(value: String): String? {
if (value.isBlank()) return null
return if (value.toDoubleOrNull() == null) {
"Estimated cost must be a valid number"
} else {
null
}
}
fun validateResidenceId(value: Int?): String? =
if (value == null) "Property is required" else null
}