db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
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>
573 lines
24 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|