diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 7edcaf3..18e907f 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -425,13 +425,14 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -468,13 +469,14 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index 7aad1a9..0700288 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -258,6 +258,59 @@ actor APIClient { ) } + // ── Upload a resolution photo (entity_type = issue_result) ──────────── + // Saves to issue_result_photos subfolder on the server — same bucket + // as photos uploaded via the web update form. + func uploadResultPhoto(localPath: String, retrying: Bool = false) async throws -> String { + let url = try buildURL("/api/v1/photos/upload") + + guard let imageData = FileManager.default.contents(atPath: localPath) else { + throw APIError.networkError("Could not read photo: \(localPath)") + } + + let boundary = "Boundary-\(UUID().uuidString)" + let filename = URL(fileURLWithPath: localPath).lastPathComponent + let ext = (filename as NSString).pathExtension.lowercased() + let mime = ext == "png" ? "image/png" : "image/jpeg" + + var body = Data() + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\nissue_result\r\n".data(using: .utf8)!) + body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!) + body.append(imageData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + injectToken(&req) + req.httpBody = body + + let (data, response) = try await performRequest(req) + + if shouldRefresh(response, retrying: retrying) { + let refreshed = await refreshAccessToken() + if refreshed { return try await uploadResultPhoto(localPath: localPath, retrying: true) } + throw APIError.notAuthenticated + } + + struct PhotoResult: Decodable, Sendable { let serverPath: String } + if let env = try? decoder.decode(_Envelope.self, from: data), + env.ok, let r = env.data { return r.serverPath } + throw APIError.serverError("Result photo upload failed") + } + + // ── Attach resolution photos to an existing issue ───────────────────── + // PATCHes /api/v1/issues/{id}/result_photos — writes to Issue.result_photos + // (Resolution Details on web), not mobile_photo_paths (Photo Evidence). + func updateIssueResultPhotos(issueId: Int, resultPhotos: [String]) async throws { + struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int } + let _: R = try await request( + "/api/v1/issues/\(issueId)/result_photos", + method: "PATCH", + body: ["result_photos": resultPhotos] + ) + } + // ── Fetch Issue Detail (status + assigned_to) ───────────────────────── func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail { diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index 772d096..74b85b3 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -403,6 +403,8 @@ struct APIIssueDetail: Decodable, Sendable { let verifiedAt: String? let verificationNote: String? let reportedByName: String? + // Resolution photos uploaded via web or mobile resolve flow + let resultPhotos: [String] // Phase E — area and assignee context let areaName: String? let assignedToName: String? @@ -422,13 +424,14 @@ struct APIIssueDetail: Decodable, Sendable { verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) verificationNote = try? c.decode(String.self, forKey: .verificationNote) reportedByName = try? c.decode(String.self, forKey: .reportedByName) + resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] areaName = try? c.decode(String.self, forKey: .areaName) assignedToName = try? c.decode(String.self, forKey: .assignedToName) } private enum CodingKeys: String, CodingKey { case id, status, severity, description, assignedTo case facilityId, facilityName, reportedAt, resolvedAt - case resultNotes, verifiedAt, verificationNote, reportedByName + case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos case areaName, assignedToName } } diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index 4bf0ff5..a35de01 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -23,6 +23,9 @@ final class LocalIssue { var photoLocalPathsJSON: String = "[]" /// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...] var photoServerPathsJSON: String = "[]" + /// JSON-encoded array of resolution photo server paths (issue_result_photos bucket). + /// Mirrors Issue.result_photos on the server — shown under "Resolution Details". + var resultPhotoServerPathsJSON: String = "[]" // Shared coders — JSONDecoder/Encoder init is expensive (parses locale and // calendar info). Allocating them inside computed property getters means @@ -45,6 +48,8 @@ final class LocalIssue { @Transient private var _cachedLocalValue: [String] = [] @Transient private var _cachedServerKey: String = "" @Transient private var _cachedServerValue: [String] = [] + @Transient private var _cachedResultKey: String = "" + @Transient private var _cachedResultValue: [String] = [] /// Decoded local photo paths (up to 5) var photoLocalPaths: [String] { @@ -88,6 +93,28 @@ final class LocalIssue { } } + /// Decoded resolution photo server paths (issue_result_photos bucket). + /// Shown under "Resolution Details" — mirrors Issue.result_photos on the web. + var resultPhotoServerPaths: [String] { + get { + if _cachedResultKey == resultPhotoServerPathsJSON, !_cachedResultKey.isEmpty { + return _cachedResultValue + } + let decoded = (try? Self.jsonDecoder.decode([String].self, + from: Data(resultPhotoServerPathsJSON.utf8))) ?? [] + _cachedResultKey = resultPhotoServerPathsJSON + _cachedResultValue = decoded + return decoded + } + set { + let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), + encoding: .utf8)) ?? "[]" + resultPhotoServerPathsJSON = encoded + _cachedResultKey = encoded + _cachedResultValue = newValue + } + } + var createdAt: Date var syncStatus: String // "pending" | "synced" | "failed" var syncRetryCount: Int @@ -151,6 +178,7 @@ final class LocalIssue { self.issueStatus = "open" self.photoLocalPathsJSON = "[]" self.photoServerPathsJSON = "[]" + self.resultPhotoServerPathsJSON = "[]" self.createdAt = Date() self.syncStatus = "pending" self.syncRetryCount = 0 diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 41fc34f..70fc3ed 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -736,12 +736,13 @@ class SyncManager: ObservableObject { } // Refresh photos in case they were added after first pull // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). - // result_photos are resolution photos — shown separately on the web, - // not displayed on the iPad issues list. + // result_photos are resolution photos — shown separately under + // "Resolution Details" in resultPhotoServerPaths. var serverPaths: [String] = [] if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } serverPaths.append(contentsOf: api.mobilePhotoPaths) existing.photoServerPaths = serverPaths + existing.resultPhotoServerPaths = api.resultPhotos } else { // Insert new server-pulled issue let local = LocalIssue( @@ -771,6 +772,7 @@ class SyncManager: ObservableObject { if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } serverPaths.append(contentsOf: api.mobilePhotoPaths) local.photoServerPaths = serverPaths + local.resultPhotoServerPaths = api.resultPhotos if let ts = api.reportedAt, let date = Self.isoFormatter.date(from: ts) { local.createdAt = date diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift index bcf588d..a8d0799 100644 --- a/JanitorialQC/Views/Dashboard/IssuesView.swift +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -239,6 +239,14 @@ struct IssueDetailView: View { @State private var isUpdatingStatus = false @State private var statusError: String? @State private var showStatusPicker = false + // ── Resolution Photos ───────────────────────────────────────────────── + @State private var resultPhotos: [(image: UIImage, path: String)] = [] + @State private var showResultCamera = false + @State private var showResultLibrary = false + @State private var isUploadingResultPhotos = false + @State private var resultPhotoError: String? + @State private var resultPhotoSuccess = false + private let maxResultPhotos = 5 // ── Comments ────────────────────────────────────────────────────────── @State private var comments: [APIIssueComment] = [] @State private var isLoadingComments = false @@ -355,6 +363,118 @@ struct IssueDetailView: View { } } + // ── Upload Resolution Photos ─────────────────────────────────── + // Shown when the issue is resolved, online, and synced. + // Lets the inspector attach up to 5 photos showing the fix — + // identical to the "Result Photos" upload on the web update form. + if issue.issueStatus == "resolved", + sync.isOnline, + issue.serverId != nil { + + Section { + // Thumbnail strip for staged photos + if !resultPhotos.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(resultPhotos.indices, id: \.self) { i in + ZStack(alignment: .topTrailing) { + Image(uiImage: resultPhotos[i].image) + .resizable() + .scaledToFill() + .frame(width: 90, height: 90) + .clipShape(RoundedRectangle(cornerRadius: 8)) + Button { removeResultPhoto(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) + } + } + + let remaining = maxResultPhotos - resultPhotos.count + if remaining > 0 { + let countLabel = resultPhotos.isEmpty + ? "Up to \(maxResultPhotos) photos" + : "\(resultPhotos.count)/\(maxResultPhotos) — \(remaining) remaining" + Text(countLabel).font(.caption).foregroundStyle(.secondary) + + if UIImagePickerController.isSourceTypeAvailable(.camera) { + Button { showResultCamera = true } label: { + HStack { + Image(systemName: "camera.fill").font(.title3).frame(width: 36) + Text("Take Photo") + Spacer() + } + .padding(.vertical, 8).contentShape(Rectangle()) + } + .foregroundStyle(.primary) + } + + Button { showResultLibrary = true } label: { + HStack { + Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36) + Text("Choose from Library") + Spacer() + } + .padding(.vertical, 8).contentShape(Rectangle()) + } + .foregroundStyle(.primary) + } + + if let err = resultPhotoError { + Text(err).font(.caption).foregroundStyle(.red) + } + + if resultPhotoSuccess { + Label("Photos uploaded successfully.", systemImage: "checkmark.circle.fill") + .font(.caption).foregroundStyle(.green) + } + + if !resultPhotos.isEmpty { + Button { + Task { await uploadAndAttachResultPhotos() } + } label: { + if isUploadingResultPhotos { + HStack { ProgressView(); Text("Uploading…") } + } else { + Label("Upload Resolution Photos", systemImage: "arrow.up.circle.fill") + .fontWeight(.semibold) + } + } + .disabled(isUploadingResultPhotos) + .buttonStyle(.borderedProminent) + } + + } header: { + Text("Add Resolution Photos") + } footer: { + if resultPhotos.isEmpty { + Text("Attach photos showing the resolution (up to \(maxResultPhotos)).") + .font(.caption) + } else { + Text("Tap × on a photo to remove it before uploading.") + .font(.caption) + } + } + } + + // ── Resolution Photos (server-side, read display) ────────────── + if !issue.resultPhotoServerPaths.isEmpty { + Section("Resolution Photos (\(issue.resultPhotoServerPaths.count))") { + ForEach(issue.resultPhotoServerPaths, id: \.self) { relativePath in + RetryablePhotoView( + url: URL(string: ServerConfig.current + "/static/" + relativePath) + ) + } + } + } + Section("Description") { Text(issue.issueDescription) .font(.callout) @@ -508,6 +628,16 @@ struct IssueDetailView: View { ) } } + .fullScreenCover(isPresented: $showResultCamera) { + CameraPickerView(image: .constant(nil), onSelected: appendResultPhoto) + .ignoresSafeArea() + } + .sheet(isPresented: $showResultLibrary) { + MultiLibraryPickerView( + selectionLimit: maxResultPhotos - resultPhotos.count, + onSelected: appendResultPhotos + ) + } .task { await refreshStatusFromServer() await loadComments() @@ -573,6 +703,10 @@ struct IssueDetailView: View { } if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area } if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee } + // Refresh resolution photos from server + if !detail.resultPhotos.isEmpty { + issue.resultPhotoServerPaths = detail.resultPhotos + } try? context.save() } catch { // Non-fatal — show cached values silently @@ -628,6 +762,76 @@ struct IssueDetailView: View { statusError = error.localizedDescription } } + + // ── Resolution photo helpers ─────────────────────────────────────────── + + private func appendResultPhoto(_ img: UIImage) { + guard resultPhotos.count < maxResultPhotos, + let path = saveResultPhotoToDisk(img) else { return } + resultPhotos.append((image: img, path: path)) + } + + private func appendResultPhotos(_ images: [UIImage]) { + for img in images { + guard resultPhotos.count < maxResultPhotos, + let path = saveResultPhotoToDisk(img) else { break } + resultPhotos.append((image: img, path: path)) + } + } + + private func removeResultPhoto(at index: Int) { + guard index < resultPhotos.count else { return } + try? FileManager.default.removeItem(atPath: resultPhotos[index].path) + resultPhotos.remove(at: index) + } + + private func saveResultPhotoToDisk(_ 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/ResultPhotos", isDirectory: true) + try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true) + let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") + try? data.write(to: fileURL) + return fileURL.path + } + + // ── Upload resolution photos and PATCH to server ────────────────────── + // 1. Uploads each staged photo via /api/v1/photos/upload (entity_type=issue_result) + // 2. PATCHes /api/v1/issues//result_photos with the returned server paths + // 3. Appends to issue.resultPhotoServerPaths so the display section updates + // 4. Clears the staged resultPhotos array and deletes temp files + + private func uploadAndAttachResultPhotos() async { + guard let sid = issue.serverId, !resultPhotos.isEmpty else { return } + isUploadingResultPhotos = true + resultPhotoError = nil + resultPhotoSuccess = false + defer { isUploadingResultPhotos = false } + + do { + var serverPaths: [String] = [] + for photo in resultPhotos { + let path = try await APIClient.shared.uploadResultPhoto(localPath: photo.path) + serverPaths.append(path) + } + + try await APIClient.shared.updateIssueResultPhotos(issueId: sid, resultPhotos: serverPaths) + + // Append to local cache so display section updates immediately + issue.resultPhotoServerPaths = issue.resultPhotoServerPaths + serverPaths + try? context.save() + + // Clean up temp files and clear staging + for photo in resultPhotos { + try? FileManager.default.removeItem(atPath: photo.path) + } + resultPhotos = [] + resultPhotoSuccess = true + + } catch { + resultPhotoError = error.localizedDescription + } + } } // MARK: - Standalone Issue Creation @@ -926,4 +1130,3 @@ struct StandaloneIssueView: View { } } } -