diff --git a/JanitorialQC.xcodeproj/project.pbxproj b/JanitorialQC.xcodeproj/project.pbxproj index 0ec4978..0dcaf85 100644 --- a/JanitorialQC.xcodeproj/project.pbxproj +++ b/JanitorialQC.xcodeproj/project.pbxproj @@ -420,7 +420,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection and when you take a photo, so the time and place are stamped onto the photo as evidence."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -464,7 +464,7 @@ INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues."; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site."; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection and when you take a photo, so the time and place are stamped onto the photo as evidence."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index d1d9285..5cf9f37 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -134,7 +134,20 @@ actor APIClient { // ── Photo Upload ────────────────────────────────────────────────────── - func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String { + /// Upload a photo and return its server path. + /// + /// `capturedAt` / `latitude` / `longitude` drive the timestamp + GPS overlay + /// the server burns into the image. They are optional on the wire, but this + /// app must send them: photos are re-encoded on save (jpegData), which + /// strips EXIF, so the server has no other way to learn the true capture + /// moment — it would fall back to upload time, which is wrong for anything + /// captured offline. See Utils/PhotoCapture.swift. + func uploadPhoto(localPath: String, + entityType: String, + capturedAt: Date? = nil, + latitude: Double? = nil, + longitude: Double? = nil, + retrying: Bool = false) async throws -> String { let url = try buildURL("/api/v1/photos/upload") guard let imageData = FileManager.default.contents(atPath: localPath) else { @@ -148,6 +161,9 @@ actor APIClient { var body = Data() body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\n\(entityType)\r\n".data(using: .utf8)!) + body.append(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary)) + body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary)) + body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary)) 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)!) @@ -162,16 +178,38 @@ actor APIClient { if shouldRefresh(response, retrying: retrying) { let refreshed = await refreshAccessToken() - if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType, retrying: true) } + if refreshed { + return try await uploadPhoto(localPath: localPath, entityType: entityType, + capturedAt: capturedAt, latitude: latitude, + longitude: longitude, retrying: true) + } throw APIError.notAuthenticated } - struct PhotoResult: Decodable, Sendable { let serverPath: String } + // `stamped` / `capturedAt` / `captureSource` are also returned; decoded + // as optionals so older servers (which omit them) still parse. + struct PhotoResult: Decodable, Sendable { + let serverPath: String + let stamped: Bool? + let captureSource: String? + } if let env = try? decoder.decode(_Envelope.self, from: data), - env.ok, let r = env.data { return r.serverPath } + env.ok, let r = env.data { + if r.stamped == false { + print("[JQC] Photo stored unstamped (source=\(r.captureSource ?? "?")): \(r.serverPath)") + } + return r.serverPath + } throw APIError.serverError("Photo upload failed") } + /// Build one multipart text field, or empty Data when the value is nil. + private static func formField(_ name: String, _ value: String?, _ boundary: String) -> Data { + guard let value else { return Data() } + return "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n" + .data(using: .utf8) ?? Data() + } + // ── Submit Inspection ───────────────────────────────────────────────── func submitInspection(_ inspection: LocalInspection) async throws -> Int { @@ -262,7 +300,11 @@ 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 { + func uploadResultPhoto(localPath: String, + capturedAt: Date? = nil, + latitude: Double? = nil, + longitude: Double? = nil, + retrying: Bool = false) async throws -> String { let url = try buildURL("/api/v1/photos/upload") guard let imageData = FileManager.default.contents(atPath: localPath) else { @@ -276,6 +318,9 @@ actor APIClient { 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(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary)) + body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary)) + body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary)) 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)!) @@ -290,7 +335,11 @@ actor APIClient { if shouldRefresh(response, retrying: retrying) { let refreshed = await refreshAccessToken() - if refreshed { return try await uploadResultPhoto(localPath: localPath, retrying: true) } + if refreshed { + return try await uploadResultPhoto(localPath: localPath, capturedAt: capturedAt, + latitude: latitude, longitude: longitude, + retrying: true) + } throw APIError.notAuthenticated } diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIClient.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIClient.swift deleted file mode 100644 index d1d9285..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIClient.swift +++ /dev/null @@ -1,566 +0,0 @@ -// API/APIClient.swift - -import Foundation -import Combine -import UIKit - -enum APIError: Error, LocalizedError, Sendable { - case invalidURL - case notAuthenticated - case serverError(String) - case decodingError(String) - case networkError(String) - - var errorDescription: String? { - switch self { - case .invalidURL: return "Invalid URL." - case .notAuthenticated: return "Session expired. Please log in again." - case .serverError(let msg): return msg - case .decodingError(let msg): return "Data error: \(msg)" - case .networkError(let msg): return "Network error: \(msg)" - } - } -} - -// File-scope envelope — cannot be nested in a generic function (Swift restriction). -// T is constrained to Sendable so `data: T?` does not inherit @MainActor isolation. -private struct _Envelope: Decodable, Sendable { - let ok: Bool - let data: T? - let error: String? - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - ok = try c.decode(Bool.self, forKey: .ok) - data = try? c.decode(T.self, forKey: .data) - error = try? c.decode(String.self, forKey: .error) - } - private enum CodingKeys: String, CodingKey { case ok, data, error } -} - -// Free function removed — see refreshAccessToken() which decodes using a -// local JSONDecoder to avoid Swift 6 actor-isolation errors. - -// Refresh-only envelope — Sendable so it can cross actor boundaries in Swift 6. -// nonisolated init(from:) required on both types: without it the Swift 6 compiler -// infers @MainActor isolation on the Decodable conformance from the surrounding -// file context, producing "cannot be used in actor-isolated context" errors. -private struct _RefreshEnvelope: Decodable, Sendable { - struct Tokens: Decodable, Sendable { - let accessToken: String - let refreshToken: String - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - accessToken = try c.decode(String.self, forKey: .accessToken) - refreshToken = try c.decode(String.self, forKey: .refreshToken) - } - private enum CodingKeys: String, CodingKey { case accessToken, refreshToken } - } - let ok: Bool - let data: Tokens? - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - ok = try c.decode(Bool.self, forKey: .ok) - data = try? c.decode(Tokens.self, forKey: .data) - } - private enum CodingKeys: String, CodingKey { case ok, data } -} - -actor APIClient { - static let shared = APIClient() - - private let session: URLSession - private let decoder: JSONDecoder - - private init() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - self.session = URLSession(configuration: config) - self.decoder = JSONDecoder() - self.decoder.keyDecodingStrategy = .convertFromSnakeCase - } - - // ── Generic JSON Request ────────────────────────────────────────────── - - func request( - _ endpoint: String, - method: String = "GET", - body: [String: Any]? = nil, - retrying: Bool = false - ) async throws -> T { - let url = try buildURL(endpoint) - var req = buildRequest(url: url, method: method, body: body) - injectToken(&req) - - let (data, response) = try await performRequest(req) - - if shouldRefresh(response, retrying: retrying) { - let refreshed = await refreshAccessToken() - if refreshed { - return try await request(endpoint, method: method, body: body, retrying: true) - } - throw APIError.notAuthenticated - } - - return try decode(data) - } - - func post(_ endpoint: String, body: [String: Any]) async throws -> T { - return try await request(endpoint, method: "POST", body: body) - } - - // ── Inspection History ──────────────────────────────────────────────── - - func fetchInspectionHistory( - limit: Int = 50, - offset: Int = 0, - facilityId: Int? = nil, - fromDate: Date? = nil, - toDate: Date? = nil - ) async throws -> InspectionHistoryResponseData { - var ep = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed" - if let fid = facilityId { ep += "&facility_id=\(fid)" } - if let d = fromDate { ep += "&from_date=\(Self.apiDateFmt.string(from: d))" } - if let d = toDate { ep += "&to_date=\(Self.apiDateFmt.string(from: d))" } - return try await request(ep) - } - - private static let apiDateFmt: DateFormatter = { - let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - // ── Photo Upload ────────────────────────────────────────────────────── - - func uploadPhoto(localPath: String, entityType: 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\n\(entityType)\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 uploadPhoto(localPath: localPath, entityType: entityType, 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("Photo upload failed") - } - - // ── Submit Inspection ───────────────────────────────────────────────── - - func submitInspection(_ inspection: LocalInspection) async throws -> Int { - // Sanitise form_data: replace any field values that are still a local - // file reference ("local://...") with an empty string. This happens - // when a photo upload failed but the inspection was submitted anyway. - // JSONSerialization silently drops non-serialisable values, so without - // this guard the server would receive a dict missing those fields - // entirely — worse than receiving an empty string. - var sanitisedFormData: [String: Any] = [:] - for (k, v) in inspection.formData { - if let s = v as? String, s.hasPrefix("local://") { - sanitisedFormData[k] = "" // upload failed; clear the field - } else { - sanitisedFormData[k] = v - } - } - - var body: [String: Any] = [ - "template_id": inspection.templateServerId, - "facility_id": inspection.facilityServerId, - "status": "completed", - "form_data": sanitisedFormData, - "mobile_local_id": inspection.localId, - ] - if let score = inspection.overallScore { body["overall_score"] = score } - if let areaId = inspection.areaServerId { body["area_id"] = areaId } - if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId } - if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes } - if let lat = inspection.submitLatitude { body["submit_latitude"] = lat } - if let lng = inspection.submitLongitude { body["submit_longitude"] = lng } - - // IMPORTANT: timeZone must be explicitly set to UTC. - // ISO8601DateFormatter() default timeZone is the DEVICE local timezone, - // which produces offset strings like "2026-06-11T10:30:00-04:00". - // The server's _parse_datetime() only recognises the Z suffix as UTC; - // offset-format strings fail all strptime patterns and return None, - // causing the server to fall back to now_eastern() — the sync time — - // instead of the actual inspection/completion time. - let fmt = ISO8601DateFormatter() - fmt.timeZone = TimeZone(identifier: "UTC")! - body["inspection_date"] = fmt.string(from: inspection.inspectionDate) - if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) } - - struct R: Decodable, Sendable { let inspectionId: Int; let duplicate: Bool } - let r: R = try await post("/api/v1/inspections", body: body) - return r.inspectionId - } - - // ── Submit Issue ────────────────────────────────────────────────────── - - func submitIssue(_ issue: LocalIssue, inspectionServerId: Int?) async throws -> Int { - var body: [String: Any] = [ - "facility_id": issue.facilityServerId, - "severity": issue.severity, - "description": issue.issueDescription, - "mobile_local_id": issue.localId, - ] - // Use the explicitly passed serverId rather than issue.inspection?.serverId. - // The ORM relationship object is a separate fetch instance from the one - // processInspectionQueue updated, so its serverId is nil even after the - // inspection synced in the same triggerSync() pass. - if let id = inspectionServerId { body["inspection_id"] = id } - if let areaId = issue.areaServerId { body["area_id"] = areaId } - // photo_path = primary photo. Additional photos are sent via a - // separate PATCH call in processIssueQueue after the issue is created, - // because the server create endpoint only stores a single photo_path. - if let first = issue.photoServerPaths.first { body["photo_path"] = first } - - struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool } - let r: R = try await post("/api/v1/issues", body: body) - return r.issueId - } - - // ── Attach additional photos to an existing issue ───────────────────── - // Called after submitIssue when the issue has more than one photo. - // PATCHes /api/v1/issues/{id}/photos with result_photos = [server paths beyond the first]. - // The create endpoint only stores photo_path (single); extras go here. - func updateIssuePhotos(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)/photos", - method: "PATCH", - body: ["result_photos": resultPhotos] - ) - } - - // ── 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 { - return try await request("/api/v1/issues/\(issueId)") - } - - // ── Update Issue Status ─────────────────────────────────────────────── - - func updateIssueStatus(issueId: Int, status: String) async throws -> String { - let result: APIIssueStatusUpdate = try await request( - "/api/v1/issues/\(issueId)/status", - method: "PATCH", - body: ["status": status] - ) - return result.status - } - - // ── Notification polling (Phase C) ──────────────────────────────────── - - // Shared formatter for the ?since= query parameter. - // DateFormatter init is expensive — creating one per fetchNotifications() - // call (every 60 seconds) adds unnecessary allocations on the sync cycle. - private static let notifSinceFmt: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - return f - }() - - /// Fetch notifications, optionally scoped to those created after `since`. - func fetchNotifications(since: Date? = nil) async throws -> [APINotification] { - var ep = "/api/v1/notifications" - if let since { - ep += "?since=\(Self.notifSinceFmt.string(from: since))" - } - let result: APINotificationsResponseData = try await request(ep) - return result.notifications - } - - /// Mark the given notification IDs as read on the server. - func markNotificationsRead(ids: [Int]) async throws { - guard !ids.isEmpty else { return } - let _: APIMarkReadResponseData = try await request( - "/api/v1/notifications/mark-read", - method: "PATCH", - body: ["ids": ids] - ) - } - - /// Fetch issues assigned to the current user from the server. - func fetchAssignedIssues() async throws -> [APIAssignedIssue] { - let result: APIAssignedIssuesResponseData = try await request("/api/v1/issues") - return result.issues - } - - // ── Scheduled Inspections (phase36) ─────────────────────────────────── - - func fetchScheduledInspections() async throws -> [APIScheduledInspection] { - let result: APIScheduledInspectionsResponseData = - try await request("/api/v1/scheduled-inspections") - return result.scheduled - } - - // ── Issue Handler ("Handled By") ────────────────────────────────────── - - /// Set who handles an issue. `details` carries any of the optional - /// facility_handler_* / vendor_* fields; only keys present are updated. - func updateIssueHandler( - issueId: Int, - handlerType: String, - details: [String: String] = [:] - ) async throws -> String { - var body: [String: Any] = ["handler_type": handlerType] - for (k, v) in details { body[k] = v } - let result: APIIssueHandlerUpdate = try await request( - "/api/v1/issues/\(issueId)/handler", - method: "PATCH", - body: body - ) - return result.handlerType - } - - /// Fetch dashboard KPI counts for the current user (Phase B). - func fetchDashboardStats() async throws -> APIDashboardStats { - return try await request("/api/v1/stats/dashboard") - } - - // ── Device Registration ─────────────────────────────────────────────── - // Called on every app foreground (active scenePhase) when authenticated. - // Upserts a device_registrations row on the server so the admin can see - // all installed devices and their versions. - // Errors are suppressed — device registration is best-effort and must - // never block the normal app launch flow. - - /// Returns or creates a stable device UUID, persisted in Keychain so it - /// survives app restarts but is unique per physical device. - nonisolated static func stableDeviceId() -> String { - if let existing = KeychainHelper.get(Constants.Keychain.deviceId) { - return existing - } - let new = UUID().uuidString - KeychainHelper.set(new, forKey: Constants.Keychain.deviceId) - return new - } - - func registerDevice() async { - guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else { return } - - let deviceId = Self.stableDeviceId() - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" - // UIDevice.current is @MainActor — read on MainActor then pass as plain Strings - let (deviceName, iosVersion): (String, String) = await MainActor.run { - (UIDevice.current.name, UIDevice.current.systemVersion) - } - - let body: [String: Any] = [ - "device_id": deviceId, - "device_name": deviceName, - "app_version": appVersion, - "ios_version": iosVersion, - ] - - do { - // nonisolated init required — SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor - // taints synthesised Decodable inits (CLAUDE.md rule 29). - struct R: Decodable, Sendable { - let registered: Bool - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - registered = try c.decode(Bool.self, forKey: .registered) - } - private enum CodingKeys: String, CodingKey { case registered } - } - let _: R = try await request("/api/v1/devices/register", method: "POST", body: body) - print("[JQC] registerDevice succeeded") - } catch { - // Log raw response to diagnose server-side failures - if let url = URL(string: ServerConfig.current + "/api/v1/devices/register"), - let token = KeychainHelper.get(Constants.Keychain.accessToken) { - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - req.httpBody = try? JSONSerialization.data(withJSONObject: body) - if let (data, resp) = try? await URLSession.shared.data(for: req) { - let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 - let raw = String(data: data, encoding: .utf8) ?? "" - print("[JQC] registerDevice HTTP \(status): \(raw)") - } - } - print("[JQC] registerDevice error: \(error)") - } - } - - // ── Issue Comments (Phase D) ────────────────────────────────────────── - - /// Fetch all comments for an issue, oldest-first. - func fetchIssueComments(issueId: Int) async throws -> [APIIssueComment] { - let result: APIIssueCommentsResponseData = try await request( - "/api/v1/issues/\(issueId)/comments" - ) - return result.comments - } - - /// Post a new comment on an issue. Returns the new comment ID. - func postIssueComment(issueId: Int, body: String) async throws -> Int { - let result: APIAddCommentResponseData = try await request( - "/api/v1/issues/\(issueId)/comments", - method: "POST", - body: ["body": body] - ) - return result.commentId - } - - // ── Token Refresh ───────────────────────────────────────────────────── - - private func refreshAccessToken() async -> Bool { - guard let token = KeychainHelper.get(Constants.Keychain.refreshToken), - let url = URL(string: ServerConfig.current + "/api/v1/auth/refresh") - else { return false } - - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try? JSONSerialization.data(withJSONObject: ["refresh_token": token]) - - guard let (data, response) = try? await session.data(for: req), - let http = response as? HTTPURLResponse, http.statusCode == 200 - else { return false } - - // Use a local decoder — avoids referencing the actor-isolated self.decoder - // which would trigger a Swift 6 main-actor isolation error. - let localDecoder = JSONDecoder() - localDecoder.keyDecodingStrategy = .convertFromSnakeCase - guard let env = try? localDecoder.decode(_RefreshEnvelope.self, from: data), - env.ok, - let tokens = env.data - else { return false } - - KeychainHelper.set(tokens.accessToken, forKey: Constants.Keychain.accessToken) - KeychainHelper.set(tokens.refreshToken, forKey: Constants.Keychain.refreshToken) - return true - } - - // ── Private Helpers ─────────────────────────────────────────────────── - - private func buildURL(_ endpoint: String) throws -> URL { - guard let url = URL(string: ServerConfig.current + endpoint) else { - throw APIError.invalidURL - } - return url - } - - private func buildRequest(url: URL, method: String, body: [String: Any]?) -> URLRequest { - var req = URLRequest(url: url) - req.httpMethod = method - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - if let body { req.httpBody = try? JSONSerialization.data(withJSONObject: body) } - return req - } - - private func injectToken(_ req: inout URLRequest) { - if let token = KeychainHelper.get(Constants.Keychain.accessToken) { - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - } - - private func performRequest(_ req: URLRequest) async throws -> (Data, URLResponse) { - do { - return try await session.data(for: req) - } catch { - throw APIError.networkError(error.localizedDescription) - } - } - - private func shouldRefresh(_ response: URLResponse, retrying: Bool) -> Bool { - guard !retrying, let http = response as? HTTPURLResponse else { return false } - return http.statusCode == 401 - } - - private func decode(_ data: Data) throws -> T { - if let env = try? decoder.decode(_Envelope.self, from: data) { - if env.ok, let result = env.data { return result } - throw APIError.serverError(env.error ?? "Unknown server error") - } - do { - return try decoder.decode(T.self, from: data) - } catch { - throw APIError.decodingError(error.localizedDescription) - } - } -} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIModels.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIModels.swift deleted file mode 100644 index e2c954a..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/API/APIModels.swift +++ /dev/null @@ -1,767 +0,0 @@ -// API/APIModels.swift -// ------------------- -// All types in this file are pure value types with no actor isolation. -// AnyDecodable uses a JSONValue enum (not `Any`) so it is fully Sendable -// without @unchecked and causes no actor-isolation warnings. - -import Foundation - -// ── JSONValue — replaces AnyDecodable ──────────────────────────────────────── -// -// Using `Any` as a stored property is not Sendable, which causes the Swift -// compiler to defensively infer @MainActor on any struct containing it. -// A typed enum avoids this entirely: every case is a concrete Sendable type. - -enum JSONValue: Decodable, Sendable { - case string(String) - case int(Int) - case double(Double) - case bool(Bool) - case array([JSONValue]) - case object([String: JSONValue]) - case null - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.singleValueContainer() - // Order matters: Bool before Int (Bool is Int-decodable on some platforms) - if let v = try? c.decode(Bool.self) { self = .bool(v); return } - if let v = try? c.decode(Int.self) { self = .int(v); return } - if let v = try? c.decode(Double.self) { self = .double(v); return } - if let v = try? c.decode(String.self) { self = .string(v); return } - if let v = try? c.decode([JSONValue].self) { self = .array(v); return } - if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } - self = .null - } - - // Convert to Any for compatibility with existing formData/formSchema code - var anyValue: Any { - switch self { - case .string(let v): return v - case .int(let v): return v - case .double(let v): return v - case .bool(let v): return v - case .null: return NSNull() - case .array(let v): return v.map(\.anyValue) - case .object(let v): return v.mapValues(\.anyValue) - } - } -} - -// Typealias so existing code that references AnyDecodable still compiles -typealias AnyDecodable = JSONValue - -// ── API Envelope ────────────────────────────────────────────────────────────── - -struct APIResponse: Decodable, Sendable { - let ok: Bool - let data: T? - let error: String? - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - ok = try c.decode(Bool.self, forKey: .ok) - data = try? c.decode(T.self, forKey: .data) - error = try? c.decode(String.self, forKey: .error) - } - private enum CodingKeys: String, CodingKey { case ok, data, error } -} - -// ── Auth ────────────────────────────────────────────────────────────────────── - -struct APIUser: Decodable, Sendable { - let id: Int - let username: String - let fullName: String // empty string when not set; never nil - let email: String - let role: String - let createdAt: String? - /// Returns full_name when set, otherwise falls back to username. - /// Mirrors User.display_name on the server. - var displayName: String { fullName.isEmpty ? username : fullName } - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - username = try c.decode(String.self, forKey: .username) - fullName = (try? c.decode(String.self, forKey: .fullName)) ?? "" - email = try c.decode(String.self, forKey: .email) - role = try c.decode(String.self, forKey: .role) - createdAt = try? c.decode(String.self, forKey: .createdAt) - } - private enum CodingKeys: String, CodingKey { - case id, username, fullName, email, role, createdAt - } -} - -struct LoginResponseData: Decodable, Sendable { - let accessToken: String - let refreshToken: String - let tokenType: String - let expiresIn: Int - let user: APIUser - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - accessToken = try c.decode(String.self, forKey: .accessToken) - refreshToken = try c.decode(String.self, forKey: .refreshToken) - tokenType = try c.decode(String.self, forKey: .tokenType) - expiresIn = try c.decode(Int.self, forKey: .expiresIn) - user = try c.decode(APIUser.self, forKey: .user) - } - private enum CodingKeys: String, CodingKey { - case accessToken, refreshToken, tokenType, expiresIn, user - } -} - -struct RefreshResponseData: Decodable, Sendable { - let accessToken: String - let refreshToken: String - let tokenType: String - let expiresIn: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - accessToken = try c.decode(String.self, forKey: .accessToken) - refreshToken = try c.decode(String.self, forKey: .refreshToken) - tokenType = try c.decode(String.self, forKey: .tokenType) - expiresIn = try c.decode(Int.self, forKey: .expiresIn) - } - private enum CodingKeys: String, CodingKey { - case accessToken, refreshToken, tokenType, expiresIn - } -} - -struct MeResponseData: Decodable, Sendable { - let user: APIUser - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - user = try c.decode(APIUser.self, forKey: .user) - } - private enum CodingKeys: String, CodingKey { case user } -} - -// ── Facilities ──────────────────────────────────────────────────────────────── - -struct APIFacility: Decodable, Identifiable, Sendable { - let id: Int - let name: String - let address: String - let contactPerson: String - let contactPhone: String - let projectId: Int? - let projectName: String? - let isActive: Bool - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - name = try c.decode(String.self, forKey: .name) - address = try c.decode(String.self, forKey: .address) - contactPerson = try c.decode(String.self, forKey: .contactPerson) - contactPhone = try c.decode(String.self, forKey: .contactPhone) - projectId = try? c.decode(Int.self, forKey: .projectId) - projectName = try? c.decode(String.self, forKey: .projectName) - isActive = try c.decode(Bool.self, forKey: .isActive) - } - private enum CodingKeys: String, CodingKey { - case id, name, address, contactPerson, contactPhone - case projectId, projectName, isActive - } -} - -struct FacilitiesResponseData: Decodable, Sendable { - let facilities: [APIFacility] - let count: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - facilities = try c.decode([APIFacility].self, forKey: .facilities) - count = try c.decode(Int.self, forKey: .count) - } - private enum CodingKeys: String, CodingKey { case facilities, count } -} - -struct APIArea: Decodable, Identifiable, Sendable { - let id: Int - let facilityId: Int - let name: String - let areaType: String - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - facilityId = try c.decode(Int.self, forKey: .facilityId) - name = try c.decode(String.self, forKey: .name) - areaType = try c.decode(String.self, forKey: .areaType) - } - private enum CodingKeys: String, CodingKey { case id, facilityId, name, areaType } -} - -struct AreasResponseData: Decodable, Sendable { - let facilityId: Int - let areas: [APIArea] - let count: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - facilityId = try c.decode(Int.self, forKey: .facilityId) - areas = try c.decode([APIArea].self, forKey: .areas) - count = try c.decode(Int.self, forKey: .count) - } - private enum CodingKeys: String, CodingKey { case facilityId, areas, count } -} - -// ── Templates ───────────────────────────────────────────────────────────────── - -struct APITemplateSummary: Decodable, Identifiable, Sendable { - let id: Int - let name: String - let description: String - let frequency: String - let isActive: Bool - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - name = try c.decode(String.self, forKey: .name) - description = try c.decode(String.self, forKey: .description) - frequency = try c.decode(String.self, forKey: .frequency) - // Default true — backwards compatible if server omits the field - isActive = (try? c.decode(Bool.self, forKey: .isActive)) ?? true - } - private enum CodingKeys: String, CodingKey { case id, name, description, frequency, isActive } -} - -struct TemplatesResponseData: Decodable, Sendable { - let templates: [APITemplateSummary] - let count: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - templates = try c.decode([APITemplateSummary].self, forKey: .templates) - count = try c.decode(Int.self, forKey: .count) - } - private enum CodingKeys: String, CodingKey { case templates, count } -} - -struct APITemplate: Decodable, Identifiable, Sendable { - let id: Int - let name: String - let description: String - let frequency: String - // Use [[String: JSONValue]] instead of [[String: AnyDecodable]] — fully Sendable - let formSchema: [[String: JSONValue]] - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - name = try c.decode(String.self, forKey: .name) - description = try c.decode(String.self, forKey: .description) - frequency = try c.decode(String.self, forKey: .frequency) - formSchema = try c.decode([[String: JSONValue]].self, forKey: .formSchema) - } - private enum CodingKeys: String, CodingKey { - case id, name, description, frequency, formSchema - } -} - -struct TemplateDetailResponseData: Decodable, Sendable { - let template: APITemplate - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - template = try c.decode(APITemplate.self, forKey: .template) - } - private enum CodingKeys: String, CodingKey { case template } -} - -// ── Inspections ─────────────────────────────────────────────────────────────── - -struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable { - let id: Int - let templateId: Int - let templateName: String - let facilityId: Int - let facilityName: String - let areaId: Int? - let areaName: String? - let status: String - let overallScore: Double? - let inspectionDate: String? - let completedAt: String? - let mobileLocalId: String? - let inspectorNotes: String - // Form responses and schema — included in every history response so the - // detail view works without a local SwiftData copy (e.g. after reinstall). - let formDataRaw: [String: JSONValue] - let formSchemaRaw: [[String: JSONValue]] - // ── Follow-up / re-inspection ───────────────────────────────────────── - let followUpRequired: Bool - let followUpNote: String? - let parentInspectionId: Int? - - /// Form field values as [fieldId: stringValue] for the grid renderer. - var formValues: [String: String] { - var result: [String: String] = [:] - for (k, v) in formDataRaw { - switch v { - case .string(let s): result[k] = s - case .int(let n): result[k] = String(n) - case .double(let d): result[k] = String(d) - case .bool(let b): result[k] = b ? "true" : "false" - case .array(let a): result[k] = a.map { "\($0.anyValue)" }.joined(separator: ", ") - case .null: result[k] = "" - case .object: result[k] = "" - } - } - return result - } - - /// Form schema as [[String: Any]] for ReadOnlyGridFormView. - var formSchema: [[String: Any]] { - formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } } - } - - var inspectionDateParsed: Date? { - guard let str = inspectionDate else { return nil } - // Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix. - // ISO8601DateFormatter() requires a timezone by default and returns nil - // for timezone-less strings — use the shared DateFormatter instead. - return SyncManager.isoFormatter.date(from: str) - } - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - templateId = try c.decode(Int.self, forKey: .templateId) - templateName = try c.decode(String.self, forKey: .templateName) - facilityId = try c.decode(Int.self, forKey: .facilityId) - facilityName = try c.decode(String.self, forKey: .facilityName) - areaId = try? c.decode(Int.self, forKey: .areaId) - areaName = try? c.decode(String.self, forKey: .areaName) - status = try c.decode(String.self, forKey: .status) - overallScore = try? c.decode(Double.self, forKey: .overallScore) - inspectionDate = try? c.decode(String.self, forKey: .inspectionDate) - completedAt = try? c.decode(String.self, forKey: .completedAt) - mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) - inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? "" - formDataRaw = (try? c.decode([String: JSONValue].self, forKey: .formData)) ?? [:] - formSchemaRaw = (try? c.decode([[String: JSONValue]].self, forKey: .formSchema)) ?? [] - followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false - followUpNote = try? c.decode(String.self, forKey: .followUpNote) - parentInspectionId = try? c.decode(Int.self, forKey: .parentInspectionId) - } - private enum CodingKeys: String, CodingKey { - case id, templateId, templateName, facilityId, facilityName - case areaId, areaName, status, overallScore - case inspectionDate, completedAt, mobileLocalId, inspectorNotes - case formData, formSchema - case followUpRequired, followUpNote, parentInspectionId - } - - // Explicit Hashable — formDataRaw/formSchemaRaw contain JSONValue which - // has no Hashable conformance; identity is determined by server id alone. - static func == (lhs: APIInspectionSummary, rhs: APIInspectionSummary) -> Bool { - lhs.id == rhs.id - } - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - -struct InspectionHistoryResponseData: Decodable, Sendable { - let inspections: [APIInspectionSummary] - let total: Int - let limit: Int - let offset: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - inspections = try c.decode([APIInspectionSummary].self, forKey: .inspections) - total = try c.decode(Int.self, forKey: .total) - limit = try c.decode(Int.self, forKey: .limit) - offset = try c.decode(Int.self, forKey: .offset) - } - private enum CodingKeys: String, CodingKey { case inspections, total, limit, offset } -} - -// ── Issue Detail ────────────────────────────────────────────────────────────── - -struct APIIssueDetail: Decodable, Sendable { - let id: Int - let status: String - let severity: String - let description: String - let assignedTo: Int? - let facilityId: Int? - let facilityName: String? - let reportedAt: String? - let resolvedAt: String? - // Phase A — resolution details from web - let resultNotes: String? - 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? - // Handler ("Handled By", phase35) - let handlerType: String? - let handlerLabel: String? - let facilityHandlerName: String? - let facilityHandlerContact: String? - let facilityHandlerNotes: String? - let vendorName: String? - let vendorContact: String? - let vendorNotes: String? - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - status = try c.decode(String.self, forKey: .status) - severity = try c.decode(String.self, forKey: .severity) - description = try c.decode(String.self, forKey: .description) - assignedTo = try? c.decode(Int.self, forKey: .assignedTo) - facilityId = try? c.decode(Int.self, forKey: .facilityId) - facilityName = try? c.decode(String.self, forKey: .facilityName) - reportedAt = try? c.decode(String.self, forKey: .reportedAt) - resolvedAt = try? c.decode(String.self, forKey: .resolvedAt) - resultNotes = try? c.decode(String.self, forKey: .resultNotes) - 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) - handlerType = try? c.decode(String.self, forKey: .handlerType) - handlerLabel = try? c.decode(String.self, forKey: .handlerLabel) - facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName) - facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact) - facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes) - vendorName = try? c.decode(String.self, forKey: .vendorName) - vendorContact = try? c.decode(String.self, forKey: .vendorContact) - vendorNotes = try? c.decode(String.self, forKey: .vendorNotes) - } - private enum CodingKeys: String, CodingKey { - case id, status, severity, description, assignedTo - case facilityId, facilityName, reportedAt, resolvedAt - case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos - case areaName, assignedToName - case handlerType, handlerLabel - case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes - case vendorName, vendorContact, vendorNotes - } -} - -struct APIIssueStatusUpdate: Decodable, Sendable { - let issueId: Int - let status: String - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - issueId = try c.decode(Int.self, forKey: .issueId) - status = try c.decode(String.self, forKey: .status) - } - private enum CodingKeys: String, CodingKey { case issueId, status } -} - -struct APIIssueHandlerUpdate: Decodable, Sendable { - let issueId: Int - let handlerType: String - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - issueId = try c.decode(Int.self, forKey: .issueId) - handlerType = try c.decode(String.self, forKey: .handlerType) - } - private enum CodingKeys: String, CodingKey { case issueId, handlerType } -} - -// ── Notification polling (Phase C) ──────────────────────────────────────────── - -struct APINotification: Decodable, Identifiable, Sendable { - let id: Int - let title: String - let body: String - let eventType: String? - let issueId: Int? - let createdAt: String - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - title = try c.decode(String.self, forKey: .title) - body = try c.decode(String.self, forKey: .body) - eventType = try? c.decode(String.self, forKey: .eventType) - issueId = try? c.decode(Int.self, forKey: .issueId) - createdAt = try c.decode(String.self, forKey: .createdAt) - } - private enum CodingKeys: String, CodingKey { - case id, title, body, eventType, issueId, createdAt - } -} - -struct APINotificationsResponseData: Decodable, Sendable { - let notifications: [APINotification] - let count: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - notifications = try c.decode([APINotification].self, forKey: .notifications) - count = try c.decode(Int.self, forKey: .count) - } - private enum CodingKeys: String, CodingKey { case notifications, count } -} - -struct APIMarkReadResponseData: Decodable, Sendable { - let marked: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - marked = try c.decode(Int.self, forKey: .marked) - } - private enum CodingKeys: String, CodingKey { case marked } -} - -// ── Assigned issues list (Phase C) ──────────────────────────────────────────── - -struct APIAssignedIssue: Decodable, Identifiable, Sendable { - let id: Int - let status: String - let severity: String - let description: String - let assignedTo: Int? - let facilityId: Int? - let facilityName: String? - let reportedAt: String? - let mobileLocalId: String? - let photoPath: String? // primary evidence photo - let mobilePhotoPaths: [String] // extra evidence photos from iPad - let resultPhotos: [String] // resolution photos added via web - // Phase A — resolution details from web - let resultNotes: String? - let verifiedAt: String? - let verificationNote: String? - let reportedByName: String? - // Phase E — area and assignee context - let areaName: String? - let assignedToName: String? - // Handler ("Handled By", phase35) — who resolves the issue - let handlerType: String? // "internal" | "facility" | "vendor" - let handlerLabel: String? // human-readable label - let facilityHandlerName: String? - let facilityHandlerContact: String? - let facilityHandlerNotes: String? - let vendorName: String? - let vendorContact: String? - let vendorNotes: String? - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - status = try c.decode(String.self, forKey: .status) - severity = try c.decode(String.self, forKey: .severity) - description = try c.decode(String.self, forKey: .description) - assignedTo = try? c.decode(Int.self, forKey: .assignedTo) - facilityId = try? c.decode(Int.self, forKey: .facilityId) - facilityName = try? c.decode(String.self, forKey: .facilityName) - reportedAt = try? c.decode(String.self, forKey: .reportedAt) - mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId) - photoPath = try? c.decode(String.self, forKey: .photoPath) - mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? [] - resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] - resultNotes = try? c.decode(String.self, forKey: .resultNotes) - verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) - verificationNote = try? c.decode(String.self, forKey: .verificationNote) - reportedByName = try? c.decode(String.self, forKey: .reportedByName) - areaName = try? c.decode(String.self, forKey: .areaName) - assignedToName = try? c.decode(String.self, forKey: .assignedToName) - handlerType = try? c.decode(String.self, forKey: .handlerType) - handlerLabel = try? c.decode(String.self, forKey: .handlerLabel) - facilityHandlerName = try? c.decode(String.self, forKey: .facilityHandlerName) - facilityHandlerContact = try? c.decode(String.self, forKey: .facilityHandlerContact) - facilityHandlerNotes = try? c.decode(String.self, forKey: .facilityHandlerNotes) - vendorName = try? c.decode(String.self, forKey: .vendorName) - vendorContact = try? c.decode(String.self, forKey: .vendorContact) - vendorNotes = try? c.decode(String.self, forKey: .vendorNotes) - } - private enum CodingKeys: String, CodingKey { - case id, status, severity, description, assignedTo - case facilityId, facilityName, reportedAt, mobileLocalId - case photoPath, mobilePhotoPaths, resultPhotos - case resultNotes, verifiedAt, verificationNote, reportedByName - case areaName, assignedToName - case handlerType, handlerLabel - case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes - case vendorName, vendorContact, vendorNotes - } -} - -struct APIAssignedIssuesResponseData: Decodable, Sendable { - let issues: [APIAssignedIssue] - let total: Int - let limit: Int - let offset: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - issues = try c.decode([APIAssignedIssue].self, forKey: .issues) - total = try c.decode(Int.self, forKey: .total) - limit = try c.decode(Int.self, forKey: .limit) - offset = try c.decode(Int.self, forKey: .offset) - } - private enum CodingKeys: String, CodingKey { case issues, total, limit, offset } -} - -// ── Scheduled Inspections (phase36 — planned/recurring assignments) ─────────── - -struct APIScheduledInspection: Decodable, Identifiable, Sendable { - let id: Int - let facilityId: Int - let facilityName: String? - let templateId: Int - let templateName: String? - let inspectorId: Int? - let frequency: String - let frequencyLabel: String? - let nextDueDate: String? // ISO date "YYYY-MM-DD" - let isOverdue: Bool - let notes: String? - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - facilityId = try c.decode(Int.self, forKey: .facilityId) - facilityName = try? c.decode(String.self, forKey: .facilityName) - templateId = try c.decode(Int.self, forKey: .templateId) - templateName = try? c.decode(String.self, forKey: .templateName) - inspectorId = try? c.decode(Int.self, forKey: .inspectorId) - frequency = (try? c.decode(String.self, forKey: .frequency)) ?? "once" - frequencyLabel = try? c.decode(String.self, forKey: .frequencyLabel) - nextDueDate = try? c.decode(String.self, forKey: .nextDueDate) - isOverdue = (try? c.decode(Bool.self, forKey: .isOverdue)) ?? false - notes = try? c.decode(String.self, forKey: .notes) - } - private enum CodingKeys: String, CodingKey { - case id, facilityId, facilityName, templateId, templateName - case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes - } -} - -struct APIScheduledInspectionsResponseData: Decodable, Sendable { - let scheduled: [APIScheduledInspection] - let total: Int - let limit: Int - let offset: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - scheduled = try c.decode([APIScheduledInspection].self, forKey: .scheduled) - total = try c.decode(Int.self, forKey: .total) - limit = try c.decode(Int.self, forKey: .limit) - offset = try c.decode(Int.self, forKey: .offset) - } - private enum CodingKeys: String, CodingKey { case scheduled, total, limit, offset } -} - -// ── Dashboard Stats (Phase B) ───────────────────────────────────────────────── - -struct APIDashboardStats: Decodable, Sendable { - let todayInspections: Int - let completedToday: Int - let openIssues: Int - let avgScore30d: Double? - let pendingFollowups: Int - let slaBreached: Int - let slaAtRisk: Int - // Phase E — severity breakdown of open issues - let severityCritical: Int - let severityHigh: Int - let severityMedium: Int - let severityLow: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - todayInspections = (try? c.decode(Int.self, forKey: .todayInspections)) ?? 0 - completedToday = (try? c.decode(Int.self, forKey: .completedToday)) ?? 0 - openIssues = (try? c.decode(Int.self, forKey: .openIssues)) ?? 0 - avgScore30d = try? c.decode(Double.self, forKey: .avgScore30d) - pendingFollowups = (try? c.decode(Int.self, forKey: .pendingFollowups)) ?? 0 - slaBreached = (try? c.decode(Int.self, forKey: .slaBreached)) ?? 0 - slaAtRisk = (try? c.decode(Int.self, forKey: .slaAtRisk)) ?? 0 - // Decode from nested severity_breakdown dict - if let breakdown = try? c.decode([String: Int].self, forKey: .severityBreakdown) { - severityCritical = breakdown["critical"] ?? 0 - severityHigh = breakdown["high"] ?? 0 - severityMedium = breakdown["medium"] ?? 0 - severityLow = breakdown["low"] ?? 0 - } else { - severityCritical = 0 - severityHigh = 0 - severityMedium = 0 - severityLow = 0 - } - } - private enum CodingKeys: String, CodingKey { - case todayInspections, completedToday, openIssues - case avgScore30d, pendingFollowups, slaBreached, slaAtRisk - case severityBreakdown - } -} - -// ── Issue Comments (Phase D) ────────────────────────────────────────────────── - -struct APIIssueComment: Decodable, Identifiable, Sendable { - let id: Int - let issueId: Int - let authorName: String - let authorRole: String - let statusAtTime: String - let body: String - let createdAt: String - - var createdAtDate: Date? { - SyncManager.isoFormatter.date(from: createdAt) - } - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(Int.self, forKey: .id) - issueId = try c.decode(Int.self, forKey: .issueId) - authorName = (try? c.decode(String.self, forKey: .authorName)) ?? "Unknown" - authorRole = (try? c.decode(String.self, forKey: .authorRole)) ?? "" - statusAtTime = (try? c.decode(String.self, forKey: .statusAtTime)) ?? "" - body = try c.decode(String.self, forKey: .body) - createdAt = try c.decode(String.self, forKey: .createdAt) - } - private enum CodingKeys: String, CodingKey { - case id, issueId, authorName, authorRole, statusAtTime, body, createdAt - } -} - -struct APIIssueCommentsResponseData: Decodable, Sendable { - let issueId: Int - let comments: [APIIssueComment] - let count: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - issueId = try c.decode(Int.self, forKey: .issueId) - comments = try c.decode([APIIssueComment].self, forKey: .comments) - count = try c.decode(Int.self, forKey: .count) - } - private enum CodingKeys: String, CodingKey { case issueId, comments, count } -} - -struct APIAddCommentResponseData: Decodable, Sendable { - let commentId: Int - - nonisolated init(from decoder: any Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - commentId = try c.decode(Int.self, forKey: .commentId) - } - private enum CodingKeys: String, CodingKey { case commentId } -} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/JanitorialQCApp.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/JanitorialQCApp.swift deleted file mode 100644 index a0effae..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/JanitorialQCApp.swift +++ /dev/null @@ -1,182 +0,0 @@ -// JQCApp.swift -// ------------ -// App entry point. - -import SwiftUI -import SwiftData -import BackgroundTasks -import UserNotifications -import Combine - -// ── AppearanceManager ───────────────────────────────────────────────────────── -// Persists the user's preferred colour scheme to UserDefaults and exposes it -// as a @Published property so the root view can apply .preferredColorScheme. -// "system" (nil) means the app follows iOS system appearance — the default. - -enum AppearanceMode: String, CaseIterable { - case system = "system" - case light = "light" - case dark = "dark" - - var displayName: String { - switch self { - case .system: return "System" - case .light: return "Light" - case .dark: return "Dark" - } - } - - /// The SwiftUI ColorScheme value to pass to .preferredColorScheme(). - /// nil = follow the OS (system default). - var colorScheme: ColorScheme? { - switch self { - case .system: return nil - case .light: return .light - case .dark: return .dark - } - } -} - -final class AppearanceManager: ObservableObject { - static let shared = AppearanceManager() - private static let defaultsKey = "jqc.appearanceMode" - - @Published var mode: AppearanceMode { - didSet { - UserDefaults.standard.set(mode.rawValue, forKey: Self.defaultsKey) - } - } - - private init() { - let saved = UserDefaults.standard.string(forKey: Self.defaultsKey) ?? "" - mode = AppearanceMode(rawValue: saved) ?? .system - } -} - -// ── AppDelegate — runtime orientation lock ──────────────────────────────────── -// Info.plist must declare all 4 orientations so iPad multitasking is supported -// (App Store requirement). This delegate restricts the app to landscape-only -// at runtime by returning only the two landscape masks. -// Portrait is intentionally excluded: the grid-based inspection form is -// designed for landscape and does not adapt well to portrait on iPad. - -final class AppDelegate: NSObject, UIApplicationDelegate { - func application( - _ application: UIApplication, - supportedInterfaceOrientationsFor window: UIWindow? - ) -> UIInterfaceOrientationMask { - return [.landscapeLeft, .landscapeRight] - } -} - -@main -struct JanitorialQCApp: App { - - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - - @StateObject private var auth = AuthManager.shared - @StateObject private var sync = SyncManager.shared - @StateObject private var appearance = AppearanceManager.shared - - init() { - registerBackgroundTasks() - requestNotificationPermission() - // Set delegate so notifications display as banners when the app is in - // the foreground. Without this iOS silently drops them. - UNUserNotificationCenter.current().delegate = NotificationDelegate.shared - } - - var body: some Scene { - WindowGroup { - ContentView() - .environmentObject(auth) - .environmentObject(sync) - .environmentObject(appearance) - .preferredColorScheme(appearance.mode.colorScheme) - } - .modelContainer(for: [ - LocalFacility.self, - LocalArea.self, - LocalTemplate.self, - LocalInspection.self, - LocalIssue.self, - LocalScheduledInspection.self, - PendingPhoto.self, - SyncQueueEntry.self, - ], isUndoEnabled: false) { result in - switch result { - case .success(let container): - // Only set the model context here — do NOT await anything. - // Session restore and sync are triggered by ContentView.task{} - // which runs on the MainActor inside the SwiftUI lifecycle, - // guaranteeing isLoading changes are seen by the view immediately. - SyncManager.shared.modelContext = container.mainContext - case .failure(let error): - fatalError("SwiftData container failed: \(error)") - } - } - } - - // ── Local notification permission ───────────────────────────────────── - - private func requestNotificationPermission() { - UNUserNotificationCenter.current().requestAuthorization( - options: [.alert, .sound, .badge] - ) { granted, error in - if let error { print("[JQC] Notification permission error: \(error)") } - } - } - - private func registerBackgroundTasks() { - BGTaskScheduler.shared.register( - forTaskWithIdentifier: "com.jqc.sync", - using: nil - ) { task in - guard let processingTask = task as? BGProcessingTask else { - task.setTaskCompleted(success: false) - return - } - handleBackgroundSync(task: processingTask) - } - } - - private func handleBackgroundSync(task: BGProcessingTask) { - scheduleBackgroundSync() - let syncTask = Task { - await SyncManager.shared.triggerSync() - } - task.expirationHandler = { - syncTask.cancel() - } - Task { - await syncTask.value - task.setTaskCompleted(success: !syncTask.isCancelled) - } - } -} - -// ── Notification delegate ───────────────────────────────────────────────────── -// Allows local notifications to appear as banners while the app is in the -// foreground. Without this delegate iOS discards them silently. - -final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { - static let shared = NotificationDelegate() - private override init() {} - - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: - @escaping (UNNotificationPresentationOptions) -> Void - ) { - // Show banner + play sound even when the app is active in foreground. - completionHandler([.banner, .sound]) - } -} - -func scheduleBackgroundSync() { - let request = BGProcessingTaskRequest(identifier: "com.jqc.sync") - request.requiresNetworkConnectivity = true - request.requiresExternalPower = false - try? BGTaskScheduler.shared.submit(request) -} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalIssue.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalIssue.swift deleted file mode 100644 index 4c2c683..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalIssue.swift +++ /dev/null @@ -1,228 +0,0 @@ -// Models/LocalIssue.swift -// ----------------------- -// SwiftData model for issues flagged during an offline inspection. - -import Foundation -import SwiftData - -@Model -final class LocalIssue { - - @Attribute(.unique) var localId: String - var serverId: Int? - - var inspectionLocalId: String // references LocalInspection.localId - var facilityServerId: Int // facility this issue belongs to - /// Server ID of the area this issue was flagged in. Set when flagged during - /// an inspection that has an area selected. Nil for standalone issues. - var areaServerId: Int? - var severity: String // "low" | "medium" | "high" | "critical" - var issueDescription: String - var issueStatus: String = "open" // server status: "open" | "in_progress" | "resolved" | "pending_verification" - /// 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 = "[]" - /// 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 - // a new instance per access; on a list showing 50 issues each with two - // JSON-backed arrays that's 200 allocations per render pass. Static - // instances are created once and reused for the lifetime of the app. - private static let jsonDecoder = JSONDecoder() - private static let jsonEncoder = JSONEncoder() - - // Lightweight decode cache — avoids re-parsing identical JSON strings. - // SwiftData may call the getter multiple times per render pass (once for - // isEmpty, once for count, once for ForEach). Caching the last-decoded - // value by JSON string identity means the JSON parse only happens when - // the underlying data actually changes. - // @Transient tells SwiftData not to persist these — they're in-memory only. - // Lightweight decode cache — split into key+value pairs because SwiftData's - // @Transient macro does not support tuple types. Two separate @Transient - // properties per cache entry achieve the same result with no schema impact. - @Transient private var _cachedLocalKey: String = "" - @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] { - get { - if _cachedLocalKey == photoLocalPathsJSON, !_cachedLocalKey.isEmpty { - return _cachedLocalValue - } - let decoded = (try? Self.jsonDecoder.decode([String].self, - from: Data(photoLocalPathsJSON.utf8))) ?? [] - _cachedLocalKey = photoLocalPathsJSON - _cachedLocalValue = decoded - return decoded - } - set { - let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), - encoding: .utf8)) ?? "[]" - photoLocalPathsJSON = encoded - _cachedLocalKey = encoded - _cachedLocalValue = newValue - } - } - - /// Decoded server photo paths - var photoServerPaths: [String] { - get { - if _cachedServerKey == photoServerPathsJSON, !_cachedServerKey.isEmpty { - return _cachedServerValue - } - let decoded = (try? Self.jsonDecoder.decode([String].self, - from: Data(photoServerPathsJSON.utf8))) ?? [] - _cachedServerKey = photoServerPathsJSON - _cachedServerValue = decoded - return decoded - } - set { - let encoded = (try? String(data: Self.jsonEncoder.encode(newValue), - encoding: .utf8)) ?? "[]" - photoServerPathsJSON = encoded - _cachedServerKey = encoded - _cachedServerValue = newValue - } - } - - /// 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 - var syncErrorMessage: String? - - // ── Phase A additions — persisted from server response ──────────────── - // All new String?/Date? fields default to nil; SwiftData lightweight migration - // supports nil-default optional properties without a migration plan. - - /// Facility display name cached from the server response. Used when the - /// local facility reference cache has been cleared (Settings → Clear Cache). - var facilityNameCache: String? - - /// Server-side reported_at timestamp. More accurate than createdAt for - /// server-pulled issues because createdAt falls back to device time when - /// the issue was created offline. - var serverReportedAt: Date? - - /// Resolution notes added by web staff after fixing the issue. - var resultNotes: String? - - /// Timestamp when a director/admin verified the fix. - var verifiedAt: Date? - - /// Note left by the verifier. - var verificationNote: String? - - /// Display name of the user who originally reported this issue. - var reportedByName: String? - - /// Name of the area this issue was flagged in (e.g. "Main Lobby"). - /// Set from server response; nil for standalone issues without area context. - var areaNameCache: String? - - /// Display name of the user currently assigned to this issue. - /// Nil when unassigned. Updated on every pullAssignedIssues(). - var assignedToName: String? - - // ── Handler ("Handled By", phase35) ─────────────────────────────────── - // Who resolves the issue: internal (our staff) | facility (facility's own - // staff) | vendor (external contractor). Synced from the server; the - // inspector may also set it from Issue Detail. nil-default optionals → - // SwiftData lightweight migration safe. - var handlerType: String? // "internal" | "facility" | "vendor" - var handlerLabel: String? // human-readable label from server - var facilityHandlerName: String? - var facilityHandlerContact: String? - var facilityHandlerNotes: String? - var vendorName: String? - var vendorContact: String? - var vendorNotes: String? - - // Explicit inverse declared so SwiftData has an unambiguous relationship - // graph at schema-build time. Without it the relationship is implicit, - // which can cause migration warnings or incorrect cascade behaviour on some - // SwiftData versions. The deleteRule is .nullify (default) — deleting the - // parent inspection cascades via LocalInspection.localIssues; this side - // only nullifies the back-pointer. - @Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues) - var inspection: LocalInspection? - - init( - inspectionLocalId: String, - facilityServerId: Int, - severity: String, - description: String - ) { - self.localId = UUID().uuidString - self.serverId = nil - self.inspectionLocalId = inspectionLocalId - self.facilityServerId = facilityServerId - self.areaServerId = nil - self.severity = severity - self.issueDescription = description - self.issueStatus = "open" - self.photoLocalPathsJSON = "[]" - self.photoServerPathsJSON = "[]" - self.resultPhotoServerPathsJSON = "[]" - self.createdAt = Date() - self.syncStatus = "pending" - self.syncRetryCount = 0 - self.syncErrorMessage = nil - // Phase A fields — nil by default - self.facilityNameCache = nil - self.serverReportedAt = nil - self.resultNotes = nil - self.verifiedAt = nil - self.verificationNote = nil - self.reportedByName = nil - self.areaNameCache = nil - self.assignedToName = nil - // Handler fields — nil by default - self.handlerType = nil - self.handlerLabel = nil - self.facilityHandlerName = nil - self.facilityHandlerContact = nil - self.facilityHandlerNotes = nil - self.vendorName = nil - self.vendorContact = nil - self.vendorNotes = nil - } - - var severityColor: String { - switch severity { - case "critical": return "red" - case "high": return "orange" - case "medium": return "yellow" - default: return "blue" - } - } -} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalScheduledInspection.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalScheduledInspection.swift deleted file mode 100644 index 66adadc..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Models/LocalScheduledInspection.swift +++ /dev/null @@ -1,47 +0,0 @@ -// Models/LocalScheduledInspection.swift -// -------------------------------------- -// SwiftData model for planned/recurring inspection assignments (phase36). -// -// Read-only reference data pulled from the server (GET /api/v1/scheduled-inspections) -// and refreshed by SyncManager.pullScheduledInspections() — never created or -// mutated on device. Surfaced in the "Scheduled" section on the Dashboard and -// My Inspections. Tapping "Start" opens the normal new-inspection flow with the -// facility + template preselected; the schedule lifecycle (fulfil / roll-forward) -// stays server-driven. -// -// All non-optional stored properties carry explicit inline defaults so SwiftData -// lightweight migration can add the new table without a migration plan. - -import Foundation -import SwiftData - -@Model -final class LocalScheduledInspection { - - /// Server ID of the ScheduledInspection row — stable unique identity. - @Attribute(.unique) var serverId: Int = 0 - - var facilityServerId: Int = 0 - var facilityName: String = "" - var templateServerId: Int = 0 - var templateName: String = "" - var inspectorId: Int? = nil - - var frequency: String = "once" // once | daily | weekly | monthly - var frequencyLabel: String = "" // human-readable label from server - - /// Raw ISO date string "YYYY-MM-DD" from the server (display fallback). - var dueDateString: String = "" - /// Parsed due date — used for @Query sorting. Nil if the string was absent. - var nextDue: Date? = nil - - var isOverdue: Bool = false - var notes: String? = nil - - /// Last time this row was refreshed from the server pull. - var updatedAt: Date = Date() - - init(serverId: Int) { - self.serverId = serverId - } -} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Sync/SyncManager.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Sync/SyncManager.swift deleted file mode 100644 index c101099..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Sync/SyncManager.swift +++ /dev/null @@ -1,903 +0,0 @@ -// Sync/SyncManager.swift -// ---------------------- -// Manages connectivity monitoring, reference data sync (Phase A), -// the outbox queue for offline inspection/issue submission (Phase B), -// and notification polling (Phase C). - -import Foundation -import Network -import SwiftData -import SwiftUI -import Combine -import UserNotifications - -@MainActor -class SyncManager: ObservableObject { - - // ── Published State ─────────────────────────────────────────────────── - - @Published var isOnline = false - @Published var isSyncing = false - @Published var lastSyncAt: Date? - @Published var syncError: String? - @Published var pendingCount = 0 - /// Dashboard KPI stats fetched from the server. Nil until first successful fetch. - @Published var dashboardStats: APIDashboardStats? - /// Count of notifications received since last resetNotificationPoller(). - /// Incremented on each poll that returns new items; reset to 0 on logout. - @Published var unreadNotificationCount = 0 - /// The most recent batch of notifications (up to 50) for the in-app inbox. - /// Replaced entirely on each successful poll; empty until first fetch. - @Published var recentNotifications: [APINotification] = [] - - // ── Dependencies ────────────────────────────────────────────────────── - - private let monitor = NWPathMonitor() - private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor") - var modelContext: ModelContext? - - // ── Notification polling state ──────────────────────────────────────── - // Tracks the timestamp of the most recently fetched notification so each - // poll only retrieves newer records. Nil on first launch → server returns - // last 50 unread. Reset to nil on logout. - private var lastNotificationFetch: Date? - private var pollTask: Task? // replaces Timer — Task.sleep works correctly - private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds - - // ── Shared date formatters ──────────────────────────────────────────── - // DateFormatter init is expensive — allocating one per poll call or per - // issue would add measurable overhead at sync time. These are created - // once and reused across all calls. Both are nonisolated statics so they - // can be read from any context without actor-hopping. - // - // isoFormatter — parses/formats ISO 8601 strings from the server API - // e.g. "2026-05-01T14:30:00" - // notifFormatter — same format, used to advance the notification poll cursor - nonisolated static let isoFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - return f - }() - - /// Parses server date-only strings ("YYYY-MM-DD"), e.g. scheduled due dates. - nonisolated static let dateOnlyFormatter: DateFormatter = { - let f = DateFormatter() - f.locale = Locale(identifier: "en_US_POSIX") - f.dateFormat = "yyyy-MM-dd" - return f - }() - - static let shared = SyncManager() - private init() {} - - // ── Start Monitoring ────────────────────────────────────────────────── - - func startMonitoring() { - monitor.pathUpdateHandler = { [weak self] path in - Task { @MainActor [weak self] in - guard let self else { return } - let wasOffline = !self.isOnline - self.isOnline = path.status == .satisfied - if self.isOnline { - if wasOffline { - await self.triggerSync() - } - self.startPollTask() - } else { - self.stopPollTask() - } - } - } - monitor.start(queue: monitorQueue) - } - - // ── Notification poll task ──────────────────────────────────────────── - // Timer.scheduledTimer requires RunLoop.main to be ticking. When called - // from inside a Swift Concurrency Task { @MainActor } the current RunLoop - // is NOT RunLoop.main — the timer is added to a runloop that never runs, - // so it silently fires never. Task + Task.sleep has no such dependency. - - private func startPollTask() { - guard pollTask == nil else { return } // already running - pollTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 60_000_000_000) - guard !Task.isCancelled else { break } - await MainActor.run { [weak self] in - guard let self, self.isOnline, - AuthManager.shared.isAuthenticated else { return } - Task { await self.pollNotifications() } - } - } - } - } - - private func stopPollTask() { - pollTask?.cancel() - pollTask = nil - } - - /// Called on logout so the next login starts a clean fetch. - func resetNotificationPoller() { - lastNotificationFetch = nil - unreadNotificationCount = 0 - recentNotifications = [] - stopPollTask() - } - - /// Called when the app enters the background (scenePhase == .background). - /// Stops the poll loop so it doesn't accumulate suspended sleep cycles. - func suspendPolling() { - stopPollTask() - } - - /// Called when the app returns to the foreground (scenePhase == .active). - /// Restarts the poll loop and immediately syncs so stale data is refreshed - /// without waiting up to 60s for the next scheduled tick. - func resumePolling() { - guard isOnline, AuthManager.shared.isAuthenticated else { return } - startPollTask() - Task { await triggerSync() } - } - - /// Call when the user opens the NotificationsView to clear the badge. - func markNotificationsViewed() { - unreadNotificationCount = 0 - } - - // ── Notification polling ────────────────────────────────────────────── - - func pollNotifications() async { - guard isOnline, AuthManager.shared.isAuthenticated else { return } - do { - let notifications = try await APIClient.shared.fetchNotifications(since: lastNotificationFetch) - guard !notifications.isEmpty else { return } - - // Deliver a local notification for each new item - for n in notifications { - deliverLocalNotification(n) - } - - // Update in-app inbox state. - // Prepend new notifications and cap at 50 — avoids allocating two - // arrays and concatenating them on every poll (the old pattern - // `notifications + recentNotifications.prefix(50 - count)` always - // created a new array even when notifications.count >= 50). - recentNotifications.insert(contentsOf: notifications, at: 0) - if recentNotifications.count > 50 { recentNotifications = Array(recentNotifications.prefix(50)) } - unreadNotificationCount += notifications.count - - // Update the cursor to the newest notification's timestamp so the - // next poll only fetches newer items — do NOT mark notifications as - // read on the server. Read state is a deliberate user action managed - // via the web app; marking read here would cause the web badge count - // to always show zero when the iPad has polled before the user checks. - let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) } - if let newest = dates.max() { - lastNotificationFetch = newest - } - - } catch APIError.notAuthenticated { - // Token expired and refresh failed — let AuthManager handle it - } catch { - // Network errors are silent; next poll will retry - } - } - - // ── Local notification delivery ─────────────────────────────────────── - - private func deliverLocalNotification(_ n: APINotification) { - let content = UNMutableNotificationContent() - content.title = n.title - content.body = n.body - content.sound = .default - - // Use the server notification ID as the identifier so duplicate - // deliveries (if the same record is fetched twice) replace rather - // than stack. - let identifier = "jqc-notif-\(n.id)" - let request = UNNotificationRequest( - identifier: identifier, - content: content, - trigger: nil // nil = deliver immediately - ) - UNUserNotificationCenter.current().add(request) { error in - if let error { - print("[JQC] Local notification delivery failed: \(error)") - } - } - } - - // ── Full Sync ───────────────────────────────────────────────────────── - - func triggerSync() async { - // Do not sync unless authenticated — avoids 401 loops before - // restoreSession() completes on first launch. - guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return } - - // Re-entrancy guard. triggerSync() has many independent call sites - // (issue submit, inspection submit, manual "Sync Now", NWPathMonitor - // reconnect, the 60s poll timer, app-foreground). Although this class - // is @MainActor, the `await` points inside processPhotoQueue/etc. - // yield the actor, so a second triggerSync() call can interleave - // between those awaits and run concurrently with the first. - // - // Without this guard, two overlapping passes both fetch the same - // "pending" PendingPhoto records (neither has flipped uploadStatus - // yet), both upload the same local file, and each appends its own - // distinct server-generated filename to LocalIssue.photoServerPaths. - // The `!paths.contains(serverPath)` dedup check in processPhotoQueue - // never catches this because the two server paths are different - // strings for the same photo content — producing duplicated photos - // in the issue's evidence (and therefore in the exported PDF). - // - // Guarding re-entrancy here closes the race at its source rather - // than trying to dedupe by content downstream. - guard !isSyncing else { return } - - isSyncing = true - syncError = nil - defer { isSyncing = false } - - await processPhotoQueue(context: context) - await processInspectionQueue(context: context) - await processIssueQueue(context: context) - await pullReferenceData() - await pullAssignedIssues(context: context) - await pullScheduledInspections(context: context) - - // Poll notifications immediately on every sync rather than waiting - // for the 60-second timer — ensures the inspector sees assignments - // and follow-up requests as soon as the app goes online. - await pollNotifications() - - // Fetch dashboard KPIs — best-effort, non-fatal on failure. - await fetchDashboardStats() - - // Periodically remove local photo files that are no longer needed. - // Runs at most once per hour to avoid repeated FileManager calls on - // every 60-second sync cycle. - cleanupOrphanedPhotos(context: context) - - updatePendingCount(context: context) - lastSyncAt = Date() - } - - // ── Outbox: Photos ──────────────────────────────────────────────────── - - private func processPhotoQueue(context: ModelContext) async { - // Fetch all then filter in Swift — #Predicate cannot reference - // string literals against PendingPhoto.uploadStatus reliably - // when the predicate type is inferred across model boundaries. - guard let allPhotos = try? context.fetch(FetchDescriptor()) else { return } - var pending = allPhotos - .filter { $0.uploadStatus == "pending" } - .sorted { $0.createdAt < $1.createdAt } - - // Defense-in-depth: if two PendingPhoto rows somehow reference the - // exact same local file (e.g. a future call site re-submitting the - // same photo array), only upload it once. The primary fix for photo - // duplication is the re-entrancy guard in triggerSync(), but this - // keeps processPhotoQueue itself safe even if it's ever invoked - // outside that guard. - var seenPaths = Set() - var duplicates: [PendingPhoto] = [] - pending = pending.filter { photo in - if seenPaths.contains(photo.localFilePath) { - duplicates.append(photo) - return false - } - seenPaths.insert(photo.localFilePath) - return true - } - for dup in duplicates { - // Mark the duplicate row as uploaded without re-uploading — the - // first row for this file will populate serverPath/photoServerPaths. - dup.uploadStatus = "uploaded" - } - - // Pre-fetch parent records ONCE before the loop. - // Without this, every successful photo upload fetched ALL LocalInspection - // and ALL LocalIssue records from SwiftData to find the parent — - // N photos → 2N full-table fetches. Pre-fetching here reduces that - // to 2 fetches regardless of how many photos are in the queue. - // Fetch-all + filter in Swift — #Predicate with a captured String variable - // causes "LocalInspection is ambiguous" under Xcode 26 (CLAUDE.md rule 3). - let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] - let allIssues = (try? context.fetch(FetchDescriptor())) ?? [] - - for photo in pending { - do { - let serverPath = try await APIClient.shared.uploadPhoto( - localPath: photo.localFilePath, - entityType: photo.entityType - ) - photo.serverPath = serverPath - photo.uploadStatus = "uploaded" - - // Update parent inspection form field value. - // Pre-fetched before the loop — not repeated per photo. - if photo.entityType == "inspection", let fieldId = photo.fieldId { - let entityId = photo.entityLocalId - allInspections.first(where: { $0.localId == entityId })? - .setValue(serverPath, forFieldId: fieldId) - } - - // Update parent issue photo paths array. - // Pre-fetched before the loop — not repeated per photo. - if photo.entityType == "issue" { - let entityId = photo.entityLocalId - 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() - - } catch { - photo.uploadStatus = "failed" - try? context.save() - } - } - } - - // ── Outbox: Inspections ─────────────────────────────────────────────── - - private func processInspectionQueue(context: ModelContext) async { - guard let all = try? context.fetch(FetchDescriptor()) else { return } - let pending = all - .filter { $0.status == "completed" && $0.syncStatus == "pending" } - .sorted { $0.createdAt < $1.createdAt } - - for inspection in pending { - let photosReady = inspection.pendingPhotos.allSatisfy { - $0.uploadStatus == "uploaded" || $0.uploadStatus == "failed" - } - guard photosReady else { continue } - - do { - let inspectionId = try await APIClient.shared.submitInspection(inspection) - inspection.serverId = inspectionId - inspection.syncStatus = "synced" - inspection.status = "synced" - - // Clear follow-up flag on parent. - // Reuses the `all` array already fetched at the top of this - // function — avoids a redundant full-table fetch per inspection. - if let parentLocalId = inspection.parentLocalId, - let parent = all.first(where: { $0.localId == parentLocalId }) { - parent.followUpRequired = false - parent.followUpNote = nil - } - - try? context.save() - - } catch { - inspection.syncRetryCount += 1 - inspection.syncErrorMessage = error.localizedDescription - if inspection.syncRetryCount >= 5 { - inspection.syncStatus = "failed" - } - syncError = "Failed to sync inspection: \(error.localizedDescription)" - try? context.save() - } - } - } - - // ── Outbox: Issues ──────────────────────────────────────────────────── - - private func processIssueQueue(context: ModelContext) async { - guard let all = try? context.fetch(FetchDescriptor()) else { return } - let pending = all - .filter { $0.syncStatus == "pending" } - .sorted { $0.createdAt < $1.createdAt } - - // Pre-fetch all inspections to check parent sync status AND to resolve - // the parent's serverId. The allInspections array is the same set of - // objects that processInspectionQueue updated (serverId written in-memory - // this same triggerSync pass), so looking up serverId here is reliable. - // issue.inspection?.serverId is NOT reliable — it navigates a @Relationship - // that SwiftData may have loaded as a separate object instance before - // processInspectionQueue wrote the serverId back, leaving it nil even - // when the parent inspection already synced successfully this same pass. - let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] - - for issue in pending { - let parentLocalId = issue.inspectionLocalId - let parent = allInspections.first(where: { $0.localId == parentLocalId }) - - // ── Parent inspection status guards ─────────────────────────── - if let parent { - switch parent.syncStatus { - case "failed": - // Parent permanently failed — this issue can never be linked. - // Mark it failed immediately rather than creating an orphaned - // server record with no inspection_id. - issue.syncStatus = "failed" - issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted." - try? context.save() - syncError = "Issue \(issue.localId.prefix(8))\u{2026} blocked: parent inspection did not sync." - continue - - case "synced": - // Parent has a serverId — proceed and link correctly. - break - - default: - // Parent is still "pending" (draft or awaiting submission). - // Submitting now would create a server issue with no - // inspection_id — the issue and inspection appear unlinked - // on the web. Defer until the next triggerSync() pass, by - // which point processInspectionQueue will have synced the - // parent and assigned it a serverId. - continue - } - } - // parent == nil means inspectionLocalId == "" (standalone issue) — submit without inspection_id. - - // Resolve the parent's server ID. Safe to force-unwrap serverId - // here — the switch above guarantees parent.syncStatus == "synced" - // when parent is non-nil, so serverId is always set at this point. - let inspectionServerId = parent?.serverId - - do { - let issueId = try await APIClient.shared.submitIssue(issue, inspectionServerId: inspectionServerId) - issue.serverId = issueId - issue.syncStatus = "synced" - // Photos are now represented by photoServerPaths on the server. - // Clear the local file paths so IssueDetailView doesn't render - // a duplicate "local photos" section alongside the server section. - issue.photoLocalPaths = [] - try? context.save() - - // If there are additional photos beyond the first (which was sent - // as photo_path on create), PATCH them to result_photos now. - // The server create endpoint only stores photo_path; result_photos - // must be set via a separate PATCH call. - let extras = Array(issue.photoServerPaths.dropFirst()) - if !extras.isEmpty { - try? await APIClient.shared.updateIssuePhotos( - issueId: issueId, resultPhotos: extras - ) - } - - } catch { - issue.syncRetryCount += 1 - issue.syncErrorMessage = error.localizedDescription - if issue.syncRetryCount >= 5 { - issue.syncStatus = "failed" - } - try? context.save() - } - } - } - - // ── Reference Data ──────────────────────────────────────────────────── - - func pullReferenceData() async { - guard isOnline, let context = modelContext else { return } - - do { - let facilitiesData: FacilitiesResponseData = - try await APIClient.shared.request("/api/v1/facilities") - let templatesData: TemplatesResponseData = - try await APIClient.shared.request("/api/v1/templates") - - let existingFacilities = try context.fetch(FetchDescriptor()) - let facilityMap = Dictionary( - existingFacilities.map { ($0.serverId, $0) }, - uniquingKeysWith: { a, _ in a } - ) - - // Deduplicate the server response by id before upserting. - // The server may return the same facility id more than once - // (e.g. one row per contract assignment), which would insert - // duplicate LocalFacility records and show buildings twice in - // every picker. Keep only the first occurrence of each id. - var seenFacilityIds = Set() - let uniqueFacilities = facilitiesData.facilities.filter { - seenFacilityIds.insert($0.id).inserted - } - - for apiFacility in uniqueFacilities { - let localFacility: LocalFacility - if let existing = facilityMap[apiFacility.id] { - existing.update(from: apiFacility) - localFacility = existing - } else { - let newFacility = LocalFacility(from: apiFacility) - context.insert(newFacility) - localFacility = newFacility - } - try await upsertAreas(for: apiFacility.id, facility: localFacility, context: context) - } - - let existingTemplates = try context.fetch(FetchDescriptor()) - let templateMap = Dictionary( - existingTemplates.map { ($0.serverId, $0) }, - uniquingKeysWith: { a, _ in a } - ) - - for apiSummary in templatesData.templates { - let summaryChanged: Bool - if let existing = templateMap[apiSummary.id] { - summaryChanged = existing.updateSummary(from: apiSummary) - } else { - context.insert(LocalTemplate(from: apiSummary)) - summaryChanged = true // new template — must fetch schema - } - try await upsertTemplateSchema( - id: apiSummary.id, - context: context, - templateMap: templateMap, - summaryChanged: summaryChanged - ) - } - - // Delete any cached templates the server no longer returns. - // The server endpoint now only returns active templates, so any - // locally cached template not in the response was deactivated. - // Deleting ensures they never appear in the picker even offline. - let returnedTemplateIds = Set(templatesData.templates.map { $0.id }) - for existing in existingTemplates { - if !returnedTemplateIds.contains(existing.serverId) { - context.delete(existing) - } - } - - try context.save() - - } catch APIError.notAuthenticated { - syncError = "Session expired. Please log in again." - } catch { - syncError = "Sync failed: \(error.localizedDescription)" - } - } - - // ── Pending Count ───────────────────────────────────────────────────── - - // ── Orphaned Photo Cleanup ──────────────────────────────────────────── - // Removes local JPEG files from Documents/JQCPhotos/ that are no longer - // referenced by any LocalInspection, LocalIssue, or PendingPhoto record. - // Once an inspection or issue is fully synced its local photos are no - // longer needed — the server holds the canonical copies. Without this, - // weeks of inspections accumulate hundreds of MBs of orphaned files. - // - // Throttled to once per hour via UserDefaults to avoid redundant - // FileManager enumeration on every 60-second sync cycle. - - private static let lastCleanupKey = "jqc.photoCleanup.lastRunAt" - private static let cleanupInterval: TimeInterval = 3600 // 1 hour - - private func cleanupOrphanedPhotos(context: ModelContext) { - let last = UserDefaults.standard.object(forKey: Self.lastCleanupKey) as? Date - guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return } - UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey) - - // ── Phase 1: collect referenced paths on @MainActor (SwiftData fetches) ── - // These are fast in-memory operations — always runs on the main actor. - var referencedPaths = Set() - - // PendingPhoto — not yet uploaded - if let pendingPhotos = try? context.fetch(FetchDescriptor()) { - for p in pendingPhotos where p.uploadStatus != "uploaded" { - referencedPaths.insert(p.localFilePath) - } - } - // LocalInspection — draft photos (formData values starting with "local://") - if let inspections = try? context.fetch(FetchDescriptor()) { - for insp in inspections where insp.status == "draft" { - for val in insp.formData.values { - if let s = val as? String, s.hasPrefix("local://") { - referencedPaths.insert(String(s.dropFirst("local://".count))) - } - } - } - } - // LocalIssue — unsync'd issue photos - if let issues = try? context.fetch(FetchDescriptor()) { - for issue in issues where issue.syncStatus != "synced" { - for path in issue.photoLocalPaths { referencedPaths.insert(path) } - } - } - - // ── Phase 2: FileManager enumeration + deletion on a background thread ── - // Directory enumeration and file removal are I/O-bound and can stutter - // the main thread when JQCPhotos/ contains hundreds of files. Dispatching - // here is safe because `referencedPaths` is a value type (Set) - // captured by copy — no shared mutable state crosses the boundary. - Task.detached(priority: .utility) { - let fm = FileManager.default - guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - let photosDir = docsDir.appendingPathComponent("JQCPhotos") - guard let diskFiles = try? fm.contentsOfDirectory( - at: photosDir, includingPropertiesForKeys: nil - ) else { return } - - var deletedCount = 0 - for fileURL in diskFiles { - if !referencedPaths.contains(fileURL.path) { - try? fm.removeItem(at: fileURL) - deletedCount += 1 - } - } - if deletedCount > 0 { - print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)") - } - } - } - - func updatePendingCount(context: ModelContext) { - // Fetch-all + filter in Swift — #Predicate with string literals is - // banned under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION (CLAUDE.md rule 3). - // These fetches are lightweight (no relationships loaded) and run once - // per sync cycle at the very end, not in a hot loop. - let inspCount = (try? context.fetch(FetchDescriptor()))? - .filter { $0.syncStatus == "pending" }.count ?? 0 - let issueCount = (try? context.fetch(FetchDescriptor()))? - .filter { $0.syncStatus == "pending" }.count ?? 0 - pendingCount = inspCount + issueCount - } - - // ── Private Helpers ─────────────────────────────────────────────────── - - private func upsertAreas(for facilityId: Int, facility: LocalFacility, context: ModelContext) async throws { - let areasData: AreasResponseData = try await APIClient.shared.request( - "/api/v1/facilities/\(facilityId)/areas" - ) - let existing = (try? context.fetch(FetchDescriptor()))? - .filter { $0.facilityServerId == facilityId } ?? [] - let areaMap = Dictionary(existing.map { ($0.serverId, $0) }, - uniquingKeysWith: { a, _ in a }) - for apiArea in areasData.areas { - if let ex = areaMap[apiArea.id] { - ex.update(from: apiArea) - // Re-wire relationship in case it was lost (e.g. cache clear) - if ex.facility == nil { ex.facility = facility } - } else { - let newArea = LocalArea(from: apiArea) - // Wire the inverse relationship so LocalFacility.areas is populated. - // Without this assignment SwiftData never links the area into the - // facility's areas array and selectedFacility?.areas returns []. - newArea.facility = facility - context.insert(newArea) - } - } - } - - private func upsertTemplateSchema( - id: Int, - context: ModelContext, - templateMap: [Int: LocalTemplate], - summaryChanged: Bool - ) async throws { - let existing = templateMap[id] ?? (try? context.fetch(FetchDescriptor()))? - .first { $0.serverId == id } - - // Skip the detail API call if: - // • The schema was already fetched (schemaFetchedAt is non-nil) - // • No summary fields changed this sync pass (name, frequency, isActive) - // • The local schema is non-empty (not a first-run blank) - // - // This reduces N sequential GET /api/v1/templates/{id} calls to zero - // on a typical sync where templates haven't changed — the common case. - // The schema is always re-fetched when any summary field changes, - // when schemaFetchedAt is nil (new template or first launch), or - // when the local schema is empty ("[]"). - if let existing, - existing.schemaFetchedAt != nil, - existing.formSchemaJSON != "[]", - !summaryChanged { - return // schema is current — no network call needed - } - - let detailData: TemplateDetailResponseData = try await APIClient.shared.request( - "/api/v1/templates/\(id)" - ) - existing?.updateSchema(from: detailData.template) - } - - // ── Pull server-assigned issues ─────────────────────────────────────── - // Fetches issues assigned to the current user on the server and upserts - // them into SwiftData so IssuesListView shows them alongside device-created issues. - // Keyed by serverId — existing records are updated in-place, new ones inserted. - // These records carry syncStatus = "synced" and a generated localId so they - // are never re-submitted to the server by processIssueQueue. - - func pullAssignedIssues(context: ModelContext) async { - guard isOnline, AuthManager.shared.isAuthenticated else { return } - do { - let apiIssues = try await APIClient.shared.fetchAssignedIssues() - // Do not return early on empty — deletion still needs to run - // to remove issues that were unassigned from this inspector. - - // Build a map of existing LocalIssues by serverId for upsert - let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] - var serverIdMap: [Int: LocalIssue] = [:] - for local in allLocal { - if let sid = local.serverId { serverIdMap[sid] = local } - } - - for api in apiIssues { - if let existing = serverIdMap[api.id] { - // Update mutable fields on existing record - existing.issueStatus = api.status - existing.severity = api.severity - existing.issueDescription = api.description - if let fid = api.facilityId { existing.facilityServerId = fid } - // Cache facility name so IssueDetailView works when local - // facility reference cache has been cleared (Settings → Clear Cache). - if let fn = api.facilityName, !fn.isEmpty { - existing.facilityNameCache = fn - } - // Phase A — resolution details from web staff - existing.resultNotes = api.resultNotes - existing.verificationNote = api.verificationNote - existing.reportedByName = api.reportedByName - existing.areaNameCache = api.areaName - existing.assignedToName = api.assignedToName - // Handler ("Handled By", phase35) - existing.handlerType = api.handlerType - existing.handlerLabel = api.handlerLabel - existing.facilityHandlerName = api.facilityHandlerName - existing.facilityHandlerContact = api.facilityHandlerContact - existing.facilityHandlerNotes = api.facilityHandlerNotes - existing.vendorName = api.vendorName - existing.vendorContact = api.vendorContact - existing.vendorNotes = api.vendorNotes - if let vts = api.verifiedAt, - let date = Self.isoFormatter.date(from: vts) { - existing.verifiedAt = date - } - // 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 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( - inspectionLocalId: "", - facilityServerId: api.facilityId ?? 0, - severity: api.severity, - description: api.description - ) - local.serverId = api.id - local.issueStatus = api.status - local.syncStatus = "synced" // never re-submit - // Cache facility name for offline display - local.facilityNameCache = api.facilityName - // Phase A — resolution details from web staff - local.resultNotes = api.resultNotes - local.verificationNote = api.verificationNote - local.reportedByName = api.reportedByName - local.areaNameCache = api.areaName - local.assignedToName = api.assignedToName - // Handler ("Handled By", phase35) - local.handlerType = api.handlerType - local.handlerLabel = api.handlerLabel - local.facilityHandlerName = api.facilityHandlerName - local.facilityHandlerContact = api.facilityHandlerContact - local.facilityHandlerNotes = api.facilityHandlerNotes - local.vendorName = api.vendorName - local.vendorContact = api.vendorContact - local.vendorNotes = api.vendorNotes - if let vts = api.verifiedAt, - let date = Self.isoFormatter.date(from: vts) { - local.verifiedAt = date - } - // Store server photos so IssueDetailView can show them - // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). - var serverPaths: [String] = [] - 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 - local.serverReportedAt = date // accurate server timestamp - } - context.insert(local) - } - } - - // Remove server-pulled records that are no longer in the response. - // This happens when an issue is reassigned to a different inspector — - // the server stops returning it for this user, so the local copy must - // be deleted. Only remove records that were pulled from the server - // (syncStatus == "synced" AND serverId != nil AND inspectionLocalId == ""). - // Device-created issues (inspectionLocalId != "") are never touched. - let returnedServerIds = Set(apiIssues.map { $0.id }) - for local in allLocal { - guard let sid = local.serverId, - local.syncStatus == "synced", - local.inspectionLocalId == "" - else { continue } - if !returnedServerIds.contains(sid) { - context.delete(local) - } - } - - try? context.save() - - } catch APIError.notAuthenticated { - // Let AuthManager handle session expiry - } catch { - // Non-fatal — IssuesListView still shows device-created issues - } - } - - // ── Scheduled Inspections (phase36) ─────────────────────────────────── - // Read-only pull of planned/recurring assignments for the Dashboard and - // My Inspections "Scheduled" section. Upsert by serverId, then delete rows - // the server no longer returns (schedule fulfilled, deactivated, or - // reassigned to another inspector). Best-effort — never blocks the pipeline. - - func pullScheduledInspections(context: ModelContext) async { - guard isOnline, AuthManager.shared.isAuthenticated else { return } - do { - let apiRows = try await APIClient.shared.fetchScheduledInspections() - - // Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3). - let allLocal = (try? context.fetch(FetchDescriptor())) ?? [] - var byServerId: [Int: LocalScheduledInspection] = [:] - for row in allLocal { byServerId[row.serverId] = row } - - for api in apiRows { - let row = byServerId[api.id] ?? { - let r = LocalScheduledInspection(serverId: api.id) - context.insert(r) - return r - }() - row.facilityServerId = api.facilityId - row.facilityName = api.facilityName ?? "" - row.templateServerId = api.templateId - row.templateName = api.templateName ?? "" - row.inspectorId = api.inspectorId - row.frequency = api.frequency - row.frequencyLabel = api.frequencyLabel ?? "" - row.dueDateString = api.nextDueDate ?? "" - row.nextDue = api.nextDueDate.flatMap { Self.dateOnlyFormatter.date(from: $0) } - row.isOverdue = api.isOverdue - row.notes = api.notes - row.updatedAt = Date() - } - - // Delete rows the server no longer returns. - let returnedIds = Set(apiRows.map { $0.id }) - for row in allLocal where !returnedIds.contains(row.serverId) { - context.delete(row) - } - - try? context.save() - - } catch APIError.notAuthenticated { - // Let AuthManager handle session expiry - } catch { - // Non-fatal — stale scheduled rows stay visible until next pull - } - } - - // ── Dashboard Stats ─────────────────────────────────────────────────── - // Best-effort fetch — a network failure silently leaves dashboardStats nil - // so the UI falls back to a placeholder card. Never blocks the sync pipeline. - - func fetchDashboardStats() async { - guard isOnline, AuthManager.shared.isAuthenticated else { return } - do { - dashboardStats = try await APIClient.shared.fetchDashboardStats() - } catch APIError.notAuthenticated { - // Let AuthManager handle session expiry - } catch { - // Non-fatal — stale stats stay visible until next successful fetch - } - } -} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/DashboardView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/DashboardView.swift deleted file mode 100644 index 71dd6fe..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/DashboardView.swift +++ /dev/null @@ -1,644 +0,0 @@ -// Views/Dashboard/DashboardView.swift -// ------------------------------------ -// Phase C: adds Inspection History tab, polished Settings with cache clear, -// and schedules background sync on scene enter background. -// -// CHANGED (sidebar update): -// - Templates removed from sidebar entirely. -// - Issues view added for all roles. -// - History moved to sit between Pending Sync and Settings. -// - Tab identity is now enum-based (SidebarTab) instead of raw Int -// so adding/removing tabs never breaks the detail switch. - -import SwiftUI -import SwiftData -import Combine -import MessageUI - -// MARK: - SidebarTab - -enum SidebarTab: Hashable { - case dashboard // landing page — KPI stats card - case myInspections - case issues - case facilities - case pendingSync - case history - case notifications // in-app notification inbox - case settings -} - -struct DashboardView: View { - - @EnvironmentObject private var auth: AuthManager - @EnvironmentObject private var sync: SyncManager - @Environment(\.modelContext) private var context - @Environment(\.scenePhase) private var scenePhase - - @Query( - filter: #Predicate { $0.status != "synced" }, - sort: \LocalInspection.lastModifiedAt, - order: .reverse - ) private var myInspections: [LocalInspection] - - @State private var selectedTab: SidebarTab = .dashboard - /// Each sidebar tap refreshes the UUID for that tab, forcing its - /// NavigationStack to be destroyed and recreated — even when the tab - /// hasn't changed (user is already on it but deep inside a detail view). - @State private var tabResetId: [SidebarTab: UUID] = [ - .dashboard: UUID(), - .myInspections: UUID(), - .issues: UUID(), - .facilities: UUID(), - .pendingSync: UUID(), - .history: UUID(), - .notifications: UUID(), - .settings: UUID(), - ] - - /// Explicit paths for the three tabs that push detail views. - /// Resetting these to empty pops the stack to root immediately and reliably. - @State private var inspectionsPath = NavigationPath() - @State private var issuesPath = NavigationPath() - @State private var historyPath = NavigationPath() - - /// Tap a sidebar tab: reset all navigable paths, then switch to it. - private func selectTab(_ tab: SidebarTab) { - inspectionsPath = NavigationPath() - issuesPath = NavigationPath() - historyPath = NavigationPath() - tabResetId[tab] = UUID() - selectedTab = tab - } - - var body: some View { - NavigationSplitView { - List { - // ── Dashboard ────────────────────────────────────────────── - Button { selectTab(.dashboard) } label: { - Label("Dashboard", systemImage: "chart.bar.xaxis") - .foregroundStyle(selectedTab == .dashboard ? .blue : .primary) - } - .listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear) - - // ── My Inspections ───────────────────────────────────────── - Button { selectTab(.myInspections) } label: { - HStack { - Label("My Inspections", systemImage: "checklist") - .foregroundStyle(selectedTab == .myInspections ? .blue : .primary) - Spacer() - if !myInspections.isEmpty { - Text("\(myInspections.count)") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.blue.opacity(0.15)) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) - - // ── Issues (all roles) ───────────────────────────────────── - Button { selectTab(.issues) } label: { - Label("Issues", systemImage: "exclamationmark.triangle") - .foregroundStyle(selectedTab == .issues ? .blue : .primary) - } - .listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear) - - // ── Facilities ───────────────────────────────────────────── - Button { selectTab(.facilities) } label: { - Label("Facilities", systemImage: "building.2") - .foregroundStyle(selectedTab == .facilities ? .blue : .primary) - } - .listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear) - - // ── Pending Sync ─────────────────────────────────────────── - Button { selectTab(.pendingSync) } label: { - HStack { - Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") - .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) - Spacer() - if sync.pendingCount > 0 { - Text("\(sync.pendingCount)") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.orange.opacity(0.2)) - .foregroundStyle(.orange) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) - - // ── History (moved — sits between Pending Sync and Settings) - Button { selectTab(.history) } label: { - Label("History", systemImage: "clock.arrow.circlepath") - .foregroundStyle(selectedTab == .history ? .blue : .primary) - } - .listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) - - // ── Notifications ───────────────────────────────────────── - Button { - selectTab(.notifications) - sync.markNotificationsViewed() - } label: { - HStack { - Label("Notifications", systemImage: "bell") - .foregroundStyle(selectedTab == .notifications ? .blue : .primary) - Spacer() - if sync.unreadNotificationCount > 0 { - Text("\(min(sync.unreadNotificationCount, 99))") - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.red.opacity(0.85)) - .foregroundStyle(.white) - .clipShape(Capsule()) - } - } - } - .listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear) - - // ── Settings ─────────────────────────────────────────────── - Button { selectTab(.settings) } label: { - Label("Settings", systemImage: "gear") - .foregroundStyle(selectedTab == .settings ? .blue : .primary) - } - .listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear) - } - .navigationTitle("JQC Inspector") - .listStyle(.sidebar) - .safeAreaInset(edge: .bottom) { syncStatusFooter } - - } detail: { - switch selectedTab { - case .dashboard: - NavigationStack { DashboardStatsView() } - case .myInspections: - NavigationStack(path: $inspectionsPath) { - MyInspectionsView() - .navigationDestination(for: LocalInspection.self) { inspection in - if inspection.status == "draft" { - ExecuteInspectionView(inspection: inspection) - } else { - CompletedInspectionView(inspection: inspection) - } - } - } - case .issues: - NavigationStack(path: $issuesPath) { - IssuesListView() - .navigationDestination(for: LocalIssue.self) { issue in - IssueDetailView(issue: issue) - } - } - case .facilities: - NavigationStack { FacilitiesListView() } - case .pendingSync: - NavigationStack { SyncStatusView() } - case .history: - NavigationStack(path: $historyPath) { - InspectionHistoryView() - .navigationDestination(for: APIInspectionSummary.self) { inspection in - HistoryDetailView(inspection: inspection) - } - } - case .notifications: - NavigationStack { NotificationsView() } - case .settings: - NavigationStack { SettingsView() } - } - } - .task { - if sync.isOnline { - await sync.triggerSync() - } else { - sync.updatePendingCount(context: context) - } - } - // Schedule background sync when app is backgrounded - .onChange(of: scenePhase) { - if scenePhase == .background { - scheduleBackgroundSync() - } - } - } - - private var syncStatusFooter: some View { - VStack(spacing: 0) { - Divider() - HStack(spacing: 8) { - Circle() - .fill(sync.isOnline ? Color.green : Color.orange) - .frame(width: 8, height: 8) - Text(sync.isOnline ? "Online" : "Offline") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - if sync.isSyncing { - ProgressView().scaleEffect(0.7) - } else if let lastSync = sync.lastSyncAt { - Text("Synced \(lastSync.formatted(.relative(presentation: .named)))") - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - } -} - -// MARK: - Dashboard Stats View -// Shows inspector-scoped KPI cards fetched from GET /api/v1/stats/dashboard. -// Data is refreshed on every triggerSync() via SyncManager.fetchDashboardStats(). - -struct DashboardStatsView: View { - - @EnvironmentObject private var sync: SyncManager - - // Draft inspections — shown as a resume banner at the top of the dashboard - // so the inspector never has to hunt through My Inspections to find an - // in-progress form they left open. - @Query( - filter: #Predicate { $0.status == "draft" }, - sort: \LocalInspection.lastModifiedAt, - order: .reverse - ) private var draftInspections: [LocalInspection] - @Environment(\.modelContext) private var context - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - - // ── Draft Resume Banner ──────────────────────────────────── - if !draftInspections.isEmpty { - DraftResumeBanner(drafts: draftInspections, context: context) - } - - // ── Scheduled Inspections (phase36) ──────────────────────── - // Planned/recurring assignments for this inspector. Self-hides - // when there are none. Tap a row to start it (facility + - // template preselected). - ScheduledInspectionsCard() - - if let stats = sync.dashboardStats { - // ── Today ────────────────────────────────────────────── - statsSection(title: "Today") { - HStack(spacing: 12) { - statTile( - value: "\(stats.todayInspections)", - label: "Inspections", - icon: "checklist", - color: .blue - ) - statTile( - value: "\(stats.completedToday)", - label: "Completed", - icon: "checkmark.circle.fill", - color: .green - ) - } - } - - // ── Issues ───────────────────────────────────────────── - statsSection(title: "Issues") { - HStack(spacing: 12) { - statTile( - value: "\(stats.openIssues)", - label: "Open / In Progress", - icon: "exclamationmark.triangle", - color: .orange - ) - statTile( - value: "\(stats.pendingFollowups)", - label: "Pending Follow-ups", - icon: "exclamationmark.arrow.circlepath", - color: stats.pendingFollowups > 0 ? .orange : .secondary - ) - } - } - - // ── SLA ──────────────────────────────────────────────── - if stats.slaBreached > 0 || stats.slaAtRisk > 0 { - statsSection(title: "SLA") { - HStack(spacing: 12) { - statTile( - value: "\(stats.slaBreached)", - label: "Breached", - icon: "xmark.circle.fill", - color: stats.slaBreached > 0 ? .red : .secondary - ) - statTile( - value: "\(stats.slaAtRisk)", - label: "At Risk", - icon: "clock.badge.exclamationmark", - color: stats.slaAtRisk > 0 ? .orange : .secondary - ) - } - } - } - - // ── Severity breakdown ───────────────────────────────── - if stats.openIssues > 0 { - statsSection(title: "Open Issues by Severity") { - HStack(spacing: 8) { - if stats.severityCritical > 0 { - severityTile(count: stats.severityCritical, label: "Critical", color: .red) - } - if stats.severityHigh > 0 { - severityTile(count: stats.severityHigh, label: "High", color: .orange) - } - if stats.severityMedium > 0 { - severityTile(count: stats.severityMedium, label: "Medium", color: .yellow) - } - if stats.severityLow > 0 { - severityTile(count: stats.severityLow, label: "Low", color: .blue) - } - } - } - } - - // ── Score ────────────────────────────────────────────── - statsSection(title: "Performance (30 days)") { - if let avg = stats.avgScore30d { - let color: Color = avg >= 80 ? .green : avg >= 60 ? .orange : .red - HStack(spacing: 16) { - Text(String(format: "%.1f%%", avg)) - .font(.system(size: 48, weight: .bold, design: .rounded)) - .foregroundStyle(color) - VStack(alignment: .leading, spacing: 4) { - Text("Average Score") - .font(.subheadline) - .foregroundStyle(.secondary) - Text(avg >= 80 ? "Excellent" : avg >= 60 ? "Needs Improvement" : "Below Standard") - .font(.caption.bold()) - .foregroundStyle(color) - } - } - .padding(.vertical, 4) - } else { - Text("No completed inspections in the last 30 days.") - .font(.callout) - .foregroundStyle(.secondary) - } - } - - } else if !sync.isOnline { - ContentUnavailableView( - "Offline", - systemImage: "wifi.slash", - description: Text("Dashboard stats require an internet connection.") - ) - } else { - VStack(spacing: 16) { - ProgressView("Loading stats…") - Text("Stats appear after the first sync completes.") - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 60) - } - } - .padding(24) - } - .navigationTitle("Dashboard") - .navigationBarTitleDisplayMode(.large) - .refreshable { - await sync.fetchDashboardStats() - } - } - - // ── Helpers ──────────────────────────────────────────────────────────── - - @ViewBuilder - private func statsSection( - title: String, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text(title.uppercased()) - .font(.caption.bold()) - .foregroundStyle(.secondary) - .tracking(1) - content() - } - } - - private func statTile( - value: String, - label: String, - icon: String, - color: Color - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: icon) - .font(.caption) - .foregroundStyle(color) - Text(label) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Text(value) - .font(.system(size: 32, weight: .bold, design: .rounded)) - .foregroundStyle(color) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(color.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - - private func severityTile(count: Int, label: String, color: Color) -> some View { - VStack(spacing: 4) { - Text("\(count)") - .font(.system(size: 22, weight: .bold, design: .rounded)) - .foregroundStyle(color) - Text(label) - .font(.caption2.bold()) - .foregroundStyle(color.opacity(0.8)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(color.opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } -} - -// MARK: - Draft Resume Banner -// Shown on the dashboard when the inspector has one or more in-progress -// (draft) inspections. Tapping a draft opens ExecuteInspectionView as a -// full-screen sheet — avoids cross-NavigationStack linking since the -// dashboard and My Inspections stacks are independent. - -struct DraftResumeBanner: View { - let drafts: [LocalInspection] - let context: ModelContext - @State private var selectedDraft: LocalInspection? = nil - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Label(drafts.count == 1 - ? "Inspection in progress" - : "\(drafts.count) inspections in progress", - systemImage: "pencil.and.list.clipboard") - .font(.subheadline.bold()) - .foregroundStyle(.white) - - ForEach(drafts) { draft in - Button { - selectedDraft = draft - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(templateName(for: draft)) - .font(.callout.bold()) - .foregroundStyle(.white) - Text(facilityName(for: draft)) - .font(.caption) - .foregroundStyle(.white.opacity(0.85)) - Text("Last saved \(draft.lastModifiedAt.formatted(.relative(presentation: .named)))") - .font(.caption2) - .foregroundStyle(.white.opacity(0.70)) - } - Spacer() - Label("Resume", systemImage: "play.fill") - .font(.caption.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 10).padding(.vertical, 5) - .background(Color.white.opacity(0.25)) - .clipShape(Capsule()) - } - .padding(10) - .background(Color.white.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - .padding(14) - .background(Color.blue.gradient) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .fullScreenCover(item: $selectedDraft) { draft in - // Wrap in NavigationStack so ExecuteInspectionView's toolbar - // and dismiss work correctly when presented as a sheet. - NavigationStack { - ExecuteInspectionView(inspection: draft) - } - } - } - - private func templateName(for inspection: LocalInspection) -> String { - let id = inspection.templateServerId - return (try? context.fetch( - FetchDescriptor(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Inspection" - } - - private func facilityName(for inspection: LocalInspection) -> String { - let id = inspection.facilityServerId - let all = (try? context.fetch(FetchDescriptor())) ?? [] - return all.first(where: { $0.serverId == id })?.name ?? "Unknown Facility" - } -} - - -// MARK: - Retryable Photo - -/// Loads a server photo via AsyncImage with a tap-to-retry failure state. -/// AsyncImage has no built-in retry — once it enters .failure it stays there -/// for the view's lifetime. Toggling the `id` forces SwiftUI to destroy and -/// recreate the AsyncImage, triggering a fresh network load. -// MARK: - PhotoCache -// Simple NSCache-backed in-memory image cache keyed by URL string. -// Prevents RetryablePhotoView from re-downloading the same photo on every -// view appearance (AsyncImage only caches within a single URLSession load; -// revisiting IssueDetailView or scrolling the inspection history starts a -// fresh download). Cache entries are evicted automatically by the OS under -// memory pressure — no manual lifetime management needed. - -final class PhotoCache { - static let shared = PhotoCache() - private let cache = NSCache() - - private init() { - cache.countLimit = 150 // max images in memory - cache.totalCostLimit = 80_000_000 // ~80 MB total - } - - func get(_ url: URL) -> UIImage? { cache.object(forKey: url.absoluteString as NSString) } - func set(_ image: UIImage, for url: URL) { cache.setObject(image, forKey: url.absoluteString as NSString, - cost: Int(image.size.width * image.size.height * 4)) } -} - -struct RetryablePhotoView: View { - let url: URL? - @State private var reloadToken = UUID() - @State private var cached: UIImage? = nil - - var body: some View { - Group { - if let img = cached { - // Cache hit — instant display, no spinner, no network - Image(uiImage: img) - .resizable() - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 8)) - .onAppear { - // Store into cache so next appearance is instant - if let url, let ui = ImageRenderer(content: image).uiImage { - PhotoCache.shared.set(ui, for: url) - cached = ui - } - } - case .failure: - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.secondary) - Text("Photo unavailable") - .font(.caption) - .foregroundStyle(.secondary) - Button { - reloadToken = UUID() - } label: { - Label("Retry", systemImage: "arrow.clockwise") - .font(.caption) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - case .empty: - HStack(spacing: 8) { - ProgressView() - Text("Loading…").font(.caption).foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - @unknown default: - EmptyView() - } - } - .id(reloadToken) - .onAppear { - // Check cache before AsyncImage fires a network request - if let url, let img = PhotoCache.shared.get(url) { - cached = img - } - } - } - } - } -} - diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/IssuesView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/IssuesView.swift deleted file mode 100644 index 3f6a6f9..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/IssuesView.swift +++ /dev/null @@ -1,1336 +0,0 @@ -// Views/Dashboard/IssuesView.swift -import SwiftUI -import SwiftData -import MessageUI - -// MARK: - Facilities - -struct FacilitiesListView: View { - @Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility] - - var body: some View { - Group { - if facilities.isEmpty { - ContentUnavailableView("No Facilities", systemImage: "building.2.slash", - description: Text("Connect to the internet to sync your assigned facilities.")) - } else { - List(facilities) { facility in - VStack(alignment: .leading, spacing: 4) { - Text(facility.name).font(.headline) - if !facility.address.isEmpty { - Text(facility.address).font(.caption).foregroundStyle(.secondary) - } - if facility.projectName != "No Contract" { - Text(facility.projectName).font(.caption2).foregroundStyle(.blue) - } - Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")") - .font(.caption2).foregroundStyle(.tertiary) - } - .padding(.vertical, 4) - } - } - } - .navigationTitle("Facilities (\(facilities.count))") - } -} - -// MARK: - Issues (Inspector) -// Shows issues flagged by this inspector across all inspections. -// Read-only list — tapping shows description and sync status detail. - -struct IssuesListView: View { - - // Fetch all then filter in Swift — #Predicate with string literals on - // LocalIssue is unreliable under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION - // (CLAUDE.md rule 3). Resolved issues are excluded to match the web default. - @Query( - sort: \LocalIssue.createdAt, - order: .reverse - ) private var allIssues: [LocalIssue] - - @Environment(\.modelContext) private var context - @State private var showNewIssue = false - @State private var searchText = "" - @State private var severityFilter: String? = nil // nil = all - - private let severities = ["critical", "high", "medium", "low"] - - /// Date formatter shared for search matching. - /// Formats to e.g. "Jun 19, 2026 4:55 PM" so inspectors can type - /// partial strings: "jun", "2026", "19", "4:55" all match. - private static let searchDateFmt: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .short - return f - }() - - private func dateString(for issue: LocalIssue) -> String { - let d = issue.serverReportedAt ?? issue.createdAt - return Self.searchDateFmt.string(from: d) - } - - private var issues: [LocalIssue] { - var list = allIssues.filter { $0.issueStatus != "resolved" } - - if let sev = severityFilter { - list = list.filter { $0.severity == sev } - } - - if !searchText.isEmpty { - let q = searchText.lowercased() - list = list.filter { - // ID — match "#58" or bare "58" - let idStr = $0.serverId.map { String($0) } ?? "" - let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "") - - return idMatch - || $0.issueDescription.lowercased().contains(q) - || ($0.facilityNameCache?.lowercased().contains(q) ?? false) - || ($0.areaNameCache?.lowercased().contains(q) ?? false) - || ($0.assignedToName?.lowercased().contains(q) ?? false) - || dateString(for: $0).lowercased().contains(q) - } - } - - return list - } - - var body: some View { - Group { - if allIssues.filter({ $0.issueStatus != "resolved" }).isEmpty { - ContentUnavailableView( - "No Issues", - systemImage: "exclamationmark.triangle", - description: Text("Tap + to log a new issue, or flag one during an inspection.") - ) - } else if issues.isEmpty { - ContentUnavailableView.search(text: searchText) - } else { - List(issues) { issue in - NavigationLink(value: issue) { - IssueRowView(issue: issue, context: context) - } - } - } - } - .navigationTitle("Issues (\(issues.count))") - .searchable(text: $searchText, - prompt: "Search ID, date, description, facility…") - .toolbar { - ToolbarItemGroup(placement: .primaryAction) { - // Severity filter - Menu { - Button { - severityFilter = nil - } label: { - Label("All Severities", - systemImage: severityFilter == nil ? "checkmark" : "line.3.horizontal.decrease") - } - Divider() - ForEach(severities, id: \.self) { sev in - Button { - severityFilter = (severityFilter == sev) ? nil : sev - } label: { - Label(sev.capitalized, - systemImage: severityFilter == sev ? "checkmark" : "circle") - } - } - } label: { - Image(systemName: severityFilter != nil - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") - .foregroundStyle(severityFilter != nil ? .orange : .primary) - } - - // New Issue — borderedProminent so it stands out clearly - // from the filter icon and is easy to find at a glance. - Button { - showNewIssue = true - } label: { - Label("New Issue", systemImage: "plus") - } - .buttonStyle(.borderedProminent) - } - } - .sheet(isPresented: $showNewIssue) { - StandaloneIssueView() - } - } -} - -struct IssueRowView: View { - let issue: LocalIssue - let context: ModelContext - - private var facilityName: String { - // Primary: look up from local reference cache (fast, works offline). - // Fallback: facilityNameCache persisted from the last server sync. - // This covers the case where the user cleared the local cache in Settings - // while server-pulled issues are still present. - let id = issue.facilityServerId - let all = (try? context.fetch(FetchDescriptor())) ?? [] - if let name = all.first(where: { $0.serverId == id })?.name { - return name - } - return issue.facilityNameCache ?? "Unknown Facility" - } - - private var severityColor: Color { - switch issue.severity { - case "critical": return .red - case "high": return .orange - case "medium": return .yellow - default: return .blue - } - } - - private func issueStatusColor(_ status: String) -> Color { - switch status { - case "open": return .blue - case "in_progress": return .orange - case "pending_verification": return .purple - case "resolved": return .green - default: return .secondary - } - } - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Circle() - .fill(severityColor) - .frame(width: 10, height: 10) - .padding(.top, 5) - VStack(alignment: .leading, spacing: 3) { - HStack { - Text(issue.severity.capitalized) - .font(.caption.bold()) - .foregroundStyle(severityColor) - Spacer() - Text(issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized) - .font(.caption2) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(issueStatusColor(issue.issueStatus).opacity(0.15)) - .foregroundStyle(issueStatusColor(issue.issueStatus)) - .clipShape(Capsule()) - StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) - } - Text(issue.issueDescription) - .font(.callout) - .lineLimit(2) - Text(facilityName) - .font(.caption) - .foregroundStyle(.secondary) - Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - .padding(.vertical, 4) - } -} - -struct IssueDetailView: View { - let issue: LocalIssue - @Environment(\.modelContext) private var context - @EnvironmentObject private var sync: SyncManager - - @State private var isLoadingStatus = false - @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 - @State private var newCommentText = "" - @State private var isPostingComment = false - @State private var commentError: String? - // ── Email PDF ───────────────────────────────────────────────────────── - @State private var isGeneratingPDF = false - @State private var showMailCompose = false - @State private var generatedPDFData: Data? = nil - - // ── Handler ("Handled By", phase35) editing state ────────────────────── - @State private var isEditingHandler = false - @State private var handlerDraftType = "internal" // internal | facility | vendor - @State private var handlerName = "" - @State private var handlerContact = "" - @State private var handlerNotes = "" - @State private var isSavingHandler = false - @State private var handlerError: String? - - private var facilityName: String { - let id = issue.facilityServerId - return (try? context.fetch( - FetchDescriptor(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Unknown Facility" - } - - private var severityColor: Color { - switch issue.severity { - case "critical": return .red - case "high": return .orange - case "medium": return .yellow - default: return .blue - } - } - - /// Inspector can update status only if the issue has synced (has a serverId) - /// and we are online. Admins/directors can always update when online. - private var canUpdateStatus: Bool { - guard sync.isOnline, issue.serverId != nil else { return false } - let role = AuthManager.shared.currentUserRole - return role == "admin" || role == "director" || role == "inspector" - } - - private let allStatuses: [(value: String, label: String, color: Color)] = [ - ("open", "Open", .blue), - ("in_progress", "In Progress", .orange), - ("pending_verification", "Pending Verification", .purple), - ("resolved", "Resolved", .green), - ] - - /// Who may change the handler from the iPad. Per product decision, the - /// assigned inspector may set it here (the web form limits this to - /// admin/director/PM); the server enforces facility scope for inspectors. - private var canEditHandler: Bool { - guard sync.isOnline, issue.serverId != nil else { return false } - let role = AuthManager.shared.currentUserRole - return role == "admin" || role == "director" - || role == "inspector" || role == "project_manager" - } - - private func handlerTypeLabel(_ type: String) -> String { - switch type { - case "facility": return "Facility Staff" - case "vendor": return "External Vendor" - default: return "Janitorial Staff" - } - } - - private func statusColor(for status: String) -> Color { - allStatuses.first { $0.value == status }?.color ?? .secondary - } - - private func statusLabel(for status: String) -> String { - allStatuses.first { $0.value == status }?.label - ?? status.replacingOccurrences(of: "_", with: " ").capitalized - } - - var body: some View { - List { - Section("Issue Details") { - LabeledContent("Severity") { - Text(issue.severity.capitalized) - .foregroundStyle(severityColor) - .fontWeight(.semibold) - } - LabeledContent("Facility", value: facilityName) - if let area = issue.areaNameCache, !area.isEmpty { - LabeledContent("Area", value: area) - } - if let assignee = issue.assignedToName, !assignee.isEmpty { - LabeledContent("Assigned To", value: assignee) - } - // Use serverReportedAt when available — more accurate than - // createdAt (device time) for server-pulled issues. - let reportDate = issue.serverReportedAt ?? issue.createdAt - LabeledContent("Reported", value: reportDate.formatted( - date: .long, time: .shortened)) - if let reporter = issue.reportedByName, !reporter.isEmpty { - LabeledContent("Reporter", value: reporter) - } - - // ── Issue Status ─────────────────────────────────────────── - LabeledContent("Issue Status") { - HStack(spacing: 6) { - if isLoadingStatus { - ProgressView().scaleEffect(0.7) - } else { - Text(statusLabel(for: issue.issueStatus)) - .foregroundStyle(statusColor(for: issue.issueStatus)) - .fontWeight(.semibold) - } - } - } - - // ── Status picker (online + synced only) ─────────────────── - if canUpdateStatus { - if isUpdatingStatus { - HStack { - ProgressView() - Text("Updating…").foregroundStyle(.secondary).font(.callout) - } - } else { - Picker("Change Status", selection: Binding( - get: { issue.issueStatus }, - set: { newStatus in - Task { await changeStatus(to: newStatus) } - } - )) { - ForEach(allStatuses, id: \.value) { s in - Text(s.label).tag(s.value) - } - } - .pickerStyle(.menu) - .tint(statusColor(for: issue.issueStatus)) - } - } - - if let err = statusError { - Text(err).font(.caption).foregroundStyle(.red) - } - } - - // ── Handled By (phase35) ─────────────────────────────────────── - handledBySection - - // ── 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) - } - - // ── Resolution Details (web-staff only — read-only on iPad) ──── - if let notes = issue.resultNotes, !notes.isEmpty { - Section("Resolution Notes") { - Text(notes) - .font(.callout) - .foregroundStyle(.primary) - } - } - - // ── Verification Details ─────────────────────────────────────── - if let vAt = issue.verifiedAt { - Section("Verification") { - LabeledContent("Verified", value: vAt.formatted( - date: .long, time: .shortened)) - if let note = issue.verificationNote, !note.isEmpty { - Text(note) - .font(.callout) - .foregroundStyle(.secondary) - } - } - } - - Section("Sync Status") { - LabeledContent("Sync") { - StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) - } - if let err = issue.syncErrorMessage { - Text(err).font(.caption).foregroundStyle(.red) - } - if issue.syncRetryCount > 0 { - LabeledContent("Retry Count", value: "\(issue.syncRetryCount)") - } - } - - // ── Photo display logic ──────────────────────────────────────── - // While the issue is pending (not yet submitted to the server), - // show only local photos from disk — photoServerPaths may be - // partially populated from mid-sync photo uploads, causing a mix - // of working and broken images. Once synced, photoLocalPaths is - // cleared and only the server paths section renders. - if issue.syncStatus != "synced" { - // Pending / failed: show local files only - 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) - } - } - } - } - } else { - // Synced: show server photos only - if !issue.photoServerPaths.isEmpty { - Section("Photos (\(issue.photoServerPaths.count))") { - ForEach(issue.photoServerPaths, id: \.self) { relativePath in - RetryablePhotoView( - url: URL(string: ServerConfig.current + "/static/" + relativePath) - ) - } - } - } - } - - // ── Comments ────────────────────────────────────────────────── - if sync.isOnline, issue.serverId != nil { - if isLoadingComments { - Section("Comments") { - HStack { Spacer(); ProgressView(); Spacer() } - } - } else if !comments.isEmpty { - Section("Comments (\(comments.count))") { - ForEach(comments) { comment in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(comment.authorName) - .font(.caption.bold()) - Spacer() - if let date = comment.createdAtDate { - Text(date.formatted(.relative(presentation: .named))) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - Text(comment.body) - .font(.callout) - } - .padding(.vertical, 2) - } - } - } - - // ── Add comment ──────────────────────────────────────────── - Section("Add Comment") { - TextEditor(text: $newCommentText) - .frame(minHeight: 60) - if let err = commentError { - Text(err).font(.caption).foregroundStyle(.red) - } - Button { - Task { await postComment() } - } label: { - if isPostingComment { - HStack { ProgressView(); Text("Posting…") } - } else { - Label("Post Comment", systemImage: "paperplane.fill") - } - } - .disabled( - newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || isPostingComment - ) - } - } - } - .navigationTitle("Issue Detail") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - Task { await prepareAndShowMail() } - } label: { - if isGeneratingPDF { - ProgressView() - } else { - Label("Share via Email", systemImage: "envelope") - } - } - .disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF) - } - } - .sheet(isPresented: $showMailCompose) { - if let pdfData = generatedPDFData { - MailComposeView( - subject: "Issue Report — Issue #\(issue.serverId ?? 0) (\(facilityName))", - body: issueEmailBody, - pdfData: pdfData, - pdfFilename: "Issue_\(issue.serverId ?? 0)_\(facilityName).pdf" - .replacingOccurrences(of: " ", with: "_") - ) - } - } - .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() - } - } - - /// Generates the issue PDF (re-encoding photos to keep file size minimal, - /// fetching server photos over the network if synced), then presents the - /// mail compose sheet with it attached. - private func prepareAndShowMail() async { - isGeneratingPDF = true - let data = await IssuePDFGenerator.generate(issue: issue, facilityName: facilityName) - generatedPDFData = data - isGeneratingPDF = false - showMailCompose = true - } - - private var issueEmailBody: String { - var lines: [String] = [] - lines.append("ISSUE REPORT — Issue #\(issue.serverId ?? 0)") - lines.append(String(repeating: "=", count: 40)) - lines.append("") - lines.append("Severity : \(issue.severity.capitalized)") - lines.append("Status : \(statusLabel(for: issue.issueStatus))") - lines.append("Facility : \(facilityName)") - if let area = issue.areaNameCache, !area.isEmpty { - lines.append("Area : \(area)") - } - if let assignee = issue.assignedToName, !assignee.isEmpty { - lines.append("Assigned : \(assignee)") - } - let reportDate = issue.serverReportedAt ?? issue.createdAt - lines.append("Reported : \(reportDate.formatted(date: .long, time: .shortened))") - lines.append("") - lines.append("DESCRIPTION") - lines.append(String(repeating: "-", count: 40)) - lines.append(issue.issueDescription) - lines.append("") - lines.append("— Sent from JanitorialQC Inspector") - return lines.joined(separator: "\n") - } - - // ── Fetch fresh status from server ──────────────────────────────────── - - private func refreshStatusFromServer() async { - guard sync.isOnline, let sid = issue.serverId else { return } - isLoadingStatus = true - statusError = nil - defer { isLoadingStatus = false } - do { - let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid) - issue.issueStatus = detail.status - // Refresh Phase A resolution fields from live server data - if let notes = detail.resultNotes { issue.resultNotes = notes } - if let vNote = detail.verificationNote { issue.verificationNote = vNote } - if let rName = detail.reportedByName { issue.reportedByName = rName } - if let fName = detail.facilityName, !fName.isEmpty { - issue.facilityNameCache = fName - } - if let vts = detail.verifiedAt, - let date = SyncManager.isoFormatter.date(from: vts) { - issue.verifiedAt = date - } - if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area } - if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee } - // Refresh handler ("Handled By") from live server data. Skipped - // while the inspector is mid-edit so their draft isn't disturbed. - if !isEditingHandler { - issue.handlerType = detail.handlerType - issue.handlerLabel = detail.handlerLabel - issue.facilityHandlerName = detail.facilityHandlerName - issue.facilityHandlerContact = detail.facilityHandlerContact - issue.facilityHandlerNotes = detail.facilityHandlerNotes - issue.vendorName = detail.vendorName - issue.vendorContact = detail.vendorContact - issue.vendorNotes = detail.vendorNotes - } - // Refresh resolution photos from server - if !detail.resultPhotos.isEmpty { - issue.resultPhotoServerPaths = detail.resultPhotos - } - try? context.save() - } catch { - // Non-fatal — show cached values silently - } - } - - // ── Load comments from server ────────────────────────────────────────── - - private func loadComments() async { - guard sync.isOnline, let sid = issue.serverId else { return } - isLoadingComments = true - defer { isLoadingComments = false } - do { - comments = try await APIClient.shared.fetchIssueComments(issueId: sid) - } catch { - // Non-fatal — empty list shown - } - } - - // ── Post a new comment ───────────────────────────────────────────────── - - private func postComment() async { - guard let sid = issue.serverId else { return } - let body = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !body.isEmpty else { return } - isPostingComment = true - commentError = nil - defer { isPostingComment = false } - do { - _ = try await APIClient.shared.postIssueComment(issueId: sid, body: body) - newCommentText = "" - // Reload comments so the new one appears - comments = try await APIClient.shared.fetchIssueComments(issueId: sid) - } catch { - commentError = error.localizedDescription - } - } - - // ── Push status change to server ────────────────────────────────────── - - private func changeStatus(to newStatus: String) async { - guard let sid = issue.serverId else { return } - isUpdatingStatus = true - statusError = nil - defer { isUpdatingStatus = false } - do { - let confirmed = try await APIClient.shared.updateIssueStatus( - issueId: sid, status: newStatus - ) - issue.issueStatus = confirmed - try? context.save() - } catch { - statusError = error.localizedDescription - } - } - - // ── Handled By (phase35) ─────────────────────────────────────────────── - - @ViewBuilder - private var handledBySection: some View { - Section("Handled By") { - let type = issue.handlerType ?? "internal" - - LabeledContent("Handler") { - Text(issue.handlerLabel ?? handlerTypeLabel(type)) - .fontWeight(.semibold) - } - - // Current detail — depends on handler category. - switch type { - case "facility": - if let n = issue.facilityHandlerName, !n.isEmpty { - LabeledContent("Name", value: n) - } - if let c = issue.facilityHandlerContact, !c.isEmpty { - LabeledContent("Contact", value: c) - } - if let notes = issue.facilityHandlerNotes, !notes.isEmpty { - VStack(alignment: .leading, spacing: 2) { - Text("Notes").font(.caption).foregroundStyle(.secondary) - Text(notes).font(.callout) - } - } - case "vendor": - if let n = issue.vendorName, !n.isEmpty { - LabeledContent("Vendor", value: n) - } - if let c = issue.vendorContact, !c.isEmpty { - LabeledContent("Contact", value: c) - } - if let notes = issue.vendorNotes, !notes.isEmpty { - VStack(alignment: .leading, spacing: 2) { - Text("Notes").font(.caption).foregroundStyle(.secondary) - Text(notes).font(.callout) - } - } - default: - if let a = issue.assignedToName, !a.isEmpty { - LabeledContent("Staff", value: a) - } - } - - // ── Inspector edit ───────────────────────────────────────────── - if canEditHandler { - if isEditingHandler { - Picker("Type", selection: $handlerDraftType) { - Text("Staff").tag("internal") - Text("Facility").tag("facility") - Text("Vendor").tag("vendor") - } - .pickerStyle(.segmented) - - if handlerDraftType != "internal" { - TextField( - handlerDraftType == "vendor" ? "Vendor name" : "Handler name", - text: $handlerName - ) - TextField("Contact (phone or email)", text: $handlerContact) - TextField("Notes", text: $handlerNotes, axis: .vertical) - .lineLimit(1...4) - } - - if let e = handlerError { - Text(e).font(.caption).foregroundStyle(.red) - } - - HStack { - Button("Cancel") { isEditingHandler = false } - .buttonStyle(.bordered) - Spacer() - Button { - Task { await saveHandler() } - } label: { - if isSavingHandler { - ProgressView() - } else { - Text("Save") - } - } - .buttonStyle(.borderedProminent) - .disabled(isSavingHandler) - } - } else { - Button { - beginEditHandler() - } label: { - Label("Change Handler", systemImage: "person.badge.shield.checkmark") - } - } - } - } - } - - private func beginEditHandler() { - let type = issue.handlerType ?? "internal" - handlerDraftType = type - switch type { - case "vendor": - handlerName = issue.vendorName ?? "" - handlerContact = issue.vendorContact ?? "" - handlerNotes = issue.vendorNotes ?? "" - case "facility": - handlerName = issue.facilityHandlerName ?? "" - handlerContact = issue.facilityHandlerContact ?? "" - handlerNotes = issue.facilityHandlerNotes ?? "" - default: - handlerName = ""; handlerContact = ""; handlerNotes = "" - } - handlerError = nil - isEditingHandler = true - } - - private func saveHandler() async { - guard let sid = issue.serverId else { return } - isSavingHandler = true - handlerError = nil - defer { isSavingHandler = false } - - let type = handlerDraftType - let name = handlerName.trimmingCharacters(in: .whitespacesAndNewlines) - let contact = handlerContact.trimmingCharacters(in: .whitespacesAndNewlines) - let notes = handlerNotes.trimmingCharacters(in: .whitespacesAndNewlines) - - var details: [String: String] = [:] - if type == "facility" { - details["facility_handler_name"] = name - details["facility_handler_contact"] = contact - details["facility_handler_notes"] = notes - } else if type == "vendor" { - details["vendor_name"] = name - details["vendor_contact"] = contact - details["vendor_notes"] = notes - } - - do { - let confirmed = try await APIClient.shared.updateIssueHandler( - issueId: sid, handlerType: type, details: details - ) - // Mirror the change into the local record so the UI reflects it - // immediately; the next pull re-confirms from the server. - issue.handlerType = confirmed - issue.handlerLabel = handlerTypeLabel(confirmed) - if type == "facility" { - issue.facilityHandlerName = name.isEmpty ? nil : name - issue.facilityHandlerContact = contact.isEmpty ? nil : contact - issue.facilityHandlerNotes = notes.isEmpty ? nil : notes - } else if type == "vendor" { - issue.vendorName = name.isEmpty ? nil : name - issue.vendorContact = contact.isEmpty ? nil : contact - issue.vendorNotes = notes.isEmpty ? nil : notes - } - try? context.save() - isEditingHandler = false - } catch { - handlerError = 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 -// Allows inspectors to log an issue directly from the Issues page, -// without being inside an active inspection. The issue is created with -// inspectionLocalId == "" and synced to the server via processIssueQueue. - -struct StandaloneIssueView: View { - - @Environment(\.modelContext) private var context - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var sync: SyncManager - - @Query(sort: \LocalFacility.name) private var facilities: [LocalFacility] - - // ── Contract → Facility cascade (mirrors StartInspectionView) ───────── - @State private var selectedProjectId: Int? = nil - @State private var selectedFacilityId: Int? = nil - - @State private var severity = "medium" - @State private var description = "" - @State private var photos: [(image: UIImage, path: String)] = [] - @State private var showCamera = false - @State private var showLibrary = false - @State private var showBanner = false - - private let maxPhotos = 5 - private let severities = ["low", "medium", "high", "critical"] - - private var cameraAvailable: Bool { - UIImagePickerController.isSourceTypeAvailable(.camera) - } - private var remainingSlots: Int { maxPhotos - photos.count } - - /// Unique contracts derived from cached facilities, sorted by name. - private var contracts: [(id: Int, name: String)] { - var seen = Set() - var result: [(id: Int, name: String)] = [] - for f in facilities { - if seen.insert(f.projectId).inserted { - result.append((id: f.projectId, name: f.projectName)) - } - } - return result.sorted { $0.name < $1.name } - } - - /// Facilities belonging to the selected contract. - /// Facilities for the selected contract, deduplicated by serverId. - private var filteredFacilities: [LocalFacility] { - guard let pid = selectedProjectId else { return [] } - var seen = Set() - return facilities - .filter { $0.projectId == pid } - .filter { seen.insert($0.serverId).inserted } - } - - private var canSubmit: Bool { - selectedFacilityId != nil && - !description.trimmingCharacters(in: .whitespaces).isEmpty - } - - var body: some View { - NavigationStack { - Form { - - // ── Contract picker ──────────────────────────────────────── - Section("Contract") { - if contracts.isEmpty { - Text("No contracts available. Sync required.") - .foregroundStyle(.secondary).font(.callout) - } else { - Picker("Contract", selection: $selectedProjectId) { - Text("Select a contract…").tag(Optional(nil)) - ForEach(contracts, id: \.id) { contract in - Text(contract.name).tag(Optional(contract.id)) - } - } - .pickerStyle(.navigationLink) - .onChange(of: selectedProjectId) { - // Reset facility when contract changes - let facilityBelongsToContract = facilities.contains { - $0.serverId == selectedFacilityId && - $0.projectId == selectedProjectId - } - if !facilityBelongsToContract { - selectedFacilityId = nil - } - } - } - } - - // ── Facility picker (gated on contract selection) ────────── - if selectedProjectId != nil { - Section("Facility") { - if filteredFacilities.isEmpty { - Text("No facilities in this contract.") - .foregroundStyle(.secondary).font(.callout) - } else { - Picker("Facility", selection: $selectedFacilityId) { - Text("Select a facility…").tag(Optional(nil)) - ForEach(filteredFacilities) { facility in - Text(facility.name).tag(Optional(facility.serverId)) - } - } - .pickerStyle(.navigationLink) - } - } - } - - // ── Severity ─────────────────────────────────────────────── - Section("Severity") { - Picker("Severity", selection: $severity) { - ForEach(severities, id: \.self) { s in - Text(s.capitalized).tag(s) - } - } - .pickerStyle(.segmented) - } - - // ── Description ──────────────────────────────────────────── - Section("Description") { - TextEditor(text: $description) - .frame(minHeight: 100) - } - - // ── Photos ───────────────────────────────────────────────── - Section { - 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)) - 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) - } - } - if remainingSlots > 0 { - let countLabel = photos.isEmpty - ? "Up to \(maxPhotos) photos" - : "\(photos.count)/\(maxPhotos) — \(remainingSlots) remaining" - Text(countLabel).font(.caption).foregroundStyle(.secondary) - if cameraAvailable { - Button { showCamera = true } label: { - HStack { - Image(systemName: "camera.fill").font(.title3).frame(width: 36) - Text("Take Photo") - Spacer() - } - .padding(.vertical, 10).contentShape(Rectangle()) - } - .foregroundStyle(.primary) - } - Button { showLibrary = true } label: { - HStack { - Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36) - Text("Choose from Library") - 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) } - } - - // ── Offline notice ───────────────────────────────────────── - if !sync.isOnline { - Section { - Label("You\'re offline — this issue will sync automatically.", - systemImage: "wifi.slash") - .font(.callout).foregroundStyle(.orange) - } - } - } - .navigationTitle("New Issue") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Submit") { submitIssue() } - .disabled(!canSubmit) - .fontWeight(.semibold) - } - } - .overlay(alignment: .top) { - if showBanner { - HStack(spacing: 12) { - Image(systemName: "checkmark.circle.fill") - .font(.title2).foregroundStyle(.green) - VStack(alignment: .leading, spacing: 2) { - Text("Issue Logged").font(.headline) - Text(sync.isOnline ? "Submitted to server." : "Saved — will sync when online.") - .font(.caption).foregroundStyle(.secondary) - } - Spacer() - } - .padding(16) - .background(Color(.secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: .black.opacity(0.1), radius: 8, y: 4) - .padding(.horizontal, 24).padding(.top, 8) - .transition(.move(edge: .top).combined(with: .opacity)) - .zIndex(10) - } - } - .animation(.spring(duration: 0.35), value: showBanner) - .fullScreenCover(isPresented: $showCamera) { - CameraPickerView(image: .constant(nil), onSelected: appendPhoto) - .ignoresSafeArea() - } - .sheet(isPresented: $showLibrary) { - MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos) - } - } - } - - // ── Photo helpers ───────────────────────────────────────────────────── - - private func appendPhoto(_ img: UIImage) { - guard photos.count < maxPhotos, 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, let path = savePhotoToDisk(img) else { break } - photos.append((image: img, path: path)) - } - } - - private func removePhoto(at index: Int) { - guard index < photos.count else { return } - 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 fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") - try? data.write(to: fileURL) - return fileURL.path - } - - // ── Submit ──────────────────────────────────────────────────────────── - - private func submitIssue() { - guard let facilityId = selectedFacilityId else { return } - let issue = LocalIssue( - inspectionLocalId: "", // standalone — not tied to any inspection - facilityServerId: facilityId, - severity: severity, - description: description.trimmingCharacters(in: .whitespaces) - ) - issue.photoLocalPaths = photos.map(\.path) - context.insert(issue) - - for photo in photos { - let pending = PendingPhoto( - localFilePath: photo.path, - entityType: "issue", - entityLocalId: issue.localId - ) - context.insert(pending) - } - - try? context.save() - - if sync.isOnline { Task { await sync.triggerSync() } } - - withAnimation { showBanner = true } - Task { - try? await Task.sleep(for: .seconds(2)) - dismiss() - } - } -} diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/MyInspectionsView.swift deleted file mode 100644 index e9a7bb2..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/MyInspectionsView.swift +++ /dev/null @@ -1,348 +0,0 @@ -// Views/Dashboard/MyInspectionsView.swift -import SwiftUI -import SwiftData -import MessageUI - -// MARK: - My Inspections - -struct MyInspectionsView: View { - - @Query( - filter: #Predicate { $0.status != "synced" }, - sort: \LocalInspection.lastModifiedAt, - order: .reverse - ) private var inspections: [LocalInspection] - - /// Scheduled assignments (phase36) — rendered as the top section and used - /// for the empty-state decision. Sorted by due date (ISO strings sort - /// chronologically). - @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) - private var scheduledAll: [LocalScheduledInspection] - - @Environment(\.modelContext) private var context - - @State private var showNewInspection = false - @State private var scheduledStartTarget: LocalScheduledInspection? - - // Deletion confirmation state - @State private var pendingDelete: LocalInspection? - @State private var showDeleteAlert = false - - var body: some View { - Group { - if inspections.isEmpty && scheduledAll.isEmpty { - ContentUnavailableView( - "No Inspections", - systemImage: "checklist", - description: Text("Tap + to start a new inspection.") - ) - } else { - List { - // Scheduled assignments (phase36) — self-hides when empty. - if !scheduledAll.isEmpty { - Section("Scheduled") { - ForEach(scheduledAll) { s in - Button { scheduledStartTarget = s } label: { - ScheduledRow(schedule: s) - } - .buttonStyle(.plain) - } - } - } - - if !inspections.isEmpty { - Section("In Progress") { - ForEach(inspections) { inspection in - NavigationLink(value: inspection) { - InspectionRowView(inspection: inspection, context: context) - } - // Only drafts may be deleted — submitted/pending-sync inspections are kept - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if inspection.status == "draft" { - Button(role: .destructive) { - pendingDelete = inspection - showDeleteAlert = true - } label: { - Label("Delete", systemImage: "trash") - } - } - } - } - } - } - } - // Cover attached to the stable List, not a Section. - .fullScreenCover(item: $scheduledStartTarget) { s in - StartInspectionView( - preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId - ) - } - } - } - .navigationTitle("My Inspections") - // Confirmation before deletion — destructive action cannot be undone - .alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in - Button("Delete", role: .destructive) { deleteDraft(inspection) } - Button("Cancel", role: .cancel) { pendingDelete = nil } - } message: { inspection in - Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.") - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { showNewInspection = true } label: { - Image(systemName: "plus") - } - } - } - .sheet(isPresented: $showNewInspection) { - StartInspectionView() - } - } - - private func draftName(_ inspection: LocalInspection) -> String { - let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate - return (try? context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.serverId == templateId } - ) - ).first?.name) ?? "this inspection" - } - - private func deleteDraft(_ inspection: LocalInspection) { - // Delete associated pending photos from disk and SwiftData - for photo in inspection.pendingPhotos { - try? FileManager.default.removeItem(atPath: photo.localFilePath) - context.delete(photo) - } - // Delete associated local issues - for issue in inspection.localIssues { - for path in issue.photoLocalPaths { - try? FileManager.default.removeItem(atPath: path) - } - context.delete(issue) - } - context.delete(inspection) - try? context.save() - pendingDelete = nil - } -} - -struct InspectionRowView: View { - let inspection: LocalInspection - let context: ModelContext - - private var facilityName: String { - let id = inspection.facilityServerId - return (try? context.fetch( - FetchDescriptor(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Unknown Facility" - } - - private var templateName: String { - let id = inspection.templateServerId - return (try? context.fetch( - FetchDescriptor(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Unknown Template" - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(templateName).font(.headline) - Spacer() - StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) - } - Text(facilityName).font(.callout).foregroundStyle(.secondary) - HStack { - Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2).foregroundStyle(.tertiary) - if let score = inspection.overallScore { - Spacer() - Text(String(format: "%.1f%%", score)) - .font(.caption).fontWeight(.medium) - .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) - } - } - // ── Follow-up badge ──────────────────────────────────────────── - if inspection.followUpRequired { - HStack(spacing: 4) { - Image(systemName: "exclamationmark.arrow.circlepath") - .font(.caption2) - Text("Follow-up Required") - .font(.caption2.bold()) - } - .padding(.horizontal, 8).padding(.vertical, 3) - .background(Color.orange.opacity(0.15)) - .foregroundStyle(.orange) - .clipShape(Capsule()) - } - } - .padding(.vertical, 4) - } -} - -struct StatusBadge: View { - let status: String - let syncStatus: String - - var label: String { - switch status { - case "draft": return "Draft" - case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed" - case "failed": return "Sync Failed" - default: return status.capitalized - } - } - - var color: Color { - switch status { - case "draft": return .blue - case "completed": return syncStatus == "pending" ? .orange : .green - case "failed": return .red - default: return .secondary - } - } - - var body: some View { - Text(label) - .font(.caption2) - .padding(.horizontal, 8).padding(.vertical, 3) - .background(color.opacity(0.15)) - .foregroundStyle(color) - .clipShape(Capsule()) - } -} - -// MARK: - Completed Inspection (read-only) - -struct CompletedInspectionView: View { - let inspection: LocalInspection - @Environment(\.modelContext) private var context - - @State private var showReInspect = false - - private var templateName: String { - let id = inspection.templateServerId - return (try? context.fetch( - FetchDescriptor(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Inspection" - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - - // ── Follow-up required banner ────────────────────────────── - if inspection.followUpRequired { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "exclamationmark.arrow.circlepath") - .foregroundStyle(.orange) - .font(.title3) - VStack(alignment: .leading, spacing: 4) { - Text("Follow-up Inspection Required") - .font(.callout.bold()) - .foregroundStyle(.orange) - if let note = inspection.followUpNote, !note.isEmpty { - Text(note) - .font(.callout) - .foregroundStyle(.secondary) - } - Button { - showReInspect = true - } label: { - Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill") - .font(.callout.bold()) - } - .buttonStyle(.borderedProminent) - .tint(.orange) - .padding(.top, 4) - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal) - } - - // ── Is a re-inspection — parent link ─────────────────────── - if let parentId = inspection.parentServerId { - HStack(spacing: 10) { - Image(systemName: "arrow.uturn.right.circle") - .foregroundStyle(.secondary) - Text("Re-inspection of inspection #\(parentId)") - .font(.callout) - .foregroundStyle(.secondary) - } - .padding(.horizontal) - } - - GroupBox { - VStack(alignment: .leading, spacing: 8) { - if let score = inspection.overallScore { - HStack { - Text("Overall Score").font(.subheadline).foregroundStyle(.secondary) - Spacer() - Text(String(format: "%.1f%%", score)) - .font(.title2.bold()) - .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) - } - } - if let completedAt = inspection.completedAt { - HStack { - Text("Completed").font(.subheadline).foregroundStyle(.secondary) - Spacer() - Text(completedAt.formatted(date: .abbreviated, time: .shortened)) - .font(.callout) - } - } - HStack { - Text("Sync Status").font(.subheadline).foregroundStyle(.secondary) - Spacer() - StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) - } - if let error = inspection.syncErrorMessage { - Text("Error: \(error)").font(.caption).foregroundStyle(.red) - } - } - } - .padding(.horizontal) - - if !inspection.localIssues.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Flagged Issues (\(inspection.localIssues.count))") - .font(.headline).padding(.horizontal) - ForEach(inspection.localIssues) { issue in - HStack(alignment: .top, spacing: 12) { - Circle() - .fill(issue.severity == "critical" ? Color.red : - issue.severity == "high" ? Color.orange : - issue.severity == "medium" ? Color.yellow : Color.blue) - .frame(width: 8, height: 8).padding(.top, 4) - VStack(alignment: .leading, spacing: 2) { - Text(issue.severity.capitalized) - .font(.caption.bold()).foregroundStyle(.secondary) - Text(issue.issueDescription).font(.callout) - } - } - .padding(.horizontal) - } - } - } - } - .padding(.vertical) - } - .navigationTitle(templateName) - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showReInspect) { - StartInspectionView( - preFillTemplateId: inspection.templateServerId, - preFillFacilityId: inspection.facilityServerId, - parentServerId: inspection.serverId, - parentLocalId: inspection.localId - ) - } - } -} - diff --git a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/ScheduledInspectionsView.swift b/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/ScheduledInspectionsView.swift deleted file mode 100644 index 7cd6f71..0000000 --- a/JanitorialQC/Assets.xcassets/AppIcon.appiconset/Views/Dashboard/ScheduledInspectionsView.swift +++ /dev/null @@ -1,113 +0,0 @@ -// Views/Dashboard/ScheduledInspectionsView.swift -// ---------------------------------------------- -// Displays the inspector's planned/recurring inspection assignments (phase36), -// pulled read-only from GET /api/v1/scheduled-inspections by -// SyncManager.pullScheduledInspections(). -// -// Two consumers share one ScheduledRow: -// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView -// • MyInspectionsView renders its own "Scheduled" List section inline, -// reusing ScheduledRow, with the start cover attached to the List. -// Both self-hide when there are no scheduled inspections and present -// StartInspectionView (facility + template preselected) when a row is tapped. -// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping -// "Start" simply seeds the normal new-inspection flow. - -import SwiftUI -import SwiftData - -// MARK: - Shared row - -struct ScheduledRow: View { - let schedule: LocalScheduledInspection - - private var dueText: String { - if let d = schedule.nextDue { - return d.formatted(date: .abbreviated, time: .omitted) - } - return schedule.dueDateString.isEmpty ? "—" : schedule.dueDateString - } - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "calendar.badge.clock") - .font(.title3) - .foregroundStyle(schedule.isOverdue ? .red : .blue) - .padding(.top, 2) - - VStack(alignment: .leading, spacing: 3) { - Text(schedule.templateName.isEmpty ? "Inspection" : schedule.templateName) - .font(.callout.bold()) - Text(schedule.facilityName.isEmpty ? "Facility" : schedule.facilityName) - .font(.caption) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - if schedule.isOverdue { - Text("Overdue") - .font(.caption2.bold()) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(Color.red.opacity(0.15)) - .foregroundStyle(.red) - .clipShape(Capsule()) - } - Text("Due \(dueText)") - .font(.caption2) - .foregroundStyle(schedule.isOverdue ? .red : .secondary) - if !schedule.frequencyLabel.isEmpty { - Text("· \(schedule.frequencyLabel)") - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - } - - Spacer(minLength: 8) - - Label("Start", systemImage: "play.fill") - .font(.caption.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 10).padding(.vertical, 5) - .background(schedule.isOverdue ? Color.red : Color.blue) - .clipShape(Capsule()) - } - .contentShape(Rectangle()) - } -} - -// MARK: - Dashboard card (VStack) - -struct ScheduledInspectionsCard: View { - @Query(sort: \LocalScheduledInspection.dueDateString, order: .forward) - private var scheduled: [LocalScheduledInspection] - - @State private var startTarget: LocalScheduledInspection? = nil - - var body: some View { - if !scheduled.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text("SCHEDULED") - .font(.caption.bold()) - .foregroundStyle(.secondary) - .tracking(1) - - ForEach(scheduled) { s in - Button { startTarget = s } label: { - ScheduledRow(schedule: s) - .padding(12) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .buttonStyle(.plain) - } - } - // Cover attached to the stable VStack root (mirrors DraftResumeBanner). - .fullScreenCover(item: $startTarget) { s in - StartInspectionView( - preFillTemplateId: s.templateServerId, - preFillFacilityId: s.facilityServerId - ) - } - } - } -} diff --git a/JanitorialQC/Models/PendingPhoto.swift b/JanitorialQC/Models/PendingPhoto.swift index 0923441..ee5dbf2 100644 --- a/JanitorialQC/Models/PendingPhoto.swift +++ b/JanitorialQC/Models/PendingPhoto.swift @@ -25,21 +25,39 @@ final class PendingPhoto { var uploadStatus: String var createdAt: Date + // ── Capture metadata (sent to the server, burned into the photo) ─────── + // Recorded when the shutter fires, NOT when the upload runs — the app is + // offline-first, so a photo taken at 09:14 may not sync until 16:00 and + // the stamp must show 09:14. EXIF cannot serve as a fallback here: the + // photo is re-encoded via jpegData() on save, which strips every tag. + // Optional so existing SwiftData stores migrate without a schema step. + var capturedAt: Date? + var captureLatitude: Double? + var captureLongitude: Double? + var inspection: LocalInspection? init( localFilePath: String, entityType: String, entityLocalId: String, - fieldId: String? = nil + fieldId: String? = nil, + capturedAt: Date? = nil, + captureLatitude: Double? = nil, + captureLongitude: Double? = nil ) { - self.localId = UUID().uuidString - self.localFilePath = localFilePath - self.entityType = entityType - self.entityLocalId = entityLocalId - self.fieldId = fieldId - self.serverPath = nil - self.uploadStatus = "pending" - self.createdAt = Date() + self.localId = UUID().uuidString + self.localFilePath = localFilePath + self.entityType = entityType + self.entityLocalId = entityLocalId + self.fieldId = fieldId + self.serverPath = nil + self.uploadStatus = "pending" + self.createdAt = Date() + // Fall back to now when the caller has no recorded capture moment — + // still far better than the server's upload-time default. + self.capturedAt = capturedAt ?? Date() + self.captureLatitude = captureLatitude + self.captureLongitude = captureLongitude } } diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 8ca027e..07d91f5 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -301,9 +301,15 @@ class SyncManager: ObservableObject { for photo in pending { do { + // Capture metadata was recorded at the shutter, not now — the + // sync may run hours after an offline capture, and the server + // burns these values into the photo as its timestamp/GPS bar. let serverPath = try await APIClient.shared.uploadPhoto( localPath: photo.localFilePath, - entityType: photo.entityType + entityType: photo.entityType, + capturedAt: photo.capturedAt, + latitude: photo.captureLatitude, + longitude: photo.captureLongitude ) photo.serverPath = serverPath photo.uploadStatus = "uploaded" diff --git a/JanitorialQC/Utils/PhotoCapture.swift b/JanitorialQC/Utils/PhotoCapture.swift new file mode 100644 index 0000000..9839166 --- /dev/null +++ b/JanitorialQC/Utils/PhotoCapture.swift @@ -0,0 +1,120 @@ +// Utils/PhotoCapture.swift +// ------------------------ +// Capture-time metadata for evidence photos. +// +// The server burns a timestamp + GPS overlay into every photo uploaded through +// POST /api/v1/photos/upload (see photo_stamp.py in the Flask app). It resolves +// that metadata from the client form fields first, then the image's own EXIF, +// then — last resort — server receipt time. +// +// EXIF is NOT a usable fallback for this app: savePhotoToDisk() re-encodes each +// UIImage via jpegData(compressionQuality:), which strips every EXIF tag. So +// these client fields are the ONLY source of true capture time and location. +// Without them, a photo taken offline at 09:14 and synced at 16:00 is stamped +// 16:00 — the wrong time, on evidence. +// +// Hence the flow: record the moment + fix AT CAPTURE, carry them through +// CapturedPhoto -> PendingPhoto -> the upload request. + +import Foundation +import UIKit +import CoreLocation + +// MARK: - CapturedPhoto + +/// A photo the user just took or picked, together with the metadata recorded +/// at that instant. Property names match the tuple this replaced +/// (`image`, `path`), so existing call sites keep compiling. +struct CapturedPhoto { + + let image: UIImage + let path: String + let capturedAt: Date + let latitude: Double? + let longitude: Double? + + /// Stamps "now" plus the freshest GPS fix available at the moment of capture. + init(image: UIImage, path: String) { + let fix = PhotoLocationProvider.shared.lastLocation + self.image = image + self.path = path + self.capturedAt = Date() + self.latitude = fix?.coordinate.latitude + self.longitude = fix?.coordinate.longitude + } +} + +// MARK: - PhotoLocationProvider + +/// Shared, always-warm location source for photo capture. +/// +/// One long-lived CLLocationManager: views call `start()` in `onAppear` so a +/// fix already exists the instant the shutter fires. Kept separate from +/// ExecuteInspectionView's `InspectionLocationManager` (phase 25 submit GPS), +/// which is per-view and would start cold on every photo screen. +final class PhotoLocationProvider: NSObject, CLLocationManagerDelegate { + + static let shared = PhotoLocationProvider() + + private let manager = CLLocationManager() + + /// Most recent fix, or nil if unavailable/denied. + private(set) var lastLocation: CLLocation? + + private override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + manager.distanceFilter = 10 + } + + /// Request permission if needed and begin updating. Safe to call repeatedly. + /// Requires NSLocationWhenInUseUsageDescription in the target's Info settings. + func start() { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + // locationManagerDidChangeAuthorization starts updates once granted. + case .authorizedWhenInUse, .authorizedAlways: + manager.startUpdatingLocation() + default: + break // denied/restricted — lat/lng stay nil; the timestamp is still stamped + } + } + + func stop() { + manager.stopUpdatingLocation() + } + + // CLLocationManagerDelegate + + func locationManager(_ m: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + if let loc = locations.last { lastLocation = loc } + } + + func locationManager(_ m: CLLocationManager, didFailWithError error: Error) { + // Non-fatal — photos still upload, just without coordinates. + print("[JQC] Photo location fix failed: \(error.localizedDescription)") + } + + func locationManagerDidChangeAuthorization(_ m: CLLocationManager) { + if m.authorizationStatus == .authorizedWhenInUse || + m.authorizationStatus == .authorizedAlways { + manager.startUpdatingLocation() + } + } +} + +// MARK: - Wire format + +enum PhotoCaptureFormat { + + /// ISO-8601 with an explicit offset — the format the server's + /// `_parse_client_datetime()` expects. A naive string with no offset would + /// be read as Eastern wall time, so the offset must always be present. + static let iso8601: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() +} diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index a844165..085c0fd 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -643,11 +643,17 @@ struct ExecuteInspectionView: View { private func handlePhotoSelected(localPath: String, field: [String: Any]) { let fid = fieldId(field) formValues[fid] = "local://\(localPath)" + // Stamp capture time + the current fix now; the upload may be hours + // later on a slow sync and must not use its own clock. + let fix = locationManager.lastLocation ?? PhotoLocationProvider.shared.lastLocation let photo = PendingPhoto( - localFilePath: localPath, - entityType: "inspection", - entityLocalId: inspection.localId, - fieldId: fid + localFilePath: localPath, + entityType: "inspection", + entityLocalId: inspection.localId, + fieldId: fid, + capturedAt: Date(), + captureLatitude: fix?.coordinate.latitude, + captureLongitude: fix?.coordinate.longitude ) inspection.pendingPhotos.append(photo) context.insert(photo) diff --git a/JanitorialQC/Views/Dashboard/FlagIssueView.swift b/JanitorialQC/Views/Dashboard/FlagIssueView.swift index bf9a3ef..dc63c73 100644 --- a/JanitorialQC/Views/Dashboard/FlagIssueView.swift +++ b/JanitorialQC/Views/Dashboard/FlagIssueView.swift @@ -24,7 +24,9 @@ struct FlagIssueView: View { @State private var description = "" // Each entry: (UIImage for display, local file path for storage) - @State private var photos: [(image: UIImage, path: String)] = [] + // CapturedPhoto records the capture moment + GPS fix at shutter time; the + // `image` / `path` members match the tuple this replaced. + @State private var photos: [CapturedPhoto] = [] @State private var showCamera = false @State private var showLibrary = false @@ -190,6 +192,9 @@ struct FlagIssueView: View { } .navigationTitle("Flag Issue") .navigationBarTitleDisplayMode(.inline) + // Warm up GPS so a fix exists the instant a photo is taken; the + // coordinates are burned into the photo server-side. + .onAppear { PhotoLocationProvider.shared.start() } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } @@ -242,14 +247,14 @@ struct FlagIssueView: View { private func appendPhoto(_ img: UIImage) { guard photos.count < maxPhotos else { return } guard let path = savePhotoToDisk(img) else { return } - photos.append((image: img, path: path)) + photos.append(CapturedPhoto(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)) + photos.append(CapturedPhoto(image: img, path: path)) } } @@ -290,9 +295,12 @@ struct FlagIssueView: View { // 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 + localFilePath: photo.path, + entityType: "issue", + entityLocalId: issue.localId, + capturedAt: photo.capturedAt, + captureLatitude: photo.latitude, + captureLongitude: photo.longitude ) context.insert(pending) } diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift index b1bc898..2a7085a 100644 --- a/JanitorialQC/Views/Dashboard/IssuesView.swift +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -240,7 +240,8 @@ struct IssueDetailView: View { @State private var statusError: String? @State private var showStatusPicker = false // ── Resolution Photos ───────────────────────────────────────────────── - @State private var resultPhotos: [(image: UIImage, path: String)] = [] + // Resolution photos are evidence too — record capture time + GPS at shutter. + @State private var resultPhotos: [CapturedPhoto] = [] @State private var showResultCamera = false @State private var showResultLibrary = false @State private var isUploadingResultPhotos = false @@ -641,6 +642,8 @@ struct IssueDetailView: View { } .navigationTitle("Issue Detail") .navigationBarTitleDisplayMode(.inline) + // Warm up GPS so resolution photos taken here carry coordinates. + .onAppear { PhotoLocationProvider.shared.start() } .toolbar { ToolbarItem(placement: .primaryAction) { Button { @@ -981,14 +984,14 @@ struct IssueDetailView: View { private func appendResultPhoto(_ img: UIImage) { guard resultPhotos.count < maxResultPhotos, let path = saveResultPhotoToDisk(img) else { return } - resultPhotos.append((image: img, path: path)) + resultPhotos.append(CapturedPhoto(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)) + resultPhotos.append(CapturedPhoto(image: img, path: path)) } } @@ -1024,7 +1027,12 @@ struct IssueDetailView: View { do { var serverPaths: [String] = [] for photo in resultPhotos { - let path = try await APIClient.shared.uploadResultPhoto(localPath: photo.path) + let path = try await APIClient.shared.uploadResultPhoto( + localPath: photo.path, + capturedAt: photo.capturedAt, + latitude: photo.latitude, + longitude: photo.longitude + ) serverPaths.append(path) } @@ -1066,7 +1074,9 @@ struct StandaloneIssueView: View { @State private var severity = "medium" @State private var description = "" - @State private var photos: [(image: UIImage, path: String)] = [] + // CapturedPhoto records the capture moment + GPS fix at shutter time; the + // `image` / `path` members match the tuple this replaced. + @State private var photos: [CapturedPhoto] = [] @State private var showCamera = false @State private var showLibrary = false @State private var showBanner = false @@ -1238,6 +1248,9 @@ struct StandaloneIssueView: View { } .navigationTitle("New Issue") .navigationBarTitleDisplayMode(.inline) + // Warm up GPS so a fix exists the instant a photo is taken; the + // coordinates are burned into the photo server-side. + .onAppear { PhotoLocationProvider.shared.start() } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } @@ -1284,13 +1297,13 @@ struct StandaloneIssueView: View { private func appendPhoto(_ img: UIImage) { guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { return } - photos.append((image: img, path: path)) + photos.append(CapturedPhoto(image: img, path: path)) } private func appendPhotos(_ images: [UIImage]) { for img in images { guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { break } - photos.append((image: img, path: path)) + photos.append(CapturedPhoto(image: img, path: path)) } } @@ -1325,9 +1338,12 @@ struct StandaloneIssueView: View { for photo in photos { let pending = PendingPhoto( - localFilePath: photo.path, - entityType: "issue", - entityLocalId: issue.localId + localFilePath: photo.path, + entityType: "issue", + entityLocalId: issue.localId, + capturedAt: photo.capturedAt, + captureLatitude: photo.latitude, + captureLongitude: photo.longitude ) context.insert(pending) }