From 9fd3f5b82934fb3147b80ca0e2ffc98d117847cd Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 11 May 2026 17:31:21 -0400 Subject: [PATCH] 05/11 Update issue image upload --- JanitorialQC/API/APIClient.swift | 3 +- JanitorialQC/Models/LocalIssue.swift | 22 ++- JanitorialQC/Sync/SyncManager.swift | 14 +- .../Views/Dashboard/DashboardView.swift | 24 +-- .../Views/Dashboard/FlagIssueView.swift | 180 +++++++++++++----- .../Views/Dashboard/FormFieldView.swift | 47 +++++ 6 files changed, 223 insertions(+), 67 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 77f2414..fab4553 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -194,7 +194,8 @@ actor APIClient { "mobile_local_id": issue.localId, ] if let id = issue.inspection?.serverId { body["inspection_id"] = id } - if let path = issue.photoServerPath { body["photo_path"] = path } + // Send the first uploaded photo as photo_path (server Issue.photo_path is a single column) + if let firstPhoto = issue.photoServerPaths.first { body["photo_path"] = firstPhoto } struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool } let r: R = try await post("/api/v1/issues", body: body) diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index 6b53d02..f61e552 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -16,8 +16,22 @@ final class LocalIssue { var severity: String // "low" | "medium" | "high" | "critical" var issueDescription: String var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification" - var photoLocalPath: String? // local file path before upload - var photoServerPath: String? // server path after upload + /// JSON-encoded array of absolute local file paths, e.g. ["/var/.../photo1.jpg", ...] + var photoLocalPathsJSON: String = "[]" + /// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...] + var photoServerPathsJSON: String = "[]" + + /// Decoded local photo paths (up to 5) + var photoLocalPaths: [String] { + get { (try? JSONDecoder().decode([String].self, from: Data(photoLocalPathsJSON.utf8))) ?? [] } + set { photoLocalPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" } + } + + /// Decoded server photo paths + var photoServerPaths: [String] { + get { (try? JSONDecoder().decode([String].self, from: Data(photoServerPathsJSON.utf8))) ?? [] } + set { photoServerPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" } + } var createdAt: Date var syncStatus: String // "pending" | "synced" | "failed" @@ -39,8 +53,8 @@ final class LocalIssue { self.severity = severity self.issueDescription = description self.issueStatus = "open" - self.photoLocalPath = nil - self.photoServerPath = nil + self.photoLocalPathsJSON = "[]" + self.photoServerPathsJSON = "[]" self.createdAt = Date() self.syncStatus = "pending" self.syncRetryCount = 0 diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 392fb06..63bd481 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -95,15 +95,15 @@ class SyncManager: ObservableObject { inspections?.first?.setValue(serverPath, forFieldId: fieldId) } - // Update parent issue photo path + // Update parent issue photo paths array if photo.entityType == "issue" { let entityId = photo.entityLocalId - let issues = try? context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.localId == entityId } - ) - ) - issues?.first?.photoServerPath = serverPath + let allIssues = (try? context.fetch(FetchDescriptor())) ?? [] + if let issue = allIssues.first(where: { $0.localId == entityId }) { + var paths = issue.photoServerPaths + if !paths.contains(serverPath) { paths.append(serverPath) } + issue.photoServerPaths = paths + } } try? context.save() diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 5b2e00b..4d8f84a 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -293,7 +293,7 @@ struct MyInspectionsView: View { } // Delete associated local issues for issue in inspection.localIssues { - if let path = issue.photoLocalPath { + for path in issue.photoLocalPaths { try? FileManager.default.removeItem(atPath: path) } context.delete(issue) @@ -860,16 +860,18 @@ struct IssueDetailView: View { } } - if let photoPath = issue.photoLocalPath { - Section("Photo") { - if let img = UIImage(contentsOfFile: photoPath) { - Image(uiImage: img) - .resizable() - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - Label("Photo pending upload", systemImage: "photo") - .foregroundStyle(.secondary) + if !issue.photoLocalPaths.isEmpty { + Section("Photos (\(issue.photoLocalPaths.count))") { + ForEach(issue.photoLocalPaths, id: \.self) { path in + if let img = UIImage(contentsOfFile: path) { + Image(uiImage: img) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Label("Photo pending upload", systemImage: "photo") + .foregroundStyle(.secondary) + } } } } diff --git a/JanitorialQC/Views/Dashboard/FlagIssueView.swift b/JanitorialQC/Views/Dashboard/FlagIssueView.swift index d9b3221..c9bc55f 100644 --- a/JanitorialQC/Views/Dashboard/FlagIssueView.swift +++ b/JanitorialQC/Views/Dashboard/FlagIssueView.swift @@ -3,13 +3,14 @@ // Sheet for flagging an issue during an inspection. // Saves locally immediately; syncs to server when online. // -// CHANGED: Area picker removed. Facility is derived directly from the -// inspection (inspection.facilityServerId) and displayed as read-only info, -// matching the web app's flag_issue.html behaviour where facility_id is -// a hidden field populated from the inspection context. +// CHANGED: Multi-photo support (up to 5). +// - Inline camera / library buttons replace the small confirmationDialog. +// - Thumbnail grid shows all attached photos with per-photo remove buttons. +// - Each photo creates its own PendingPhoto record for upload. import SwiftUI import SwiftData +import PhotosUI struct FlagIssueView: View { @@ -21,16 +22,21 @@ struct FlagIssueView: View { @State private var severity = "medium" @State private var description = "" - @State private var selectedImage: UIImage? - @State private var photoLocalPath: String? - @State private var showChoice = false - @State private var showCamera = false - @State private var showLibrary = false + + // Each entry: (UIImage for display, local file path for storage) + @State private var photos: [(image: UIImage, path: String)] = [] + + @State private var showCamera = false + @State private var showLibrary = false + + private let maxPhotos = 5 private var cameraAvailable: Bool { UIImagePickerController.isSourceTypeAvailable(.camera) } + private var remainingSlots: Int { maxPhotos - photos.count } + private let severities = ["low", "medium", "high", "critical"] private var facility: LocalFacility? { @@ -49,7 +55,7 @@ struct FlagIssueView: View { var body: some View { NavigationStack { Form { - // ── Facility (read-only) — matches web alert banner ──────── + // ── Facility (read-only) ─────────────────────────────────── Section { HStack(spacing: 10) { Image(systemName: "building.2") @@ -86,20 +92,88 @@ struct FlagIssueView: View { .frame(minHeight: 100) } - // ── Photo ────────────────────────────────────────────────── - Section("Photo (Optional)") { - if let img = selectedImage { - Image(uiImage: img) - .resizable() - .scaledToFit() - .frame(maxHeight: 160) - .clipShape(RoundedRectangle(cornerRadius: 8)) + // ── Photos ───────────────────────────────────────────────── + Section { + // Thumbnail grid + if !photos.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(photos.indices, id: \.self) { i in + ZStack(alignment: .topTrailing) { + Image(uiImage: photos[i].image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 10)) + // Remove button + Button { + removePhoto(at: i) + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .black.opacity(0.7)) + } + .offset(x: 6, y: -6) + } + } + } + .padding(.vertical, 6) + } } - Button { - if cameraAvailable { showChoice = true } else { showLibrary = true } - } label: { - Label(selectedImage == nil ? "Attach Photo" : "Replace Photo", - systemImage: "camera") + + // Add photo buttons — shown only while slots remain + if remainingSlots > 0 { + let countLabel = photos.isEmpty + ? "Up to \(maxPhotos) photos" + : "\(photos.count)/\(maxPhotos) — \(remainingSlots) remaining" + + Text(countLabel) + .font(.caption) + .foregroundStyle(.secondary) + + // Camera button + if cameraAvailable { + Button { + showCamera = true + } label: { + HStack { + Image(systemName: "camera.fill") + .font(.title3) + .frame(width: 36) + Text("Take Photo") + .font(.body) + Spacer() + } + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .foregroundStyle(.primary) + } + + // Library button + Button { + showLibrary = true + } label: { + HStack { + Image(systemName: "photo.on.rectangle.angled") + .font(.title3) + .frame(width: 36) + Text("Choose from Library") + .font(.body) + Spacer() + } + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .foregroundStyle(.primary) + } + } header: { + Text("Photos (Optional)") + } footer: { + if !photos.isEmpty { + Text("Tap × on a photo to remove it.") + .font(.caption) } } @@ -125,34 +199,51 @@ struct FlagIssueView: View { .fontWeight(.semibold) } } - .confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) { - Button("Take Photo") { showCamera = true } - Button("Photo Library") { showLibrary = true } - Button("Cancel", role: .cancel) {} - } .fullScreenCover(isPresented: $showCamera) { - CameraPickerView(image: $selectedImage, onSelected: savePhoto) + CameraPickerView(image: .constant(nil), onSelected: appendPhoto) .ignoresSafeArea() } .sheet(isPresented: $showLibrary) { - LibraryPickerView(image: $selectedImage, onSelected: savePhoto) + MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos) } } } - private func savePhoto(_ img: UIImage) { - guard let data = img.jpegData(compressionQuality: 0.8) else { return } + // ── Photo helpers ───────────────────────────────────────────────────── + + private func appendPhoto(_ img: UIImage) { + guard photos.count < maxPhotos else { return } + guard let path = savePhotoToDisk(img) else { return } + photos.append((image: img, path: path)) + } + + private func appendPhotos(_ images: [UIImage]) { + for img in images { + guard photos.count < maxPhotos else { break } + guard let path = savePhotoToDisk(img) else { continue } + photos.append((image: img, path: path)) + } + } + + private func removePhoto(at index: Int) { + guard index < photos.count else { return } + // Delete the local file + try? FileManager.default.removeItem(atPath: photos[index].path) + photos.remove(at: index) + } + + private func savePhotoToDisk(_ img: UIImage) -> String? { + guard let data = img.jpegData(compressionQuality: 0.8) else { return nil } let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true) - try? FileManager.default.createDirectory(at: photosDir, - withIntermediateDirectories: true) - let filename = "\(UUID().uuidString).jpg" - let fileURL = photosDir.appendingPathComponent(filename) + try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true) + let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") try? data.write(to: fileURL) - photoLocalPath = fileURL.path - selectedImage = img + return fileURL.path } + // ── Submit ──────────────────────────────────────────────────────────── + private func submitIssue() { let issue = LocalIssue( inspectionLocalId: inspection.localId, @@ -160,18 +251,19 @@ struct FlagIssueView: View { severity: severity, description: description.trimmingCharacters(in: .whitespaces) ) - issue.photoLocalPath = photoLocalPath - issue.inspection = inspection + issue.photoLocalPaths = photos.map(\.path) + issue.inspection = inspection inspection.localIssues.append(issue) context.insert(issue) - if let path = photoLocalPath { - let photo = PendingPhoto( - localFilePath: path, + // Create one PendingPhoto per photo so they all upload independently + for photo in photos { + let pending = PendingPhoto( + localFilePath: photo.path, entityType: "issue", entityLocalId: issue.localId ) - context.insert(photo) + context.insert(pending) } try? context.save() diff --git a/JanitorialQC/Views/Dashboard/FormFieldView.swift b/JanitorialQC/Views/Dashboard/FormFieldView.swift index f029dfa..b058bcb 100644 --- a/JanitorialQC/Views/Dashboard/FormFieldView.swift +++ b/JanitorialQC/Views/Dashboard/FormFieldView.swift @@ -578,6 +578,53 @@ struct LibraryPickerView: UIViewControllerRepresentable { } } +// ── Multi-image Library Picker — PHPickerViewController with configurable limit ─ + +struct MultiLibraryPickerView: UIViewControllerRepresentable { + /// Maximum number of images the user may select in this session. + var selectionLimit: Int + var onSelected: ([UIImage]) -> Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = selectionLimit + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ vc: PHPickerViewController, context: Context) {} + func makeCoordinator() -> Coordinator { Coordinator(self) } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: MultiLibraryPickerView + init(_ parent: MultiLibraryPickerView) { self.parent = parent } + + func picker(_ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { return } + + var images: [UIImage] = [] + let group = DispatchGroup() + + for result in results { + guard result.itemProvider.canLoadObject(ofClass: UIImage.self) else { continue } + group.enter() + result.itemProvider.loadObject(ofClass: UIImage.self) { object, _ in + if let img = object as? UIImage { images.append(img) } + group.leave() + } + } + + group.notify(queue: .main) { + self.parent.onSelected(images) + } + } + } +} + // MARK: - TableFieldView struct TableFieldView: View {