wip
This commit is contained in:
365
iosApp/iosApp/EditResidenceView.swift
Normal file
365
iosApp/iosApp/EditResidenceView.swift
Normal file
@@ -0,0 +1,365 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct EditResidenceView: View {
|
||||
let residence: Residence
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@StateObject private var lookupsManager = LookupsManager.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
// 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 = ""
|
||||
@State private var streetAddressError: String = ""
|
||||
@State private var cityError: String = ""
|
||||
@State private var stateProvinceError: String = ""
|
||||
@State private var postalCodeError: String = ""
|
||||
|
||||
// Picker state
|
||||
@State private var showPropertyTypePicker = false
|
||||
|
||||
typealias Field = AddResidenceView.Field
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
Color(.systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Required Information Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Required Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
FormTextField(
|
||||
label: "Property Name",
|
||||
text: $name,
|
||||
error: nameError,
|
||||
placeholder: "My Home",
|
||||
focusedField: $focusedField,
|
||||
field: .name
|
||||
)
|
||||
|
||||
// Property Type Picker
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Property Type")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button(action: {
|
||||
showPropertyTypePicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Text(selectedPropertyType?.name ?? "Select Type")
|
||||
.foregroundColor(selectedPropertyType == nil ? .gray : .primary)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
label: "Street Address",
|
||||
text: $streetAddress,
|
||||
error: streetAddressError,
|
||||
placeholder: "123 Main St",
|
||||
focusedField: $focusedField,
|
||||
field: .streetAddress
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Apartment/Unit (Optional)",
|
||||
text: $apartmentUnit,
|
||||
error: "",
|
||||
placeholder: "Apt 4B",
|
||||
focusedField: $focusedField,
|
||||
field: .apartmentUnit
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "City",
|
||||
text: $city,
|
||||
error: cityError,
|
||||
placeholder: "San Francisco",
|
||||
focusedField: $focusedField,
|
||||
field: .city
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "State/Province",
|
||||
text: $stateProvince,
|
||||
error: stateProvinceError,
|
||||
placeholder: "CA",
|
||||
focusedField: $focusedField,
|
||||
field: .stateProvince
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Postal Code",
|
||||
text: $postalCode,
|
||||
error: postalCodeError,
|
||||
placeholder: "94102",
|
||||
focusedField: $focusedField,
|
||||
field: .postalCode
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Country",
|
||||
text: $country,
|
||||
error: "",
|
||||
placeholder: "USA",
|
||||
focusedField: $focusedField,
|
||||
field: .country
|
||||
)
|
||||
}
|
||||
|
||||
// Optional Information Section
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Optional Information")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
FormTextField(
|
||||
label: "Bedrooms",
|
||||
text: $bedrooms,
|
||||
error: "",
|
||||
placeholder: "3",
|
||||
focusedField: $focusedField,
|
||||
field: .bedrooms,
|
||||
keyboardType: .numberPad
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Bathrooms",
|
||||
text: $bathrooms,
|
||||
error: "",
|
||||
placeholder: "2.5",
|
||||
focusedField: $focusedField,
|
||||
field: .bathrooms,
|
||||
keyboardType: .decimalPad
|
||||
)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
label: "Square Footage",
|
||||
text: $squareFootage,
|
||||
error: "",
|
||||
placeholder: "1800",
|
||||
focusedField: $focusedField,
|
||||
field: .squareFootage,
|
||||
keyboardType: .numberPad
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Lot Size (acres)",
|
||||
text: $lotSize,
|
||||
error: "",
|
||||
placeholder: "0.25",
|
||||
focusedField: $focusedField,
|
||||
field: .lotSize,
|
||||
keyboardType: .decimalPad
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
label: "Year Built",
|
||||
text: $yearBuilt,
|
||||
error: "",
|
||||
placeholder: "2010",
|
||||
focusedField: $focusedField,
|
||||
field: .yearBuilt,
|
||||
keyboardType: .numberPad
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Description")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextEditor(text: $description)
|
||||
.frame(height: 100)
|
||||
.padding(8)
|
||||
.background(Color(.systemBackground))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Toggle("Primary Residence", isOn: $isPrimary)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
// Submit Button
|
||||
Button(action: submitForm) {
|
||||
HStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
} else {
|
||||
Text("Update Property")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(viewModel.isLoading ? Color.gray : Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Residence")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPropertyTypePicker) {
|
||||
PropertyTypePickerView(
|
||||
propertyTypes: lookupsManager.residenceTypes,
|
||||
selectedType: $selectedPropertyType,
|
||||
isPresented: $showPropertyTypePicker
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
populateFields()
|
||||
}
|
||||
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK") {
|
||||
viewModel.clearError()
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func populateFields() {
|
||||
// Populate fields from the 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
|
||||
selectedPropertyType = lookupsManager.residenceTypes.first { $0.id == Int(residence.propertyType) ?? 0 }
|
||||
}
|
||||
|
||||
private func validateForm() -> Bool {
|
||||
var isValid = true
|
||||
|
||||
if name.isEmpty {
|
||||
nameError = "Name is required"
|
||||
isValid = false
|
||||
} else {
|
||||
nameError = ""
|
||||
}
|
||||
|
||||
if streetAddress.isEmpty {
|
||||
streetAddressError = "Street address is required"
|
||||
isValid = false
|
||||
} else {
|
||||
streetAddressError = ""
|
||||
}
|
||||
|
||||
if city.isEmpty {
|
||||
cityError = "City is required"
|
||||
isValid = false
|
||||
} else {
|
||||
cityError = ""
|
||||
}
|
||||
|
||||
if stateProvince.isEmpty {
|
||||
stateProvinceError = "State/Province is required"
|
||||
isValid = false
|
||||
} else {
|
||||
stateProvinceError = ""
|
||||
}
|
||||
|
||||
if postalCode.isEmpty {
|
||||
postalCodeError = "Postal code is required"
|
||||
isValid = false
|
||||
} else {
|
||||
postalCodeError = ""
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
private func submitForm() {
|
||||
guard validateForm() else { return }
|
||||
guard let propertyType = selectedPropertyType else {
|
||||
// Show error
|
||||
return
|
||||
}
|
||||
|
||||
let request = ResidenceCreateRequest(
|
||||
name: name,
|
||||
propertyType: Int32(propertyType.id),
|
||||
streetAddress: streetAddress,
|
||||
apartmentUnit: apartmentUnit.isEmpty ? nil : apartmentUnit,
|
||||
city: city,
|
||||
stateProvince: stateProvince,
|
||||
postalCode: postalCode,
|
||||
country: country,
|
||||
bedrooms: Int32(bedrooms) as? KotlinInt,
|
||||
bathrooms: Float(bathrooms) as? KotlinFloat,
|
||||
squareFootage: Int32(squareFootage) as? KotlinInt,
|
||||
lotSize: Float(lotSize) as? KotlinFloat,
|
||||
yearBuilt: Int32(yearBuilt) as? KotlinInt,
|
||||
description: description.isEmpty ? nil : description,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: isPrimary
|
||||
)
|
||||
|
||||
viewModel.updateResidence(id: residence.id, request: request) { success in
|
||||
if success {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ struct ResidenceDetailView: View {
|
||||
@State private var isLoadingTasks = false
|
||||
@State private var tasksError: String?
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditResidence = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -47,6 +48,16 @@ struct ResidenceDetailView: View {
|
||||
.navigationTitle("Property Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if viewModel.selectedResidence != nil {
|
||||
Button(action: {
|
||||
showEditResidence = true
|
||||
}) {
|
||||
Text("Edit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
showAddTask = true
|
||||
@@ -58,12 +69,23 @@ struct ResidenceDetailView: View {
|
||||
.sheet(isPresented: $showAddTask) {
|
||||
AddTaskView(residenceId: residenceId, isPresented: $showAddTask)
|
||||
}
|
||||
.sheet(isPresented: $showEditResidence) {
|
||||
if let residence = viewModel.selectedResidence {
|
||||
EditResidenceView(residence: residence, isPresented: $showEditResidence)
|
||||
}
|
||||
}
|
||||
.onChange(of: showAddTask) { isShowing in
|
||||
if !isShowing {
|
||||
// Refresh tasks when sheet is dismissed
|
||||
loadResidenceWithTasks()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditResidence) { isShowing in
|
||||
if !isShowing {
|
||||
// Refresh residence data when edit sheet is dismissed
|
||||
loadResidenceData()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadResidenceData()
|
||||
}
|
||||
@@ -296,6 +318,69 @@ struct TaskCard: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
ForEach(task.completions, id: \.id) { completion in
|
||||
Spacer().frame(height: 12)
|
||||
|
||||
// Card equivalent
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Top row: date + rating badge
|
||||
HStack {
|
||||
Text(completion.completionDate.components(separatedBy: "T").first ?? "")
|
||||
.font(.body.weight(.bold))
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let rating = completion.rating {
|
||||
Text("\(rating)★")
|
||||
.font(.caption.weight(.bold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.tertiarySystemFill))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Completed by
|
||||
if let name = completion.completedByName {
|
||||
Text("By: \(name)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// Cost
|
||||
if let cost = completion.actualCost {
|
||||
Text("Cost: $\(cost)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.teal) // tertiary equivalent
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.secondary.opacity(0.15)) // surfaceVariant equivalent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if task.showCompletedButton {
|
||||
Button(action: {}) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill") // SF Symbol
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Complete Task")
|
||||
.font(.title3.weight(.semibold)) // ≈ Material titleSmall + SemiBold
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.buttonStyle(.borderedProminent) // gives filled look
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
@@ -118,6 +118,33 @@ class ResidenceViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
residenceApi.updateResidence(token: token, id: id, request: request) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<Residence> {
|
||||
self.selectedResidence = successResult.data
|
||||
self.isLoading = false
|
||||
completion(true)
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user