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,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)
}
}

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

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

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

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

View File

@@ -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]) ?? []
}
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

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

View File

@@ -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,