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>
322 lines
13 KiB
Swift
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))
|
|
}
|