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:
Trey t
2025-11-10 19:39:41 -06:00
parent 764a90cb41
commit d3caffa792
25 changed files with 3506 additions and 29 deletions

View 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()
}
}
}
}
}
}