Files
honeyDueKMP/iosApp/iosApp/Contractor/ContractorFormSheet.swift
Trey T af73f8861b iOS VoiceOver accessibility overhaul — 67 files
New framework:
- AccessibilityLabels.swift: centralized A11y struct with VoiceOver strings
- AccessibilityModifiers.swift: reusable .a11yHeader, .a11yDecorative,
  .a11yButton, .a11yCard, .a11yStatValue View extensions

Shared components: decorative elements hidden, stat views combined,
status/priority badges labeled, error views announced, empty states grouped

Cards: ResidenceCard, TaskCard, DynamicTaskCard, ContractorCard,
DocumentCard, WarrantyCard — all grouped with combined labels,
chevrons hidden, action buttons labeled

Main screens: Login, Register, Residences, Tasks, Contractors, Documents —
toolbar buttons labeled, section headers marked, form field hints added

Onboarding: all 10 views — header traits, button hints, task selection
state, progress indicator, decorative backgrounds hidden

Profile/Subscription: toggle hints, theme selection state, feature
comparison table accessibility, subscription button labels

iOS build verified: BUILD SUCCEEDED
2026-03-26 14:51:29 -05:00

538 lines
22 KiB
Swift

import SwiftUI
import ComposeApp
// MARK: - Field Focus Enum
enum ContractorFormField: Hashable {
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()
@ObservedObject private var dataManager = DataManagerObservable.shared
let contractor: Contractor?
let onSave: () -> Void
// Form fields
@State private var name = ""
@State private var company = ""
@State private var phone = ""
@State private var email = ""
@State private var website = ""
@State private var streetAddress = ""
@State private var city = ""
@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?
private var specialties: [ContractorSpecialty] {
return dataManager.contractorSpecialties
}
private var canSave: Bool {
!name.isEmpty
}
var body: some View {
NavigationStack {
Form {
// Basic Information
Section {
HStack {
Image(systemName: "person")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
TextField(L10n.Contractors.nameLabel, text: $name)
.focused($focusedField, equals: .name)
.textContentType(.name)
.submitLabel(.next)
.onSubmit { focusedField = .company }
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.nameField)
}
HStack {
Image(systemName: "building.2")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
TextField(L10n.Contractors.companyLabel, text: $company)
.focused($focusedField, equals: .company)
.textContentType(.organizationName)
.submitLabel(.next)
.onSubmit { focusedField = .phone }
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.companyField)
}
} header: {
Text(L10n.Contractors.basicInfoSection)
.accessibilityAddTraits(.isHeader)
} footer: {
Text(L10n.Contractors.basicInfoFooter)
.font(.caption)
.foregroundColor(Color.appError)
}
.sectionBackground()
// Residence (Optional)
Section {
Button(action: { showingResidencePicker = true }) {
HStack {
Image("outline")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundColor(Color.appPrimary)
Text(selectedResidenceName ?? L10n.Contractors.personalNoResidence)
.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(L10n.Contractors.residenceSection)
} footer: {
Text(selectedResidenceId == nil
? L10n.Contractors.residenceFooterPersonal
: String(format: L10n.Contractors.residenceFooterShared, selectedResidenceName ?? ""))
.font(.caption)
}
.sectionBackground()
// Contact Information
Section {
HStack {
Image(systemName: "phone.fill")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
TextField(L10n.Contractors.phoneLabel, text: $phone)
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.focused($focusedField, equals: .phone)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.phoneField)
}
HStack {
Image(systemName: "envelope.fill")
.foregroundColor(Color.appAccent)
.frame(width: 24)
TextField(L10n.Contractors.emailLabel, text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .email)
.submitLabel(.next)
.onSubmit { focusedField = .website }
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.emailField)
}
HStack {
Image(systemName: "globe")
.foregroundColor(Color.appAccent)
.frame(width: 24)
TextField(L10n.Contractors.websiteLabel, text: $website)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .website)
}
} header: {
Text(L10n.Contractors.contactInfoSection)
.accessibilityAddTraits(.isHeader)
}
.sectionBackground()
// Specialties (Multi-select)
Section {
Button(action: { showingSpecialtyPicker = true }) {
HStack {
Image(systemName: "wrench.and.screwdriver")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
if selectedSpecialtyIds.isEmpty {
Text(L10n.Contractors.selectSpecialtiesPlaceholder)
.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))
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.specialtyPicker)
} header: {
Text(L10n.Contractors.specialtiesSection)
}
.sectionBackground()
// Address
Section {
HStack {
Image(systemName: "location.fill")
.foregroundColor(Color.appError)
.frame(width: 24)
TextField(L10n.Contractors.streetAddressLabel, text: $streetAddress)
.focused($focusedField, equals: .streetAddress)
}
HStack {
Image(systemName: "building.2.crop.circle")
.foregroundColor(Color.appPrimary)
.frame(width: 24)
TextField(L10n.Contractors.cityLabel, text: $city)
.focused($focusedField, equals: .city)
}
HStack(spacing: AppSpacing.sm) {
HStack {
Image(systemName: "map")
.foregroundColor(Color.appAccent)
.frame(width: 24)
TextField(L10n.Contractors.stateLabel, text: $stateProvince)
.focused($focusedField, equals: .stateProvince)
}
Divider()
.frame(height: 24)
TextField(L10n.Contractors.zipLabel, text: $postalCode)
.keyboardType(.numberPad)
.focused($focusedField, equals: .postalCode)
.frame(maxWidth: 100)
.keyboardDismissToolbar()
}
} header: {
Text(L10n.Contractors.addressSection)
.accessibilityAddTraits(.isHeader)
}
.sectionBackground()
// Notes
Section {
HStack(alignment: .top) {
Image(systemName: "note.text")
.foregroundColor(Color.appAccent)
.frame(width: 24)
.padding(.top, 8)
TextEditor(text: $notes)
.frame(height: 100)
.focused($focusedField, equals: .notes)
.keyboardDismissToolbar()
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.notesField)
}
} header: {
Text(L10n.Contractors.notesSection)
} footer: {
Text(L10n.Contractors.notesFooter)
.font(.caption)
}
.sectionBackground()
// Favorite
Section {
Toggle(isOn: $isFavorite) {
Label(L10n.Contractors.favoriteLabel, systemImage: "star.fill")
.foregroundColor(isFavorite ? Color.appAccent : Color.appTextPrimary)
}
.tint(Color.appAccent)
}
.sectionBackground()
// Error Message
if let error = viewModel.errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.callout)
.foregroundColor(Color.appError)
}
}
.sectionBackground()
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(contractor == nil ? L10n.Contractors.addTitle : L10n.Contractors.editTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.cancel) {
dismiss()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.formCancelButton)
}
ToolbarItem(placement: .confirmationAction) {
Button(action: saveContractor) {
if viewModel.isCreating || viewModel.isUpdating {
ProgressView()
} else {
Text(contractor == nil ? L10n.Common.add : L10n.Common.save)
.bold()
}
}
.disabled(!canSave || viewModel.isCreating || viewModel.isUpdating)
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.saveButton)
}
}
.sheet(isPresented: $showingResidencePicker) {
residencePickerSheet
}
.sheet(isPresented: $showingSpecialtyPicker) {
specialtyPickerSheet
}
.onAppear {
// Track screen view for new contractors
if contractor == nil {
AnalyticsManager.shared.trackScreen(.newContractor)
}
residenceViewModel.loadMyResidences()
loadContractorData()
}
.onChange(of: residenceViewModel.selectedResidence?.id) { _, _ in
if let residence = residenceViewModel.selectedResidence,
residence.id == selectedResidenceId {
selectedResidenceName = residence.name
}
}
.handleErrors(
error: viewModel.errorMessage,
onRetry: { saveContractor() }
)
}
}
// 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(L10n.Contractors.personalNoResidence)
.foregroundColor(Color.appTextPrimary)
Spacer()
if selectedResidenceId == nil {
Image(systemName: "checkmark")
.foregroundColor(Color.appPrimary)
}
}
}
.sectionBackground()
// 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)
}
}
}
.sectionBackground()
}
} else if residenceViewModel.isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
.sectionBackground()
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Contractors.selectResidence)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.Common.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)
}
}
}
.sectionBackground()
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Contractors.selectSpecialties)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Contractors.clearAction) {
selectedSpecialtyIds.removeAll()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.Common.done) {
showingSpecialtyPicker = false
}
}
}
}
.presentationDetents([.large])
}
// MARK: - Data Loading
private func loadContractorData() {
guard let contractor = contractor else { return }
name = contractor.name
company = contractor.company ?? ""
phone = contractor.phone ?? ""
email = contractor.email ?? ""
website = contractor.website ?? ""
streetAddress = contractor.streetAddress ?? ""
city = contractor.city ?? ""
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
if let selectedResidenceId {
residenceViewModel.getResidence(id: selectedResidenceId)
}
}
// 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,
website: website.isEmpty ? nil : website,
streetAddress: streetAddress.isEmpty ? nil : streetAddress,
city: city.isEmpty ? nil : city,
stateProvince: stateProvince.isEmpty ? nil : stateProvince,
postalCode: postalCode.isEmpty ? nil : postalCode,
rating: nil,
isFavorite: isFavorite.asKotlin,
notes: notes.isEmpty ? nil : notes,
specialtyIds: specialtyIdsArray.isEmpty ? nil : specialtyIdsArray
)
viewModel.updateContractor(id: contractor.id, request: request) { success in
if success {
onSave()
dismiss()
}
}
} else {
// 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,
website: website.isEmpty ? nil : website,
streetAddress: streetAddress.isEmpty ? nil : streetAddress,
city: city.isEmpty ? nil : city,
stateProvince: stateProvince.isEmpty ? nil : stateProvince,
postalCode: postalCode.isEmpty ? nil : postalCode,
rating: nil,
isFavorite: isFavorite,
notes: notes.isEmpty ? nil : notes,
specialtyIds: specialtyIdsArray.isEmpty ? nil : specialtyIdsArray
)
viewModel.createContractor(request: request) { success in
if success {
// Track contractor creation
AnalyticsManager.shared.track(.contractorCreated)
onSave()
dismiss()
}
}
}
}
}