Add contractor management and integrate with task completions
Features: - Add full contractor CRUD functionality (Android & iOS) - Add contractor selection to task completion dialog - Display contractor info in completion cards - Add ContractorSpecialty model and API integration - Add contractors tab to bottom navigation - Replace hardcoded specialty lists with API data - Update lookup endpoints to return arrays instead of paginated responses Changes: - Add Contractor models (ContractorSummary, ContractorDetail, ContractorCreate/UpdateRequest) - Add ContractorApi with endpoints for list, detail, create, update, delete, toggle favorite - Add ContractorViewModel for state management - Add ContractorsScreen and ContractorDetailScreen for Android - Add AddContractorDialog with form validation - Add Contractor views for iOS (list, detail, form) - Update CompleteTaskDialog to include contractor selection - Update CompletionCardView to show contractor name and phone - Add contractor field to TaskCompletion model - Update LookupsApi to return List<T> instead of paginated responses - Update LookupsRepository and LookupsViewModel to handle array responses - Update LookupsManager (iOS) to handle array responses for contractor specialties 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
435
iosApp/iosApp/Contractor/ContractorFormSheet.swift
Normal file
435
iosApp/iosApp/Contractor/ContractorFormSheet.swift
Normal file
@@ -0,0 +1,435 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ContractorFormSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@ObservedObject private var lookupsManager = LookupsManager.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 secondaryPhone = ""
|
||||
@State private var specialty = ""
|
||||
@State private var licenseNumber = ""
|
||||
@State private var website = ""
|
||||
@State private var address = ""
|
||||
@State private var city = ""
|
||||
@State private var state = ""
|
||||
@State private var zipCode = ""
|
||||
@State private var notes = ""
|
||||
@State private var isFavorite = false
|
||||
|
||||
@State private var showingSpecialtyPicker = false
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
var specialties: [String] {
|
||||
lookupsManager.contractorSpecialties.map { $0.name }
|
||||
}
|
||||
|
||||
enum Field: Hashable {
|
||||
case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website
|
||||
case address, city, state, zipCode, notes
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
AppColors.background.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// Basic Information
|
||||
SectionHeader(title: "Basic Information")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "Name *",
|
||||
text: $name,
|
||||
icon: "person",
|
||||
focused: $focusedField,
|
||||
field: .name
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Company",
|
||||
text: $company,
|
||||
icon: "building.2",
|
||||
focused: $focusedField,
|
||||
field: .company
|
||||
)
|
||||
}
|
||||
|
||||
// Contact Information
|
||||
SectionHeader(title: "Contact Information")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "Phone *",
|
||||
text: $phone,
|
||||
icon: "phone",
|
||||
keyboardType: .phonePad,
|
||||
focused: $focusedField,
|
||||
field: .phone
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Email",
|
||||
text: $email,
|
||||
icon: "envelope",
|
||||
keyboardType: .emailAddress,
|
||||
focused: $focusedField,
|
||||
field: .email
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Secondary Phone",
|
||||
text: $secondaryPhone,
|
||||
icon: "phone",
|
||||
keyboardType: .phonePad,
|
||||
focused: $focusedField,
|
||||
field: .secondaryPhone
|
||||
)
|
||||
}
|
||||
|
||||
// Business Details
|
||||
SectionHeader(title: "Business Details")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
// Specialty Picker
|
||||
Button(action: { showingSpecialtyPicker = true }) {
|
||||
HStack {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(specialty.isEmpty ? "Specialty" : specialty)
|
||||
.foregroundColor(specialty.isEmpty ? AppColors.textTertiary : AppColors.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surfaceSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
title: "License Number",
|
||||
text: $licenseNumber,
|
||||
icon: "doc.badge",
|
||||
focused: $focusedField,
|
||||
field: .licenseNumber
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "Website",
|
||||
text: $website,
|
||||
icon: "globe",
|
||||
keyboardType: .URL,
|
||||
focused: $focusedField,
|
||||
field: .website
|
||||
)
|
||||
}
|
||||
|
||||
// Address
|
||||
SectionHeader(title: "Address")
|
||||
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "Street Address",
|
||||
text: $address,
|
||||
icon: "mappin",
|
||||
focused: $focusedField,
|
||||
field: .address
|
||||
)
|
||||
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
FormTextField(
|
||||
title: "City",
|
||||
text: $city,
|
||||
focused: $focusedField,
|
||||
field: .city
|
||||
)
|
||||
|
||||
FormTextField(
|
||||
title: "State",
|
||||
text: $state,
|
||||
focused: $focusedField,
|
||||
field: .state
|
||||
)
|
||||
.frame(maxWidth: 100)
|
||||
}
|
||||
|
||||
FormTextField(
|
||||
title: "ZIP Code",
|
||||
text: $zipCode,
|
||||
keyboardType: .numberPad,
|
||||
focused: $focusedField,
|
||||
field: .zipCode
|
||||
)
|
||||
}
|
||||
|
||||
// Notes
|
||||
SectionHeader(title: "Notes")
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
HStack {
|
||||
Image(systemName: "note.text")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
Text("Private Notes")
|
||||
.font(AppTypography.labelMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
TextEditor(text: $notes)
|
||||
.frame(height: 100)
|
||||
.padding(AppSpacing.sm)
|
||||
.background(AppColors.surfaceSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.focused($focusedField, equals: .notes)
|
||||
}
|
||||
|
||||
// Favorite Toggle
|
||||
Toggle(isOn: $isFavorite) {
|
||||
HStack {
|
||||
Image(systemName: "star.fill")
|
||||
.foregroundColor(isFavorite ? AppColors.warning : AppColors.textSecondary)
|
||||
Text("Mark as Favorite")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
|
||||
// Error Message
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.font(AppTypography.bodySmall)
|
||||
.foregroundColor(AppColors.error)
|
||||
.padding(AppSpacing.sm)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppColors.error.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
.navigationTitle(contractor == nil ? "Add Contractor" : "Edit Contractor")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: saveContractor) {
|
||||
if viewModel.isCreating || viewModel.isUpdating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(contractor == nil ? "Add" : "Save")
|
||||
.foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary)
|
||||
}
|
||||
}
|
||||
.disabled(!canSave || viewModel.isCreating || viewModel.isUpdating)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSpecialtyPicker) {
|
||||
SpecialtyPickerView(
|
||||
selectedSpecialty: $specialty,
|
||||
specialties: specialties
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
loadContractorData()
|
||||
lookupsManager.loadContractorSpecialties()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.isEmpty && !phone.isEmpty
|
||||
}
|
||||
|
||||
private func loadContractorData() {
|
||||
guard let contractor = contractor else { return }
|
||||
|
||||
name = contractor.name
|
||||
company = contractor.company ?? ""
|
||||
phone = contractor.phone
|
||||
email = contractor.email ?? ""
|
||||
secondaryPhone = contractor.secondaryPhone ?? ""
|
||||
specialty = contractor.specialty ?? ""
|
||||
licenseNumber = contractor.licenseNumber ?? ""
|
||||
website = contractor.website ?? ""
|
||||
address = contractor.address ?? ""
|
||||
city = contractor.city ?? ""
|
||||
state = contractor.state ?? ""
|
||||
zipCode = contractor.zipCode ?? ""
|
||||
notes = contractor.notes ?? ""
|
||||
isFavorite = contractor.isFavorite
|
||||
}
|
||||
|
||||
private func saveContractor() {
|
||||
if let contractor = contractor {
|
||||
// Update existing contractor
|
||||
let request = ContractorUpdateRequest(
|
||||
name: name.isEmpty ? nil : name,
|
||||
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,
|
||||
city: city.isEmpty ? nil : city,
|
||||
state: state.isEmpty ? nil : state,
|
||||
zipCode: zipCode.isEmpty ? nil : zipCode,
|
||||
isFavorite: isFavorite.toKotlinBoolean(),
|
||||
isActive: nil,
|
||||
notes: notes.isEmpty ? nil : notes
|
||||
)
|
||||
|
||||
viewModel.updateContractor(id: contractor.id, request: request) { success in
|
||||
if success {
|
||||
onSave()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new contractor
|
||||
let request = ContractorCreateRequest(
|
||||
name: name,
|
||||
company: company.isEmpty ? nil : company,
|
||||
phone: 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,
|
||||
city: city.isEmpty ? nil : city,
|
||||
state: state.isEmpty ? nil : state,
|
||||
zipCode: zipCode.isEmpty ? nil : zipCode,
|
||||
isFavorite: isFavorite,
|
||||
isActive: true,
|
||||
notes: notes.isEmpty ? nil : notes
|
||||
)
|
||||
|
||||
viewModel.createContractor(request: request) { success in
|
||||
if success {
|
||||
onSave()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section Header
|
||||
struct SectionHeader: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(AppTypography.titleSmall)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Form Text Field
|
||||
struct FormTextField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
var icon: String? = nil
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
var focused: FocusState<ContractorFormSheet.Field?>.Binding
|
||||
var field: ContractorFormSheet.Field
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
if let icon = icon {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(title)
|
||||
.font(AppTypography.labelMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
.font(AppTypography.labelMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
TextField("", text: $text)
|
||||
.keyboardType(keyboardType)
|
||||
.autocapitalization(keyboardType == .emailAddress ? .none : .words)
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surfaceSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.focused(focused, equals: field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Specialty Picker
|
||||
struct SpecialtyPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var selectedSpecialty: String
|
||||
let specialties: [String]
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(specialties, id: \.self) { specialty in
|
||||
Button(action: {
|
||||
selectedSpecialty = specialty
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
Text(specialty)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
Spacer()
|
||||
if selectedSpecialty == specialty {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Specialty")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user