Files
honeyDueKMP/iosApp/iosApp/Task/CompleteTaskView.swift
Trey t 2730c94e4d Add comprehensive error message parsing to prevent raw JSON display
- Created ErrorMessageParser utility for both iOS (Swift) and Android (Kotlin)
- Parser detects JSON-formatted error messages and extracts user-friendly text
- Identifies when data objects (not errors) are returned and provides generic messages
- Updated all API error handling to pass raw error bodies instead of concatenating
- Applied ErrorMessageParser across all ViewModels and screens on both platforms
- Fixed ContractorApi and DocumentApi to not concatenate error bodies with messages
- Updated ApiResultHandler to automatically parse all error messages
- Error messages now show "Request failed. Please check your input and try again." instead of raw JSON

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:59:42 -06:00

468 lines
19 KiB
Swift

import SwiftUI
import PhotosUI
import ComposeApp
struct CompleteTaskView: View {
let task: TaskDetail
let onComplete: () -> Void
@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 = ""
@State private var rating: Int = 3
@State private var selectedItems: [PhotosPickerItem] = []
@State private var selectedImages: [UIImage] = []
@State private var isSubmitting: Bool = false
@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 {
Form {
// Task Info Section
Section {
VStack(alignment: .leading, spacing: 8) {
Text(task.title)
.font(.headline)
HStack {
Label(task.category.name.capitalized, systemImage: "folder")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if let status = task.status {
Text(status.displayName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary)
.clipShape(Capsule())
}
}
}
} header: {
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")
}
LabeledContent {
TextField("0.00", text: $actualCost)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.overlay(alignment: .leading) {
Text("$")
.foregroundStyle(.secondary)
}
.padding(.leading, 12)
} label: {
Label("Actual Cost", systemImage: "dollarsign.circle")
}
} header: {
Text("Optional Information")
} footer: {
Text("Add any additional details about completing this task.")
}
// Notes Section
Section {
VStack(alignment: .leading, spacing: 8) {
Label("Notes", systemImage: "note.text")
.font(.subheadline)
.foregroundStyle(.secondary)
TextEditor(text: $notes)
.frame(minHeight: 100)
.scrollContentBackground(.hidden)
}
} footer: {
Text("Optional notes about the work completed.")
}
// Rating Section
Section {
VStack(spacing: 12) {
HStack {
Label("Quality Rating", systemImage: "star")
.font(.subheadline)
Spacer()
Text("\(rating) / 5")
.font(.subheadline)
.foregroundStyle(.secondary)
}
HStack(spacing: 16) {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= rating ? "star.fill" : "star")
.font(.title2)
.foregroundStyle(star <= rating ? .yellow : .gray)
.symbolRenderingMode(.hierarchical)
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
rating = star
}
}
}
}
.frame(maxWidth: .infinity)
}
} footer: {
Text("Rate the quality of work from 1 to 5 stars.")
}
// Images Section
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Button(action: {
showCamera = true
}) {
Label("Take Photo", systemImage: "camera")
.frame(maxWidth: .infinity)
.foregroundStyle(.blue)
}
.buttonStyle(.bordered)
PhotosPicker(
selection: $selectedItems,
maxSelectionCount: 5,
matching: .images,
photoLibrary: .shared()
) {
Label("Library", systemImage: "photo.on.rectangle.angled")
.frame(maxWidth: .infinity)
.foregroundStyle(.blue)
}
.buttonStyle(.bordered)
}
.onChange(of: selectedItems) { newItems in
Task {
selectedImages = []
for item in newItems {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
selectedImages.append(image)
}
}
}
}
// Display selected images
if !selectedImages.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(selectedImages.indices, id: \.self) { index in
ImageThumbnailView(
image: selectedImages[index],
onRemove: {
withAnimation {
selectedImages.remove(at: index)
selectedItems.remove(at: index)
}
}
)
}
}
.padding(.vertical, 4)
}
}
}
} header: {
Text("Photos (\(selectedImages.count)/5)")
} footer: {
Text("Add up to 5 photos documenting the completed work.")
}
// Complete Button Section
Section {
Button(action: handleComplete) {
HStack {
if isSubmitting {
ProgressView()
.tint(.white)
} else {
Label("Complete Task", systemImage: "checkmark.circle.fill")
}
}
.frame(maxWidth: .infinity)
.fontWeight(.semibold)
}
.listRowBackground(isSubmitting ? Color.gray : Color.green)
.foregroundStyle(.white)
.disabled(isSubmitting)
}
}
.navigationTitle("Complete Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) {}
} message: {
Text(errorMessage)
}
.sheet(isPresented: $showCamera) {
CameraPickerView { image in
if selectedImages.count < 5 {
selectedImages.append(image)
}
}
}
.sheet(isPresented: $showContractorPicker) {
ContractorPickerView(
selectedContractor: $selectedContractor,
contractorViewModel: contractorViewModel
)
}
.onAppear {
contractorViewModel.loadContractors()
}
.handleErrors(
error: errorMessage,
onRetry: { handleComplete() }
)
}
}
private func handleComplete() {
guard TokenStorage.shared.getToken() != nil else {
errorMessage = "Not authenticated"
showError = true
return
}
isSubmitting = true
// Get current date in ISO format
let dateFormatter = ISO8601DateFormatter()
let currentDate = dateFormatter.string(from: Date())
// Create request
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,
rating: KotlinInt(int: Int32(rating))
)
Task {
do {
let result: ApiResult<TaskCompletion>
// If there are images, upload with images
if !selectedImages.isEmpty {
// Compress images to meet size requirements
let imageDataArray = ImageCompression.compressImages(selectedImages)
let imageByteArrays = imageDataArray.map { KotlinByteArray(data: $0) }
let fileNames = (0..<imageDataArray.count).map { "image_\($0).jpg" }
result = try await APILayer.shared.createTaskCompletionWithImages(
request: request,
images: imageByteArrays,
imageFileNames: fileNames
)
} else {
// Upload without images
result = try await APILayer.shared.createTaskCompletion(request: request)
}
await MainActor.run {
if result is ApiResultSuccess<TaskCompletion> {
self.isSubmitting = false
self.dismiss()
self.onComplete()
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.showError = true
self.isSubmitting = false
} else {
self.errorMessage = "Failed to complete task"
self.showError = true
self.isSubmitting = false
}
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.showError = true
self.isSubmitting = false
}
}
}
}
}
// Helper extension to convert Data to KotlinByteArray
extension KotlinByteArray {
convenience init(data: Data) {
let array = [UInt8](data)
self.init(size: Int32(array.count))
for (index, byte) in array.enumerated() {
self.set(index: Int32(index), value: Int8(bitPattern: byte))
}
}
}
// 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(.blue)
}
}
}
// 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(.blue)
}
}
}
}
}
}
.navigationTitle("Select Contractor")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}