Files
honeyDueKMP/iosApp/iosApp/ResidenceFormView.swift
Trey t dd5e050025 Make residence address fields optional (only name required)
Updated Kotlin models, Android UI, and iOS UI to make all address fields
optional for residences. Only the residence name is now required.

Changes:
- Kotlin: Made propertyType, streetAddress, city, stateProvince, postalCode,
  country nullable in Residence, ResidenceSummary, ResidenceWithTasks models
- Kotlin: Updated navigation routes to handle nullable address fields
- Android: Updated ResidenceFormScreen and ResidenceDetailScreen to handle nulls
- iOS: Updated ResidenceFormView validation to only check name field
- iOS: Updated PropertyHeaderCard and ResidenceCard to use optional binding
  for address field displays

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:03:45 -06:00

322 lines
13 KiB
Swift

import SwiftUI
import ComposeApp
struct ResidenceFormView: View {
let existingResidence: Residence?
@Binding var isPresented: Bool
var onSuccess: (() -> Void)?
@StateObject private var viewModel = ResidenceViewModel()
@FocusState private var focusedField: Field?
// Lookups from DataCache
@State private var residenceTypes: [ResidenceType] = []
// Form fields
@State private var name: String = ""
@State private var selectedPropertyType: ResidenceType?
@State private var streetAddress: String = ""
@State private var apartmentUnit: String = ""
@State private var city: String = ""
@State private var stateProvince: String = ""
@State private var postalCode: String = ""
@State private var country: String = "USA"
@State private var bedrooms: String = ""
@State private var bathrooms: String = ""
@State private var squareFootage: String = ""
@State private var lotSize: String = ""
@State private var yearBuilt: String = ""
@State private var description: String = ""
@State private var isPrimary: Bool = false
// Validation errors
@State private var nameError: String = ""
enum Field {
case name, streetAddress, apartmentUnit, city, stateProvince, postalCode, country
case bedrooms, bathrooms, squareFootage, lotSize, yearBuilt, description
}
private var isEditMode: Bool {
existingResidence != nil
}
private var canSave: Bool {
!name.isEmpty
}
var body: some View {
NavigationView {
Form {
Section {
TextField("Property Name", text: $name)
.focused($focusedField, equals: .name)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
if !nameError.isEmpty {
Text(nameError)
.font(.caption)
.foregroundColor(.red)
}
Picker("Property Type", selection: $selectedPropertyType) {
Text("Select Type").tag(nil as ResidenceType?)
ForEach(residenceTypes, id: \.id) { type in
Text(type.name).tag(type as ResidenceType?)
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
} header: {
Text("Property Details")
} footer: {
Text("Required: Name")
.font(.caption)
.foregroundColor(.red)
}
Section {
TextField("Street Address", text: $streetAddress)
.focused($focusedField, equals: .streetAddress)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
TextField("Apartment/Unit (optional)", text: $apartmentUnit)
.focused($focusedField, equals: .apartmentUnit)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.apartmentUnitField)
TextField("City", text: $city)
.focused($focusedField, equals: .city)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
TextField("State/Province", text: $stateProvince)
.focused($focusedField, equals: .stateProvince)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
TextField("Postal Code", text: $postalCode)
.focused($focusedField, equals: .postalCode)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
TextField("Country", text: $country)
.focused($focusedField, equals: .country)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
} header: {
Text("Address")
}
Section(header: Text("Property Features")) {
HStack {
Text("Bedrooms")
Spacer()
TextField("0", text: $bedrooms)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bedrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
}
HStack {
Text("Bathrooms")
Spacer()
TextField("0.0", text: $bathrooms)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bathrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
}
TextField("Square Footage", text: $squareFootage)
.keyboardType(.numberPad)
.focused($focusedField, equals: .squareFootage)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.squareFootageField)
TextField("Lot Size (acres)", text: $lotSize)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .lotSize)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.lotSizeField)
TextField("Year Built", text: $yearBuilt)
.keyboardType(.numberPad)
.focused($focusedField, equals: .yearBuilt)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.yearBuiltField)
}
Section(header: Text("Additional Details")) {
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.descriptionField)
Toggle("Primary Residence", isOn: $isPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
}
.navigationTitle(isEditMode ? "Edit Residence" : "Add Residence")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.formCancelButton)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(!canSave || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
}
}
.onAppear {
loadResidenceTypes()
initializeForm()
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { submitForm() }
)
}
}
private func loadResidenceTypes() {
Task {
// Get residence types from DataCache via APILayer
let result = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
if let success = result as? ApiResultSuccess<NSArray>,
let types = success.data as? [ResidenceType] {
await MainActor.run {
self.residenceTypes = types
}
} else {
// Fallback to DataCache directly
await MainActor.run {
if let cached = DataCache.shared.residenceTypes.value as? [ResidenceType] {
self.residenceTypes = cached
}
}
}
}
}
private func initializeForm() {
if let residence = existingResidence {
// Edit mode - populate fields from existing residence
name = residence.name
streetAddress = residence.streetAddress ?? ""
apartmentUnit = residence.apartmentUnit ?? ""
city = residence.city ?? ""
stateProvince = residence.stateProvince ?? ""
postalCode = residence.postalCode ?? ""
country = residence.country ?? ""
bedrooms = residence.bedrooms != nil ? "\(residence.bedrooms!)" : ""
bathrooms = residence.bathrooms != nil ? "\(residence.bathrooms!)" : ""
squareFootage = residence.squareFootage != nil ? "\(residence.squareFootage!)" : ""
lotSize = residence.lotSize != nil ? "\(residence.lotSize!)" : ""
yearBuilt = residence.yearBuilt != nil ? "\(residence.yearBuilt!)" : ""
description = residence.description_ ?? ""
isPrimary = residence.isPrimary
// Set the selected property type
if let propertyTypeStr = residence.propertyType, let propertyTypeId = Int(propertyTypeStr) {
selectedPropertyType = residenceTypes.first { $0.id == propertyTypeId }
}
}
// In add mode, leave selectedPropertyType as nil to force user to select
}
private func validateForm() -> Bool {
var isValid = true
if name.isEmpty {
nameError = "Name is required"
isValid = false
} else {
nameError = ""
}
return isValid
}
private func submitForm() {
guard validateForm() else { return }
// Convert optional numeric fields to Kotlin types
let bedroomsValue: KotlinInt? = {
guard !bedrooms.isEmpty, let value = Int32(bedrooms) else { return nil }
return KotlinInt(int: value)
}()
let bathroomsValue: KotlinFloat? = {
guard !bathrooms.isEmpty, let value = Float(bathrooms) else { return nil }
return KotlinFloat(float: value)
}()
let squareFootageValue: KotlinInt? = {
guard !squareFootage.isEmpty, let value = Int32(squareFootage) else { return nil }
return KotlinInt(int: value)
}()
let lotSizeValue: KotlinFloat? = {
guard !lotSize.isEmpty, let value = Float(lotSize) else { return nil }
return KotlinFloat(float: value)
}()
let yearBuiltValue: KotlinInt? = {
guard !yearBuilt.isEmpty, let value = Int32(yearBuilt) else { return nil }
return KotlinInt(int: value)
}()
// Convert propertyType to KotlinInt if it exists
let propertyTypeValue: KotlinInt? = {
guard let type = selectedPropertyType else { return nil }
return KotlinInt(int: Int32(type.id))
}()
let request = ResidenceCreateRequest(
name: name,
propertyType: propertyTypeValue,
streetAddress: streetAddress.isEmpty ? nil : streetAddress,
apartmentUnit: apartmentUnit.isEmpty ? nil : apartmentUnit,
city: city.isEmpty ? nil : city,
stateProvince: stateProvince.isEmpty ? nil : stateProvince,
postalCode: postalCode.isEmpty ? nil : postalCode,
country: country.isEmpty ? nil : country,
bedrooms: bedroomsValue,
bathrooms: bathroomsValue,
squareFootage: squareFootageValue,
lotSize: lotSizeValue,
yearBuilt: yearBuiltValue,
description: description.isEmpty ? nil : description,
purchaseDate: nil,
purchasePrice: nil,
isPrimary: isPrimary
)
if let residence = existingResidence {
// Edit mode
viewModel.updateResidence(id: residence.id, request: request) { success in
if success {
onSuccess?()
isPresented = false
}
}
} else {
// Add mode
viewModel.createResidence(request: request) { success in
if success {
onSuccess?()
isPresented = false
}
}
}
}
}
#Preview("Add Mode") {
ResidenceFormView(existingResidence: nil, isPresented: .constant(true))
}