Add residence picker to contractor create/edit screens

Kotlin/KMM:
- Update Contractor model with optional residenceId and specialties array
- Rename averageRating to rating, update address field names
- Add ContractorMinimal model for task references
- Add residence picker and multi-select specialty chips to AddContractorDialog
- Fix ContractorsScreen and ContractorDetailScreen field references

iOS:
- Rewrite ContractorFormSheet with residence and specialty pickers
- Update ContractorDetailView with FlowLayout for specialties
- Add FlowLayout component for wrapping badge layouts
- Fix ContractorCard and CompleteTaskView field references
- Update ContractorFormState with residence/specialty selection

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-29 18:42:18 -06:00
parent c748f792d0
commit b0838d85df
22 changed files with 1472 additions and 1200 deletions

View File

@@ -3,14 +3,15 @@ import ComposeApp
// MARK: - Field Focus Enum
enum ContractorFormField: Hashable {
case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website
case address, city, state, zipCode, notes
case name, company, phone, email, website
case streetAddress, city, stateProvince, postalCode, notes
}
// MARK: - Contractor Form Sheet
struct ContractorFormSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ContractorViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
let contractor: Contractor?
let onSave: () -> Void
@@ -20,25 +21,27 @@ struct ContractorFormSheet: View {
@State private var company = ""
@State private var phone = ""
@State private var email = ""
@State private var secondaryPhone = ""
@State private var specialty = ""
@State private var licenseNumber = ""
@State private var website = ""
@State private var address = ""
@State private var streetAddress = ""
@State private var city = ""
@State private var state = ""
@State private var zipCode = ""
@State private var stateProvince = ""
@State private var postalCode = ""
@State private var notes = ""
@State private var isFavorite = false
// Residence selection (optional)
@State private var selectedResidenceId: Int32?
@State private var selectedResidenceName: String?
@State private var showingResidencePicker = false
// Specialty selection (multiple)
@State private var selectedSpecialtyIds: Set<Int32> = []
@State private var showingSpecialtyPicker = false
@FocusState private var focusedField: ContractorFormField?
// Lookups from DataCache
@State private var contractorSpecialties: [ContractorSpecialty] = []
private var specialties: [String] {
return DataCache.shared.contractorSpecialties.value.map { $0.name }
private var specialties: [ContractorSpecialty] {
return DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] ?? []
}
private var canSave: Bool {
@@ -74,6 +77,31 @@ struct ContractorFormSheet: View {
}
.listRowBackground(Color.appBackgroundSecondary)
// Residence (Optional)
Section {
Button(action: { showingResidencePicker = true }) {
HStack {
Image(systemName: "house")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
Text(selectedResidenceName ?? "Personal (No Residence)")
.foregroundColor(selectedResidenceName == nil ? Color.appTextSecondary.opacity(0.7) : Color.appTextPrimary)
Spacer()
Image(systemName: "chevron.down")
.font(.caption)
.foregroundColor(Color.appTextSecondary.opacity(0.7))
}
}
} header: {
Text("Residence (Optional)")
} footer: {
Text(selectedResidenceId == nil
? "Only you will see this contractor"
: "All users of \(selectedResidenceName ?? "") will see this contractor")
.font(.caption)
}
.listRowBackground(Color.appBackgroundSecondary)
// Contact Information
Section {
HStack {
@@ -96,45 +124,6 @@ struct ContractorFormSheet: View {
.focused($focusedField, equals: .email)
}
HStack {
Image(systemName: "phone.badge.plus")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
TextField("Secondary Phone", text: $secondaryPhone)
.keyboardType(.phonePad)
.focused($focusedField, equals: .secondaryPhone)
}
} header: {
Text("Contact Information")
} footer: {
}
.listRowBackground(Color.appBackgroundSecondary)
// Business Details
Section {
Button(action: { showingSpecialtyPicker = true }) {
HStack {
Image(systemName: "wrench.and.screwdriver")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
Text(specialty.isEmpty ? "Specialty" : specialty)
.foregroundColor(specialty.isEmpty ? Color.appTextSecondary.opacity(0.5) : Color.appTextPrimary)
Spacer()
Image(systemName: "chevron.down")
.font(.caption)
.foregroundColor(Color.appTextSecondary.opacity(0.7))
}
}
HStack {
Image(systemName: "doc.badge")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
TextField("License Number", text: $licenseNumber)
.focused($focusedField, equals: .licenseNumber)
}
HStack {
Image(systemName: "globe")
.foregroundColor(Color.appAccent)
@@ -146,7 +135,36 @@ struct ContractorFormSheet: View {
.focused($focusedField, equals: .website)
}
} header: {
Text("Business Details")
Text("Contact Information")
}
.listRowBackground(Color.appBackgroundSecondary)
// Specialties (Multi-select)
Section {
Button(action: { showingSpecialtyPicker = true }) {
HStack {
Image(systemName: "wrench.and.screwdriver")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
if selectedSpecialtyIds.isEmpty {
Text("Select Specialties")
.foregroundColor(Color.appTextSecondary.opacity(0.5))
} else {
let selectedNames = specialties
.filter { selectedSpecialtyIds.contains($0.id) }
.map { $0.name }
Text(selectedNames.joined(separator: ", "))
.foregroundColor(Color.appTextPrimary)
.lineLimit(2)
}
Spacer()
Image(systemName: "chevron.down")
.font(.caption)
.foregroundColor(Color.appTextSecondary.opacity(0.7))
}
}
} header: {
Text("Specialties")
}
.listRowBackground(Color.appBackgroundSecondary)
@@ -156,8 +174,8 @@ struct ContractorFormSheet: View {
Image(systemName: "location.fill")
.foregroundColor(Color.appError)
.frame(width: 24)
TextField("Street Address", text: $address)
.focused($focusedField, equals: .address)
TextField("Street Address", text: $streetAddress)
.focused($focusedField, equals: .streetAddress)
}
HStack {
@@ -173,16 +191,16 @@ struct ContractorFormSheet: View {
Image(systemName: "map")
.foregroundColor(Color.appAccent)
.frame(width: 24)
TextField("State", text: $state)
.focused($focusedField, equals: .state)
TextField("State", text: $stateProvince)
.focused($focusedField, equals: .stateProvince)
}
Divider()
.frame(height: 24)
TextField("ZIP", text: $zipCode)
TextField("ZIP", text: $postalCode)
.keyboardType(.numberPad)
.focused($focusedField, equals: .zipCode)
.focused($focusedField, equals: .postalCode)
.frame(maxWidth: 100)
}
} header: {
@@ -258,41 +276,14 @@ struct ContractorFormSheet: View {
.disabled(!canSave || viewModel.isCreating || viewModel.isUpdating)
}
}
.sheet(isPresented: $showingResidencePicker) {
residencePickerSheet
}
.sheet(isPresented: $showingSpecialtyPicker) {
NavigationStack {
List {
ForEach(specialties, id: \.self) { spec in
Button(action: {
specialty = spec
showingSpecialtyPicker = false
}) {
HStack {
Text(spec)
.foregroundColor(Color.appTextPrimary)
Spacer()
if specialty == spec {
Image(systemName: "checkmark")
.foregroundColor(Color.appPrimary)
}
}
}
}
}
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Select Specialty")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
showingSpecialtyPicker = false
}
}
}
}
.presentationDetents([.large])
specialtyPickerSheet
}
.onAppear {
residenceViewModel.loadMyResidences()
loadContractorData()
}
.handleErrors(
@@ -302,6 +293,121 @@ struct ContractorFormSheet: View {
}
}
// MARK: - Residence Picker Sheet
private var residencePickerSheet: some View {
NavigationStack {
List {
// Personal (no residence) option
Button(action: {
selectedResidenceId = nil
selectedResidenceName = nil
showingResidencePicker = false
}) {
HStack {
Text("Personal (No Residence)")
.foregroundColor(Color.appTextPrimary)
Spacer()
if selectedResidenceId == nil {
Image(systemName: "checkmark")
.foregroundColor(Color.appPrimary)
}
}
}
.listRowBackground(Color.appBackgroundSecondary)
// Residences
if let residences = residenceViewModel.myResidences?.residences {
ForEach(residences, id: \.id) { residence in
Button(action: {
selectedResidenceId = residence.id
selectedResidenceName = residence.name
showingResidencePicker = false
}) {
HStack {
Text(residence.name)
.foregroundColor(Color.appTextPrimary)
Spacer()
if selectedResidenceId == residence.id {
Image(systemName: "checkmark")
.foregroundColor(Color.appPrimary)
}
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
} else if residenceViewModel.isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.listRowBackground(Color.appBackgroundSecondary)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Select Residence")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
showingResidencePicker = false
}
}
}
}
.presentationDetents([.medium, .large])
}
// MARK: - Specialty Picker Sheet (Multi-select)
private var specialtyPickerSheet: some View {
NavigationStack {
List {
ForEach(specialties, id: \.id) { specialty in
Button(action: {
if selectedSpecialtyIds.contains(specialty.id) {
selectedSpecialtyIds.remove(specialty.id)
} else {
selectedSpecialtyIds.insert(specialty.id)
}
}) {
HStack {
Text(specialty.name)
.foregroundColor(Color.appTextPrimary)
Spacer()
if selectedSpecialtyIds.contains(specialty.id) {
Image(systemName: "checkmark")
.foregroundColor(Color.appPrimary)
}
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle("Select Specialties")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Clear") {
selectedSpecialtyIds.removeAll()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
showingSpecialtyPicker = false
}
}
}
}
.presentationDetents([.large])
}
// MARK: - Data Loading
private func loadContractorData() {
@@ -311,39 +417,50 @@ struct ContractorFormSheet: View {
company = contractor.company ?? ""
phone = contractor.phone ?? ""
email = contractor.email ?? ""
secondaryPhone = contractor.secondaryPhone ?? ""
specialty = contractor.specialty ?? ""
licenseNumber = contractor.licenseNumber ?? ""
website = contractor.website ?? ""
address = contractor.address ?? ""
streetAddress = contractor.streetAddress ?? ""
city = contractor.city ?? ""
state = contractor.state ?? ""
zipCode = contractor.zipCode ?? ""
stateProvince = contractor.stateProvince ?? ""
postalCode = contractor.postalCode ?? ""
notes = contractor.notes ?? ""
isFavorite = contractor.isFavorite
// Set residence if contractor has one
if let residenceId = contractor.residenceId {
selectedResidenceId = residenceId.int32Value
// Try to find residence name from loaded residences
if let residences = residenceViewModel.myResidences?.residences,
let residence = residences.first(where: { $0.id == residenceId.int32Value }) {
selectedResidenceName = residence.name
}
}
// Set specialties
selectedSpecialtyIds = Set(contractor.specialties.map { $0.id })
}
// MARK: - Save Action
private func saveContractor() {
let specialtyIdsArray = Array(selectedSpecialtyIds).map { KotlinInt(int: $0) }
if let contractor = contractor {
// Update existing contractor
let request = ContractorUpdateRequest(
name: name.isEmpty ? nil : name,
residenceId: selectedResidenceId.map { KotlinInt(int: $0) },
company: company.isEmpty ? nil : company,
phone: phone.isEmpty ? nil : phone,
email: email.isEmpty ? nil : email,
secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone,
specialty: specialty.isEmpty ? nil : specialty,
licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber,
website: website.isEmpty ? nil : website,
address: address.isEmpty ? nil : address,
streetAddress: streetAddress.isEmpty ? nil : streetAddress,
city: city.isEmpty ? nil : city,
state: state.isEmpty ? nil : state,
zipCode: zipCode.isEmpty ? nil : zipCode,
stateProvince: stateProvince.isEmpty ? nil : stateProvince,
postalCode: postalCode.isEmpty ? nil : postalCode,
rating: nil,
isFavorite: isFavorite.asKotlin,
isActive: nil,
notes: notes.isEmpty ? nil : notes
notes: notes.isEmpty ? nil : notes,
specialtyIds: specialtyIdsArray.isEmpty ? nil : specialtyIdsArray
)
viewModel.updateContractor(id: contractor.id, request: request) { success in
@@ -356,20 +473,19 @@ struct ContractorFormSheet: View {
// Create new contractor
let request = ContractorCreateRequest(
name: name,
residenceId: selectedResidenceId.map { KotlinInt(int: $0) },
company: company.isEmpty ? nil : company,
phone: phone.isEmpty ? nil : phone,
email: email.isEmpty ? nil : email,
secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone,
specialty: specialty.isEmpty ? nil : specialty,
licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber,
website: website.isEmpty ? nil : website,
address: address.isEmpty ? nil : address,
streetAddress: streetAddress.isEmpty ? nil : streetAddress,
city: city.isEmpty ? nil : city,
state: state.isEmpty ? nil : state,
zipCode: zipCode.isEmpty ? nil : zipCode,
stateProvince: stateProvince.isEmpty ? nil : stateProvince,
postalCode: postalCode.isEmpty ? nil : postalCode,
rating: nil,
isFavorite: isFavorite,
isActive: true,
notes: notes.isEmpty ? nil : notes
notes: notes.isEmpty ? nil : notes,
specialtyIds: specialtyIdsArray.isEmpty ? nil : specialtyIdsArray
)
viewModel.createContractor(request: request) { success in