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:
90
iosApp/iosApp/Contractor/ContractorCard.swift
Normal file
90
iosApp/iosApp/Contractor/ContractorCard.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ContractorCard: View {
|
||||
let contractor: ContractorSummary
|
||||
let onToggleFavorite: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.md) {
|
||||
// Avatar
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppColors.primary.opacity(0.1))
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Image(systemName: "person.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(AppColors.primary)
|
||||
}
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
// Name with favorite star
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Text(contractor.name)
|
||||
.font(AppTypography.titleMedium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
if contractor.isFavorite {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppColors.warning)
|
||||
}
|
||||
}
|
||||
|
||||
// Company
|
||||
if let company = contractor.company {
|
||||
Text(company)
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
// Info row
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
// Specialty
|
||||
if let specialty = contractor.specialty {
|
||||
Label(specialty, systemImage: "wrench.and.screwdriver")
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
// Rating
|
||||
if let rating = contractor.averageRating, rating.doubleValue > 0 {
|
||||
Label(String(format: "%.1f", rating.doubleValue), systemImage: "star.fill")
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.warning)
|
||||
}
|
||||
|
||||
// Task count
|
||||
if contractor.taskCount > 0 {
|
||||
Label("\(contractor.taskCount) tasks", systemImage: "checkmark.circle")
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Favorite button
|
||||
Button(action: onToggleFavorite) {
|
||||
Image(systemName: contractor.isFavorite ? "star.fill" : "star")
|
||||
.font(.title3)
|
||||
.foregroundColor(contractor.isFavorite ? AppColors.warning : AppColors.textTertiary)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// Chevron
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
|
||||
}
|
||||
}
|
||||
279
iosApp/iosApp/Contractor/ContractorDetailView.swift
Normal file
279
iosApp/iosApp/Contractor/ContractorDetailView.swift
Normal file
@@ -0,0 +1,279 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ContractorDetailView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
|
||||
let contractorId: Int32
|
||||
|
||||
@State private var showingEditSheet = false
|
||||
@State private var showingDeleteAlert = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.background.ignoresSafeArea()
|
||||
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
} else if let error = viewModel.errorMessage {
|
||||
ErrorView(message: error) {
|
||||
viewModel.loadContractorDetail(id: contractorId)
|
||||
}
|
||||
} else if let contractor = viewModel.selectedContractor {
|
||||
ScrollView {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// Header Card
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
// Avatar
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppColors.primary.opacity(0.1))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(AppColors.primary)
|
||||
}
|
||||
|
||||
// Name
|
||||
Text(contractor.name)
|
||||
.font(AppTypography.headlineSmall)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
|
||||
// Company
|
||||
if let company = contractor.company {
|
||||
Text(company)
|
||||
.font(AppTypography.titleMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
|
||||
// Specialty Badge
|
||||
if let specialty = contractor.specialty {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.caption)
|
||||
Text(specialty)
|
||||
.font(AppTypography.bodyMedium)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
.padding(.vertical, AppSpacing.xxs)
|
||||
.background(AppColors.primary.opacity(0.1))
|
||||
.foregroundColor(AppColors.primary)
|
||||
.cornerRadius(AppRadius.full)
|
||||
}
|
||||
|
||||
// Rating
|
||||
if let rating = contractor.averageRating, rating.doubleValue > 0 {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
ForEach(0..<5) { index in
|
||||
Image(systemName: index < Int(rating.doubleValue) ? "star.fill" : "star")
|
||||
.foregroundColor(AppColors.warning)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(String(format: "%.1f", rating.doubleValue))
|
||||
.font(AppTypography.titleMedium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
if contractor.taskCount > 0 {
|
||||
Text("\(contractor.taskCount) completed tasks")
|
||||
.font(AppTypography.bodySmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.lg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
|
||||
|
||||
// Contact Information
|
||||
DetailSection(title: "Contact Information") {
|
||||
DetailRow(icon: "phone", label: "Phone", value: contractor.phone, iconColor: AppColors.primary)
|
||||
|
||||
if let email = contractor.email {
|
||||
DetailRow(icon: "envelope", label: "Email", value: email, iconColor: AppColors.accent)
|
||||
}
|
||||
|
||||
if let secondaryPhone = contractor.secondaryPhone {
|
||||
DetailRow(icon: "phone", label: "Secondary Phone", value: secondaryPhone, iconColor: AppColors.success)
|
||||
}
|
||||
|
||||
if let website = contractor.website {
|
||||
DetailRow(icon: "globe", label: "Website", value: website, iconColor: AppColors.warning)
|
||||
}
|
||||
}
|
||||
|
||||
// Business Details
|
||||
if contractor.licenseNumber != nil {
|
||||
DetailSection(title: "Business Details") {
|
||||
if let licenseNumber = contractor.licenseNumber {
|
||||
DetailRow(icon: "doc.badge", label: "License Number", value: licenseNumber, iconColor: AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
if contractor.address != nil || contractor.city != nil {
|
||||
DetailSection(title: "Address") {
|
||||
let addressComponents = [
|
||||
contractor.address,
|
||||
[contractor.city, contractor.state].compactMap { $0 }.joined(separator: ", "),
|
||||
contractor.zipCode
|
||||
].compactMap { $0 }.filter { !$0.isEmpty }
|
||||
|
||||
if !addressComponents.isEmpty {
|
||||
DetailRow(
|
||||
icon: "mappin.circle",
|
||||
label: "Location",
|
||||
value: addressComponents.joined(separator: "\n"),
|
||||
iconColor: AppColors.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if let notes = contractor.notes, !notes.isEmpty {
|
||||
DetailSection(title: "Notes") {
|
||||
Text(notes)
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
// Task History
|
||||
DetailSection(title: "Task History") {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.foregroundColor(AppColors.success)
|
||||
Spacer()
|
||||
Text("\(contractor.taskCount) completed tasks")
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if let contractor = viewModel.selectedContractor {
|
||||
Menu {
|
||||
Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in
|
||||
viewModel.loadContractorDetail(id: contractorId)
|
||||
}}) {
|
||||
Label(
|
||||
contractor.isFavorite ? "Remove from Favorites" : "Add to Favorites",
|
||||
systemImage: contractor.isFavorite ? "star.slash" : "star"
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { showingEditSheet = true }) {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive, action: { showingDeleteAlert = true }) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.foregroundColor(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingEditSheet) {
|
||||
ContractorFormSheet(
|
||||
contractor: viewModel.selectedContractor,
|
||||
onSave: {
|
||||
viewModel.loadContractorDetail(id: contractorId)
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert("Delete Contractor", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteContractor()
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this contractor? This action cannot be undone.")
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadContractorDetail(id: contractorId)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContractor() {
|
||||
viewModel.deleteContractor(id: contractorId) { success in
|
||||
if success {
|
||||
Task { @MainActor in
|
||||
// Small delay to allow state to settle before dismissing
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail Section
|
||||
struct DetailSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
||||
Text(title)
|
||||
.font(AppTypography.titleSmall)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail Row
|
||||
struct DetailRow: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
var iconColor: Color = AppColors.textSecondary
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: AppSpacing.sm) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(iconColor)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text(label)
|
||||
.font(AppTypography.labelSmall)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
|
||||
Text(value)
|
||||
.font(AppTypography.bodyMedium)
|
||||
.foregroundColor(AppColors.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
iosApp/iosApp/Contractor/ContractorViewModel.swift
Normal file
199
iosApp/iosApp/Contractor/ContractorViewModel.swift
Normal file
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
class ContractorViewModel: ObservableObject {
|
||||
// MARK: - Published Properties
|
||||
@Published var contractors: [ContractorSummary] = []
|
||||
@Published var selectedContractor: Contractor?
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var isCreating: Bool = false
|
||||
@Published var isUpdating: Bool = false
|
||||
@Published var isDeleting: Bool = false
|
||||
@Published var successMessage: String?
|
||||
|
||||
// MARK: - Private Properties
|
||||
private let contractorApi: ContractorApi
|
||||
private let tokenStorage: TokenStorage
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
self.contractorApi = ContractorApi(client: ApiClient_iosKt.createHttpClient())
|
||||
self.tokenStorage = TokenStorage.shared
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func loadContractors(
|
||||
specialty: String? = nil,
|
||||
isFavorite: Bool? = nil,
|
||||
isActive: Bool? = nil,
|
||||
search: String? = nil
|
||||
) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
contractorApi.getContractors(
|
||||
token: token,
|
||||
specialty: specialty,
|
||||
isFavorite: isFavorite?.toKotlinBoolean(),
|
||||
isActive: isActive?.toKotlinBoolean(),
|
||||
search: search
|
||||
) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<ContractorListResponse> {
|
||||
self.contractors = successResult.data?.results ?? []
|
||||
self.isLoading = false
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isLoading = false
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadContractorDetail(id: Int32) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
contractorApi.getContractor(token: token, id: id) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<Contractor> {
|
||||
self.selectedContractor = successResult.data
|
||||
self.isLoading = false
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isLoading = false
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
isCreating = true
|
||||
errorMessage = nil
|
||||
|
||||
contractorApi.createContractor(token: token, request: request) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<Contractor> {
|
||||
self.successMessage = "Contractor added successfully"
|
||||
self.isCreating = false
|
||||
completion(true)
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isCreating = false
|
||||
completion(false)
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isCreating = false
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
isUpdating = true
|
||||
errorMessage = nil
|
||||
|
||||
contractorApi.updateContractor(token: token, id: id, request: request) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<Contractor> {
|
||||
self.successMessage = "Contractor updated successfully"
|
||||
self.isUpdating = false
|
||||
completion(true)
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isUpdating = false
|
||||
completion(false)
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isUpdating = false
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
isDeleting = true
|
||||
errorMessage = nil
|
||||
|
||||
contractorApi.deleteContractor(token: token, id: id) { result, error in
|
||||
Task { @MainActor in
|
||||
if result is ApiResultSuccess<KotlinUnit> {
|
||||
self.successMessage = "Contractor deleted successfully"
|
||||
self.isDeleting = false
|
||||
completion(true)
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
self.isDeleting = false
|
||||
completion(false)
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isDeleting = false
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
contractorApi.toggleFavorite(token: token, id: id) { result, error in
|
||||
if result is ApiResultSuccess<Contractor> {
|
||||
completion(true)
|
||||
} else if let errorResult = result as? ApiResultError {
|
||||
self.errorMessage = errorResult.message
|
||||
completion(false)
|
||||
} else if let error = error {
|
||||
self.errorMessage = error.localizedDescription
|
||||
completion(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearMessages() {
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extension
|
||||
extension Bool {
|
||||
func toKotlinBoolean() -> KotlinBoolean {
|
||||
return KotlinBoolean(bool: self)
|
||||
}
|
||||
}
|
||||
262
iosApp/iosApp/Contractor/ContractorsListView.swift
Normal file
262
iosApp/iosApp/Contractor/ContractorsListView.swift
Normal file
@@ -0,0 +1,262 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct ContractorsListView: View {
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@ObservedObject private var lookupsManager = LookupsManager.shared
|
||||
@State private var searchText = ""
|
||||
@State private var showingAddSheet = false
|
||||
@State private var selectedSpecialty: String? = nil
|
||||
@State private var showFavoritesOnly = false
|
||||
@State private var showSpecialtyFilter = false
|
||||
|
||||
var specialties: [String] {
|
||||
lookupsManager.contractorSpecialties.map { $0.name }
|
||||
}
|
||||
|
||||
var filteredContractors: [ContractorSummary] {
|
||||
contractors
|
||||
}
|
||||
|
||||
var contractors: [ContractorSummary] {
|
||||
viewModel.contractors
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Search Bar
|
||||
SearchBar(text: $searchText, placeholder: "Search contractors...")
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
|
||||
// Active Filters
|
||||
if showFavoritesOnly || selectedSpecialty != nil {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
if showFavoritesOnly {
|
||||
FilterChip(
|
||||
title: "Favorites",
|
||||
icon: "star.fill",
|
||||
onRemove: { showFavoritesOnly = false }
|
||||
)
|
||||
}
|
||||
|
||||
if let specialty = selectedSpecialty {
|
||||
FilterChip(
|
||||
title: specialty,
|
||||
onRemove: { selectedSpecialty = nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
}
|
||||
.padding(.vertical, AppSpacing.xs)
|
||||
}
|
||||
|
||||
// Content
|
||||
if viewModel.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
Spacer()
|
||||
} else if let error = viewModel.errorMessage {
|
||||
Spacer()
|
||||
ErrorView(
|
||||
message: error,
|
||||
retryAction: { loadContractors() }
|
||||
)
|
||||
Spacer()
|
||||
} else if contractors.isEmpty {
|
||||
Spacer()
|
||||
EmptyContractorsView(
|
||||
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
|
||||
)
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppSpacing.sm) {
|
||||
ForEach(filteredContractors, id: \.id) { contractor in
|
||||
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
|
||||
ContractorCard(
|
||||
contractor: contractor,
|
||||
onToggleFavorite: {
|
||||
toggleFavorite(contractor.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Contractors")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
// Favorites Filter
|
||||
Button(action: {
|
||||
showFavoritesOnly.toggle()
|
||||
loadContractors()
|
||||
}) {
|
||||
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
|
||||
.foregroundColor(showFavoritesOnly ? AppColors.warning : AppColors.textSecondary)
|
||||
}
|
||||
|
||||
// Specialty Filter
|
||||
Menu {
|
||||
Button(action: {
|
||||
selectedSpecialty = nil
|
||||
loadContractors()
|
||||
}) {
|
||||
Label("All Specialties", systemImage: selectedSpecialty == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(specialties, id: \.self) { specialty in
|
||||
Button(action: {
|
||||
selectedSpecialty = specialty
|
||||
loadContractors()
|
||||
}) {
|
||||
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.foregroundColor(selectedSpecialty != nil ? AppColors.primary : AppColors.textSecondary)
|
||||
}
|
||||
|
||||
// Add Button
|
||||
Button(action: { showingAddSheet = true }) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ContractorFormSheet(
|
||||
contractor: nil,
|
||||
onSave: {
|
||||
loadContractors()
|
||||
}
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
loadContractors()
|
||||
lookupsManager.loadContractorSpecialties()
|
||||
}
|
||||
.onChange(of: searchText) { newValue in
|
||||
loadContractors()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadContractors() {
|
||||
viewModel.loadContractors(
|
||||
specialty: selectedSpecialty,
|
||||
isFavorite: showFavoritesOnly ? true : nil,
|
||||
search: searchText.isEmpty ? nil : searchText
|
||||
)
|
||||
}
|
||||
|
||||
private func toggleFavorite(_ id: Int32) {
|
||||
viewModel.toggleFavorite(id: id) { success in
|
||||
if success {
|
||||
loadContractors()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Bar
|
||||
struct SearchBar: View {
|
||||
@Binding var text: String
|
||||
var placeholder: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
|
||||
TextField(placeholder, text: $text)
|
||||
.font(AppTypography.bodyMedium)
|
||||
|
||||
if !text.isEmpty {
|
||||
Button(action: { text = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.sm)
|
||||
.background(AppColors.surface)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filter Chip
|
||||
struct FilterChip: View {
|
||||
let title: String
|
||||
var icon: String? = nil
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
if let icon = icon {
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
}
|
||||
Text(title)
|
||||
.font(AppTypography.labelMedium)
|
||||
|
||||
Button(action: onRemove) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
.padding(.vertical, AppSpacing.xxs)
|
||||
.background(AppColors.primary.opacity(0.1))
|
||||
.foregroundColor(AppColors.primary)
|
||||
.cornerRadius(AppRadius.full)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
struct EmptyContractorsView: View {
|
||||
let hasFilters: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
Image(systemName: "person.badge.plus")
|
||||
.font(.system(size: 64))
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
|
||||
Text(hasFilters ? "No contractors found" : "No contractors yet")
|
||||
.font(AppTypography.titleMedium)
|
||||
.foregroundColor(AppColors.textSecondary)
|
||||
|
||||
if !hasFilters {
|
||||
Text("Add your first contractor to get started")
|
||||
.font(AppTypography.bodySmall)
|
||||
.foregroundColor(AppColors.textTertiary)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.xl)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContractorsListView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContractorsListView()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class LookupsManager: ObservableObject {
|
||||
@Published var taskFrequencies: [TaskFrequency] = []
|
||||
@Published var taskPriorities: [TaskPriority] = []
|
||||
@Published var taskStatuses: [TaskStatus] = []
|
||||
@Published var contractorSpecialties: [ContractorSpecialty] = []
|
||||
@Published var allTasks: [CustomTask] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var isInitialized: Bool = false
|
||||
@@ -92,4 +93,19 @@ class LookupsManager: ObservableObject {
|
||||
func clear() {
|
||||
repository.clear()
|
||||
}
|
||||
|
||||
func loadContractorSpecialties() {
|
||||
guard let token = TokenStorage.shared.getToken() else { return }
|
||||
|
||||
Task {
|
||||
let api = LookupsApi(client: ApiClient_iosKt.createHttpClient())
|
||||
let result = try? await api.getContractorSpecialties(token: token)
|
||||
|
||||
if let success = result as? ApiResultSuccess<NSArray> {
|
||||
await MainActor.run {
|
||||
self.contractorSpecialties = (success.data as? [ContractorSpecialty]) ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,21 @@ struct MainTabView: View {
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
NavigationView {
|
||||
ContractorsListView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
NavigationView {
|
||||
ProfileTabView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person.fill")
|
||||
}
|
||||
.tag(2)
|
||||
.tag(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,27 @@ struct CompletionCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let completedBy = completion.completedByName {
|
||||
// Display contractor or manual entry
|
||||
if let contractorDetails = completion.contractorDetails {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.caption2)
|
||||
.foregroundColor(AppColors.primary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("By: \(contractorDetails.name)")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if let company = contractorDetails.company {
|
||||
Text(company)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let completedBy = completion.completedByName {
|
||||
Text("By: \(completedBy)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -8,6 +8,7 @@ struct CompleteTaskView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var taskViewModel = TaskViewModel()
|
||||
@StateObject private var contractorViewModel = ContractorViewModel()
|
||||
@State private var completedByName: String = ""
|
||||
@State private var actualCost: String = ""
|
||||
@State private var notes: String = ""
|
||||
@@ -18,6 +19,8 @@ struct CompleteTaskView: View {
|
||||
@State private var showError: Bool = false
|
||||
@State private var errorMessage: String = ""
|
||||
@State private var showCamera: Bool = false
|
||||
@State private var selectedContractor: ContractorSummary? = nil
|
||||
@State private var showContractorPicker: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -50,11 +53,49 @@ struct CompleteTaskView: View {
|
||||
Text("Task Details")
|
||||
}
|
||||
|
||||
// Contractor Selection Section
|
||||
Section {
|
||||
Button(action: {
|
||||
showContractorPicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Label("Select Contractor", systemImage: "wrench.and.screwdriver")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let contractor = selectedContractor {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(contractor.name)
|
||||
.foregroundStyle(.secondary)
|
||||
if let company = contractor.company {
|
||||
Text(company)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("None")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Contractor (Optional)")
|
||||
} footer: {
|
||||
Text("Select a contractor if they completed this work, or leave blank for manual entry.")
|
||||
}
|
||||
|
||||
// Completion Details Section
|
||||
Section {
|
||||
LabeledContent {
|
||||
TextField("Your name", text: $completedByName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.disabled(selectedContractor != nil)
|
||||
} label: {
|
||||
Label("Completed By", systemImage: "person")
|
||||
}
|
||||
@@ -228,6 +269,15 @@ struct CompleteTaskView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showContractorPicker) {
|
||||
ContractorPickerView(
|
||||
selectedContractor: $selectedContractor,
|
||||
contractorViewModel: contractorViewModel
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
contractorViewModel.loadContractors()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +299,11 @@ struct CompleteTaskView: View {
|
||||
let request = TaskCompletionCreateRequest(
|
||||
task: task.id,
|
||||
completedByUser: nil,
|
||||
contractor: selectedContractor != nil ? KotlinInt(int: selectedContractor!.id) : nil,
|
||||
completedByName: completedByName.isEmpty ? nil : completedByName,
|
||||
completedByPhone: selectedContractor?.phone ?? "",
|
||||
completedByEmail: "",
|
||||
companyName: selectedContractor?.company ?? "",
|
||||
completionDate: currentDate,
|
||||
actualCost: actualCost.isEmpty ? nil : actualCost,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
@@ -310,3 +364,96 @@ extension KotlinByteArray {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contractor Picker View
|
||||
struct ContractorPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var selectedContractor: ContractorSummary?
|
||||
@ObservedObject var contractorViewModel: ContractorViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// None option
|
||||
Button(action: {
|
||||
selectedContractor = nil
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("None (Manual Entry)")
|
||||
.foregroundStyle(.primary)
|
||||
Text("Enter name manually")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if selectedContractor == nil {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contractors list
|
||||
if contractorViewModel.isLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
} else if let errorMessage = contractorViewModel.errorMessage {
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
} else {
|
||||
ForEach(contractorViewModel.contractors, id: \.id) { contractor in
|
||||
Button(action: {
|
||||
selectedContractor = contractor
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(contractor.name)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if let company = contractor.company {
|
||||
Text(company)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let specialty = contractor.specialty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.caption2)
|
||||
Text(specialty)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedContractor?.id == contractor.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Contractor")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,11 @@ class TaskViewModel: ObservableObject {
|
||||
let request = TaskCompletionCreateRequest(
|
||||
task: taskId,
|
||||
completedByUser: nil,
|
||||
contractor: nil,
|
||||
completedByName: nil,
|
||||
completedByPhone: nil,
|
||||
completedByEmail: nil,
|
||||
companyName: nil,
|
||||
completionDate: currentDate,
|
||||
actualCost: nil,
|
||||
notes: nil,
|
||||
|
||||
Reference in New Issue
Block a user