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
538 lines
22 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|