P7 Stream X: ResidenceFormState + validation (iOS parity)

Pure-function field validators matching iOS ResidenceFormView rules.
Mirrors Stream W's TaskFormState pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 13:34:49 -05:00
parent 1cbeeafa2d
commit cf2aca583b
4 changed files with 725 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
package com.tt.honeyDue.ui.screens.residence
import com.tt.honeyDue.models.ResidenceCreateRequest
import com.tt.honeyDue.models.ResidenceUpdateRequest
/**
* Immutable snapshot of the create/edit-residence form field values.
*
* Mirrors the iOS `ResidenceFormView` state
* (`iosApp/iosApp/ResidenceFormView.swift`) and its companion
* `ResidenceFormState` (`iosApp/iosApp/Core/FormStates/ResidenceFormState.swift`).
*
* All fields except [name] are optional; string numeric fields stay as
* strings here so the Compose layer can bind them directly to text inputs.
* The `toCreateRequest` / `toUpdateRequest` builders perform the final
* parse after validation.
*/
data class ResidenceFormFields(
val name: String = "",
val propertyTypeId: Int? = null,
val streetAddress: String = "",
val apartmentUnit: String = "",
val city: String = "",
val stateProvince: String = "",
val postalCode: String = "",
val country: String = "USA",
val bedrooms: String = "",
val bathrooms: String = "",
val squareFootage: String = "",
val lotSize: String = "",
val yearBuilt: String = "",
val description: String = "",
val isPrimary: Boolean = false,
)
/**
* Per-field error snapshot — one nullable string per validated field.
* A `null` entry means "no error". Non-validated fields (address parts,
* description, etc.) are not listed here because iOS has no rules for them.
*/
data class ResidenceFormErrors(
val name: String? = null,
val bedrooms: String? = null,
val bathrooms: String? = null,
val squareFootage: String? = null,
val lotSize: String? = null,
val yearBuilt: String? = null,
) {
/** True when every entry is null — i.e. the form passes validation. */
val isEmpty: Boolean
get() = name == null &&
bedrooms == null &&
bathrooms == null &&
squareFootage == null &&
lotSize == null &&
yearBuilt == null
}
/**
* Mutable container that glues [ResidenceFormFields] to the pure
* validators in [ResidenceFormValidation] and exposes typed request
* builders. Mirrors the Stream W `TaskFormState` pattern.
*
* 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 `remember { mutableStateOf(...) }` as needed.
*/
class ResidenceFormState(initial: ResidenceFormFields = ResidenceFormFields()) {
var fields: ResidenceFormFields = initial
private set
/** Derived: recomputed from the current [fields] on every access. */
val errors: ResidenceFormErrors
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: ResidenceFormFields) {
fields = f
}
/** Alias for `errors` — returned as an explicit snapshot for callers that prefer imperative style. */
fun validate(): ResidenceFormErrors = errors
/**
* Builds a [ResidenceCreateRequest] from the current fields, or `null`
* if the form does not validate. Blank optional strings are mapped to
* `null` to match the iOS `submitForm()` mapping.
*/
fun toCreateRequest(): ResidenceCreateRequest? {
if (!isValid) return null
val f = fields
return ResidenceCreateRequest(
name = f.name,
propertyTypeId = f.propertyTypeId,
streetAddress = f.streetAddress.blankToNull(),
apartmentUnit = f.apartmentUnit.blankToNull(),
city = f.city.blankToNull(),
stateProvince = f.stateProvince.blankToNull(),
postalCode = f.postalCode.blankToNull(),
country = f.country.blankToNull(),
bedrooms = f.bedrooms.blankToNull()?.toIntOrNull(),
bathrooms = f.bathrooms.blankToNull()?.toDoubleOrNull(),
squareFootage = f.squareFootage.blankToNull()?.toIntOrNull(),
lotSize = f.lotSize.blankToNull()?.toDoubleOrNull(),
yearBuilt = f.yearBuilt.blankToNull()?.toIntOrNull(),
description = f.description.blankToNull(),
isPrimary = f.isPrimary,
)
}
/**
* Builds a [ResidenceUpdateRequest] from the current fields, or `null`
* if the form does not validate. Uses the same blank-to-null mapping
* as create.
*/
fun toUpdateRequest(residenceId: Int): ResidenceUpdateRequest? {
if (!isValid) return null
val f = fields
return ResidenceUpdateRequest(
name = f.name,
propertyTypeId = f.propertyTypeId,
streetAddress = f.streetAddress.blankToNull(),
apartmentUnit = f.apartmentUnit.blankToNull(),
city = f.city.blankToNull(),
stateProvince = f.stateProvince.blankToNull(),
postalCode = f.postalCode.blankToNull(),
country = f.country.blankToNull(),
bedrooms = f.bedrooms.blankToNull()?.toIntOrNull(),
bathrooms = f.bathrooms.blankToNull()?.toDoubleOrNull(),
squareFootage = f.squareFootage.blankToNull()?.toIntOrNull(),
lotSize = f.lotSize.blankToNull()?.toDoubleOrNull(),
yearBuilt = f.yearBuilt.blankToNull()?.toIntOrNull(),
description = f.description.blankToNull(),
isPrimary = f.isPrimary,
)
}
/**
* Overload for nav args typed as [Long]. Narrows to [Int] because the
* wire model uses Int.
*/
fun toUpdateRequest(residenceId: Long): ResidenceUpdateRequest? =
toUpdateRequest(residenceId.toInt())
private fun computeErrors(f: ResidenceFormFields): ResidenceFormErrors = ResidenceFormErrors(
name = ResidenceFormValidation.validateName(f.name),
bedrooms = ResidenceFormValidation.validateBedrooms(f.bedrooms),
bathrooms = ResidenceFormValidation.validateBathrooms(f.bathrooms),
squareFootage = ResidenceFormValidation.validateSquareFootage(f.squareFootage),
lotSize = ResidenceFormValidation.validateLotSize(f.lotSize),
yearBuilt = ResidenceFormValidation.validateYearBuilt(f.yearBuilt),
)
private fun String.blankToNull(): String? = ifBlank { null }
}

