Files
honeyDueKMP/iosApp/iosApp/Task/CompleteTaskView.swift
T
Trey T db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
i18n: complete app-wide localization (10 languages) + audit tooling
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and
Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh:
- iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings
  (permissions), plural variations, ~200 new keys translated
- Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys
  ×10), routed Api/ViewModel/util error & UI strings through localization
- Backend-localized lookups/suggestions consumed via display names
- Widget extension catalog; theme names, home-profile fallbacks, validation,
  network errors, accessibility labels all localized

Add re-runnable verification gates:
- scripts/i18n_audit.py  — enumerate every literal, partition to GAP=0
- scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:52:28 -05:00

573 lines
24 KiB
Swift

import SwiftUI
import PhotosUI
import ComposeApp
/// Wrapper to retain the Kotlin ViewModel via @StateObject
private class CompletionViewModelHolder: ObservableObject {
let vm = ComposeApp.TaskCompletionViewModel()
}
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()
@StateObject private var completionHolder = CompletionViewModelHolder()
private var completionViewModel: ComposeApp.TaskCompletionViewModel { completionHolder.vm }
@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
@State private var observationTask: Task<Void, Never>? = nil
var body: some View {
NavigationStack {
Form {
// Task Info Section
Section {
VStack(alignment: .leading, spacing: 8) {
Text(task.title)
.font(.headline)
HStack {
Label((task.categoryName ?? "").capitalized, systemImage: "folder")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
if task.inProgress {
Text(L10n.Tasks.inProgress)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary)
.clipShape(Capsule())
}
}
}
} header: {
Text(L10n.Tasks.taskDetails)
}
.sectionBackground()
// 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)
}
}
.accessibilityIdentifier("TaskCompletion.ContractorPicker")
} header: {
Text(L10n.Tasks.contractorOptional)
} footer: {
Text(L10n.Tasks.contractorHelper)
}
.sectionBackground()
// 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)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.actualCostField)
} label: {
Label(L10n.Tasks.actualCost, systemImage: "dollarsign.circle")
}
} header: {
Text(L10n.Tasks.optionalInfo)
} footer: {
Text(L10n.Tasks.optionalDetails)
}
.sectionBackground()
// 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)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.notesField)
}
} footer: {
Text(L10n.Tasks.optionalNotes)
}
.sectionBackground()
// 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)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.ratingView)
.accessibilityElement(children: .combine)
.accessibilityLabel("Rating: \(rating) out of 5 stars")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
if rating < 5 { rating += 1 }
case .decrement:
if rating > 1 { rating -= 1 }
@unknown default:
break
}
}
} footer: {
Text(L10n.Tasks.rateQuality)
}
.sectionBackground()
// 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)
// Camera photos don't exist in selectedItems.
// Guard the index to avoid out-of-bounds crashes.
if index < selectedItems.count {
selectedItems.remove(at: index)
}
}
}
)
}
}
.padding(.vertical, 4)
}
}
}
} header: {
Text("\(L10n.Tasks.photos) (\(selectedImages.count)/5)")
} footer: {
Text(L10n.Tasks.addPhotos)
}
.sectionBackground()
// 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)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.submitButton)
.listRowBackground(isSubmitting ? Color.gray : Color.appPrimary)
.foregroundStyle(Color.appTextOnPrimary)
.disabled(isSubmitting)
}
}
.standardFormStyle()
.background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.completeTask)
.navigationBarTitleDisplayMode(.inline)
// ONE keyboard "Done" toolbar at the form root per-field
// `.keyboardDismissToolbar()` modifiers each install a
// separate `ToolbarItemGroup(placement: .keyboard)`, and
// SwiftUI stacks them on the responder chain so any focused
// field renders multiple Done buttons side-by-side (issue #5).
.keyboardDismissToolbar()
.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()
}
.onDisappear {
observationTask?.cancel()
observationTask = nil
}
.handleErrors(
error: errorMessage,
onRetry: { handleComplete() }
)
}
}
private func handleComplete() {
guard TokenStorage.shared.getToken() != nil else {
errorMessage = String(localized: "Not authenticated")
showError = true
return
}
isSubmitting = true
// New direct-to-B2 upload path: downsample on-device, presign, POST
// straight to B2, pass the resulting upload_ids to the completion
// create call. Bytes never traverse our API server. See
// /api/uploads/presign in honeyDueAPI-go.
if !selectedImages.isEmpty {
uploadAndCreate()
} else {
// No images go straight to the completion create.
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)),
uploadIds: nil
)
completionViewModel.createTaskCompletion(request: request)
observeCompletionState()
}
}
/// Async pipeline: downsample presign+upload to B2 create completion
/// with the returned upload_ids. Errors at any stage become a single
/// alert; partial uploads (1 of 3 succeeded) currently fail the whole
/// flow server-side cleanup reaps the orphans within the hour.
private func uploadAndCreate() {
observationTask?.cancel()
observationTask = Task {
// Step 1: downsample each image. Runs on the calling task; the
// ImageDownsampler is memory-bounded so this is safe for the
// expected batch sizes (5 images).
let payloads: [(Data, String)] = selectedImages.compactMap { uiImage -> (Data, String)? in
guard let data = ImageDownsampler.downsample(uiImage: uiImage, profile: .completion) else {
return nil
}
return (data, "completion_\(UUID().uuidString).jpg") // i18n-ignore: generated upload filename (non-UI)
}
guard payloads.count == selectedImages.count else {
await MainActor.run {
errorMessage = String(localized: "One or more photos couldn't be processed.")
showError = true
isSubmitting = false
}
return
}
// Step 2: presign + upload each to B2. PresignedUploader runs
// them in parallel under a server-enforced concurrency cap of 10.
guard let uploader = PresignedUploader() else {
await MainActor.run {
errorMessage = String(localized: "Not authenticated")
showError = true
isSubmitting = false
}
return
}
let uploadIds: [Int32]
do {
uploadIds = try await uploader.uploadAll(items: payloads, category: .completion)
} catch {
await MainActor.run {
errorMessage = (error as? PresignedUploaderError)?.errorDescription
?? error.localizedDescription
showError = true
isSubmitting = false
}
return
}
// Step 3: create completion via the existing endpoint, passing
// upload_ids so the server claims the pending_uploads rows and
// turns them into TaskCompletionImage rows.
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)),
uploadIds: uploadIds.map { KotlinInt(int: $0) }
)
await MainActor.run {
completionViewModel.createTaskCompletion(request: request)
}
await observeCompletionStateAsync()
}
}
/// Observe the createCompletionState StateFlow until a terminal value
/// arrives, then dismiss or surface an error. Called from the
/// no-images path.
private func observeCompletionState() {
observationTask?.cancel()
observationTask = Task {
await observeCompletionStateAsync()
}
}
private func observeCompletionStateAsync() async {
for await state in completionViewModel.createCompletionState {
if Task.isCancelled { break }
await MainActor.run {
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
self.isSubmitting = false
self.onComplete(success.data?.updatedTask)
self.dismiss()
} else if let error = ApiResultBridge.error(from: state) {
self.errorMessage = error.message
self.showError = true
self.isSubmitting = false
}
}
if state is ApiResultSuccess<TaskCompletionResponse> || ApiResultBridge.isError(state) {
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)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Tasks.enterManually)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
Spacer()
if selectedContractor == nil {
Image(systemName: "checkmark")
.foregroundColor(Color.appPrimary)
}
}
}
.sectionBackground()
// Contractors list
if contractorViewModel.isLoading {
HStack {
Spacer()
ProgressView()
.tint(Color.appPrimary)
Spacer()
}
.sectionBackground()
} else if let errorMessage = contractorViewModel.errorMessage {
Text(errorMessage)
.foregroundColor(Color.appError)
.font(.caption)
.sectionBackground()
} else {
ForEach(contractorViewModel.contractors, id: \.id) { contractor in
Button(action: {
selectedContractor = contractor
dismiss()
}) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(contractor.name)
.foregroundColor(Color.appTextPrimary)
if let company = contractor.company {
Text(company)
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
if let firstSpecialty = contractor.specialties.first {
HStack(spacing: 4) {
Image(systemName: "wrench.and.screwdriver")
.font(.caption2)
Text(firstSpecialty.displayName)
.font(.caption2)
}
.foregroundColor(Color.appTextSecondary.opacity(0.7))
}
}
Spacer()
if selectedContractor?.id == contractor.id {
Image(systemName: "checkmark")
.foregroundColor(Color.appPrimary)
}
}
}
.sectionBackground()
}
}
}
.standardFormStyle()
.background(WarmGradientBackground())
.navigationTitle(L10n.Tasks.selectContractor)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.Common.cancel) {
dismiss()
}
}
}
}
}
}