- Add updatedTask field to TaskCompletionResponse model from API - Modify CompleteTaskView callback to pass back the updated task - Add updateTaskInKanban() function to AllTasksView and ResidenceDetailView - Move completed tasks to correct kanban column without additional API call - Remove debug print statements from ResidenceDetailView 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
466 lines
19 KiB
Swift
466 lines
19 KiB
Swift
import SwiftUI
|
|
import PhotosUI
|
|
import ComposeApp
|
|
|
|
struct CompleteTaskView: View {
|
|
let task: TaskResponse
|
|
let onComplete: (TaskResponse?) -> Void // Pass back updated task
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@StateObject private var taskViewModel = TaskViewModel()
|
|
@StateObject private var contractorViewModel = ContractorViewModel()
|
|
private let completionViewModel = ComposeApp.TaskCompletionViewModel()
|
|
@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(L10n.Tasks.taskDetails)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
// Contractor Selection Section
|
|
Section {
|
|
Button(action: {
|
|
showContractorPicker = true
|
|
}) {
|
|
HStack {
|
|
Label(L10n.Tasks.selectContractor, 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(L10n.Tasks.none)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
} header: {
|
|
Text(L10n.Tasks.contractorOptional)
|
|
} footer: {
|
|
Text(L10n.Tasks.contractorHelper)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
// Completion Details Section
|
|
Section {
|
|
LabeledContent {
|
|
TextField(L10n.Tasks.yourName, text: $completedByName)
|
|
.multilineTextAlignment(.trailing)
|
|
.disabled(selectedContractor != nil)
|
|
} label: {
|
|
Label(L10n.Tasks.completedBy, systemImage: "person")
|
|
}
|
|
|
|
LabeledContent {
|
|
TextField("0.00", text: $actualCost)
|
|
.keyboardType(.decimalPad)
|
|
.multilineTextAlignment(.trailing)
|
|
.overlay(alignment: .leading) {
|
|
Text("$")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.leading, 12)
|
|
} label: {
|
|
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
|
|
}
|
|
} header: {
|
|
Text(L10n.Tasks.optionalInfo)
|
|
} footer: {
|
|
Text(L10n.Tasks.optionalDetails)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
// Notes Section
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(L10n.Tasks.notes, systemImage: "note.text")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
TextEditor(text: $notes)
|
|
.frame(minHeight: 100)
|
|
.scrollContentBackground(.hidden)
|
|
}
|
|
} footer: {
|
|
Text(L10n.Tasks.optionalNotes)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
// Rating Section
|
|
Section {
|
|
VStack(spacing: 12) {
|
|
HStack {
|
|
Label(L10n.Tasks.qualityRating, 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(L10n.Tasks.rateQuality)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
// Images Section
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(spacing: 12) {
|
|
Button(action: {
|
|
showCamera = true
|
|
}) {
|
|
Label(L10n.Tasks.takePhoto, systemImage: "camera")
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundStyle(Color.appPrimary)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
PhotosPicker(
|
|
selection: $selectedItems,
|
|
maxSelectionCount: 5,
|
|
matching: .images,
|
|
photoLibrary: .shared()
|
|
) {
|
|
Label(L10n.Tasks.library, systemImage: "photo.on.rectangle.angled")
|
|
.frame(maxWidth: .infinity)
|
|
.foregroundStyle(Color.appPrimary)
|
|
}
|
|
.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("\(L10n.Tasks.photos) (\(selectedImages.count)/5)")
|
|
} footer: {
|
|
Text(L10n.Tasks.addPhotos)
|
|
}
|
|
.listRowBackground(Color.appBackgroundSecondary)
|
|
|
|
// Complete Button Section
|
|
Section {
|
|
Button(action: handleComplete) {
|
|
HStack {
|
|
if isSubmitting {
|
|
ProgressView()
|
|
.tint(.white)
|
|
} else {
|
|
Label(L10n.Tasks.completeTask, systemImage: "checkmark.circle.fill")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.fontWeight(.semibold)
|
|
}
|
|
.listRowBackground(isSubmitting ? Color.gray : Color.appPrimary)
|
|
.foregroundStyle(Color.appTextOnPrimary)
|
|
.disabled(isSubmitting)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.appBackgroundPrimary)
|
|
.navigationTitle(L10n.Tasks.completeTask)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(L10n.Common.cancel) {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.alert(L10n.Tasks.error, isPresented: $showError) {
|
|
Button(L10n.Common.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
|
|
|
|
// Create request with simplified Go API format
|
|
// Note: completedAt defaults to now on server if not provided
|
|
let request = TaskCompletionCreateRequest(
|
|
taskId: task.id,
|
|
completedAt: nil,
|
|
notes: notes.isEmpty ? nil : notes,
|
|
actualCost: actualCost.isEmpty ? nil : KotlinDouble(double: Double(actualCost) ?? 0.0),
|
|
rating: KotlinInt(int: Int32(rating)),
|
|
imageUrls: nil // Images uploaded separately and URLs added by handler
|
|
)
|
|
|
|
// Use TaskCompletionViewModel to create completion
|
|
if !selectedImages.isEmpty {
|
|
// Convert images to ImageData for Kotlin
|
|
let imageDataList = selectedImages.compactMap { uiImage -> ComposeApp.ImageData? in
|
|
guard let jpegData = uiImage.jpegData(compressionQuality: 0.8) else { return nil }
|
|
let byteArray = KotlinByteArray(data: jpegData)
|
|
return ComposeApp.ImageData(bytes: byteArray, fileName: "completion_image.jpg")
|
|
}
|
|
completionViewModel.createTaskCompletionWithImages(request: request, images: imageDataList)
|
|
} else {
|
|
completionViewModel.createTaskCompletion(request: request)
|
|
}
|
|
|
|
// Observe the result
|
|
Task {
|
|
for await state in completionViewModel.createCompletionState {
|
|
await MainActor.run {
|
|
switch state {
|
|
case let success as ApiResultSuccess<TaskCompletionResponse>:
|
|
self.isSubmitting = false
|
|
self.onComplete(success.data?.updatedTask) // Pass back updated task
|
|
self.dismiss()
|
|
case let error as ApiResultError:
|
|
self.errorMessage = error.message
|
|
self.showError = true
|
|
self.isSubmitting = false
|
|
case is ApiResultLoading:
|
|
// Still loading, continue waiting
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// Break out of loop on terminal states
|
|
if state is ApiResultSuccess<TaskCompletionResponse> || state is ApiResultError {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// 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(L10n.Tasks.noneManual)
|
|
.foregroundStyle(.primary)
|
|
Text(L10n.Tasks.enterManually)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if selectedContractor == nil {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(Color.appPrimary)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Contractors list
|
|
if contractorViewModel.isLoading {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
} else if let errorMessage = contractorViewModel.errorMessage {
|
|
Text(errorMessage)
|
|
.foregroundStyle(Color.appError)
|
|
.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 firstSpecialty = contractor.specialties.first {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "wrench.and.screwdriver")
|
|
.font(.caption2)
|
|
Text(firstSpecialty.name)
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if selectedContractor?.id == contractor.id {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(Color.appPrimary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(L10n.Tasks.selectContractor)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(L10n.Common.cancel) {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|