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:
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user