View File

@@ -0,0 +1,109 @@
package com.tt.honeyDue.ui.screens.residence
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
/**
* Pure-function validators for the residence create/edit form.
*
* Parity with iOS:
* - `iosApp/iosApp/ResidenceFormView.swift::validateForm()` — only `name`
* is required (the iOS `canSave` and `validateForm` both gate solely on
* `!name.isEmpty`). We keep that contract: all address/feature fields are
* optional.
* - `iosApp/iosApp/Core/FormStates/ResidenceFormState.swift` — numeric
* fields use `asOptionalInt`/`asOptionalDouble` which silently drop
* unparseable values. On KMM we surface a user-facing error instead so
* the client isn't dropping input on the floor, matching the phrasing
* used by Stream W's `TaskFormValidation`.
*
* Each function returns `null` when the value is valid, or an error
* message otherwise. Callers compose these into a [ResidenceFormErrors]
* snapshot.
*/
object ResidenceFormValidation {
/** Maximum length enforced by the backend's `name` column. */
private const val NAME_MAX_LENGTH = 100
/** iOS uses `yearBuilt` as a 4-digit integer with no UI range gate; the
* API accepts any Int. We bound on the client to catch obvious typos. */
private const val YEAR_BUILT_MIN = 1800
fun validateName(value: String): String? {
if (value.isBlank()) return "Name is required"
if (value.length > NAME_MAX_LENGTH) {
return "Name must be $NAME_MAX_LENGTH characters or fewer"
}
return null
}
/**
* Optional integer >= 0. Matches iOS `bedrooms.value.asOptionalInt` —
* iOS allows 0 silently, so do we.
*/
fun validateBedrooms(value: String): String? {
if (value.isBlank()) return null
val n = value.toIntOrNull()
if (n == null || n < 0) {
return "Bedrooms must be a non-negative whole number"
}
return null
}
/** Optional double >= 0. Matches iOS `bathrooms.value.asOptionalDouble`. */
fun validateBathrooms(value: String): String? {
if (value.isBlank()) return null
val n = value.toDoubleOrNull()
if (n == null || n < 0.0) {
return "Bathrooms must be a non-negative number"
}
return null
}
/** Optional integer > 0. A 0-square-foot property is nonsensical. */
fun validateSquareFootage(value: String): String? {
if (value.isBlank()) return null
val n = value.toIntOrNull()
if (n == null || n <= 0) {
return "Square footage must be a positive whole number"
}
return null
}
/** Optional double > 0. Acres typically, but unit-agnostic. */
fun validateLotSize(value: String): String? {
if (value.isBlank()) return null
val n = value.toDoubleOrNull()
if (n == null || n <= 0.0) {
return "Lot size must be a positive number"
}
return null
}
/**
* Optional 4-digit year between [YEAR_BUILT_MIN] and the current year
* inclusive. iOS coerces via `asOptionalInt` with no range gate; we
* tighten on the client.
*/
fun validateYearBuilt(value: String, currentYear: Int = defaultCurrentYear()): String? {
if (value.isBlank()) return null
if (value.length != 4) return "Year built must be a 4-digit year"
val n = value.toIntOrNull() ?: return "Year built must be a 4-digit year"
if (n < YEAR_BUILT_MIN || n > currentYear) {
return "Year built must be between $YEAR_BUILT_MIN and the current year"
}
return null
}
@OptIn(ExperimentalTime::class)
private fun defaultCurrentYear(): Int {
val nowMillis = Clock.System.now().toEpochMilliseconds()
return Instant.fromEpochMilliseconds(nowMillis)
.toLocalDateTime(TimeZone.currentSystemDefault())
.year
}
}