diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index fab4553..cf214d2 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -219,6 +219,36 @@ actor APIClient { return result.status } + // ── Notification polling (Phase C) ──────────────────────────────────── + + /// 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 { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + ep += "?since=\(fmt.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 + } + // ── Token Refresh ───────────────────────────────────────────────────── private func refreshAccessToken() async -> Bool { diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift index d7f8ba3..e6da3dc 100644 --- a/JanitorialQC/API/APIModels.swift +++ b/JanitorialQC/API/APIModels.swift @@ -378,3 +378,101 @@ struct APIIssueStatusUpdate: Decodable, Sendable { } private enum CodingKeys: String, CodingKey { case issueId, status } } + +// ── 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 issue photo (relative server path) + let resultPhotos: [String] // resolution photos (relative server paths) + + 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) + resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] + } + private enum CodingKeys: String, CodingKey { + case id, status, severity, description, assignedTo + case facilityId, facilityName, reportedAt, mobileLocalId + case photoPath, resultPhotos + } +} + +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 } +} diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift index 7a106fa..691a729 100644 --- a/JanitorialQC/JanitorialQCApp.swift +++ b/JanitorialQC/JanitorialQCApp.swift @@ -5,6 +5,7 @@ import SwiftUI import SwiftData import BackgroundTasks +import UserNotifications @main struct JanitorialQCApp: App { @@ -14,6 +15,7 @@ struct JanitorialQCApp: App { init() { registerBackgroundTasks() + requestNotificationPermission() } var body: some Scene { @@ -44,6 +46,19 @@ struct JanitorialQCApp: App { } } + // ── Local notification permission ───────────────────────────────────── + // Request permission for local notifications (used by the polling poller + // to alert inspectors of new assignments and follow-up requests). + // Called at init — iOS caches the grant; subsequent calls are no-ops. + + 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", diff --git a/JanitorialQC/Models/LocalInspection.swift b/JanitorialQC/Models/LocalInspection.swift index e1d7f76..9774baa 100644 --- a/JanitorialQC/Models/LocalInspection.swift +++ b/JanitorialQC/Models/LocalInspection.swift @@ -126,11 +126,24 @@ final class LocalInspection { var earned = 0 for field in scoreable { - guard let fid = field["id"] as? String ?? (field["id"].map { "\($0)" }), - let ftype = field["type"] as? String - else { continue } + // Explicit cast required — field["id"] arrives as Int from JSONSerialization. + // Using Optional.map { "\($0)" } on Any? wraps in a second Optional, + // producing "Optional(5)" instead of "5", so all formData lookups miss + // and scores silently return 0 (CLAUDE.md rule 34). + let fid: String + if let s = field["id"] as? String { fid = s } + else if let n = field["id"] as? Int { fid = String(n) } + else { continue } + guard let ftype = field["type"] as? String else { continue } - let val = formData[fid].map { "\($0)" } ?? "" + // Same cast-first pattern for the stored value — formData values may be + // String, Int, or Bool depending on field type. + let rawVal = formData[fid] + let val: String + if let s = rawVal as? String { val = s } + else if let n = rawVal as? Int { val = String(n) } + else if let b = rawVal as? Bool { val = b ? "true" : "false" } + else { val = "" } switch ftype { case "rating": diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 63bd481..4d78404 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -1,13 +1,15 @@ // Sync/SyncManager.swift // ---------------------- // Manages connectivity monitoring, reference data sync (Phase A), -// and the outbox queue for offline inspection/issue submission (Phase B). +// 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 { @@ -26,6 +28,14 @@ class SyncManager: ObservableObject { 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 pollTimer: Timer? + private let pollInterval: TimeInterval = 60 // seconds + static let shared = SyncManager() private init() {} @@ -37,14 +47,98 @@ class SyncManager: ObservableObject { guard let self else { return } let wasOffline = !self.isOnline self.isOnline = path.status == .satisfied - if wasOffline && self.isOnline { - await self.triggerSync() + if self.isOnline { + if wasOffline { + await self.triggerSync() + } + self.startPollTimer() + } else { + self.stopPollTimer() } } } monitor.start(queue: monitorQueue) } + // ── Notification poll timer ─────────────────────────────────────────── + + private func startPollTimer() { + guard pollTimer == nil else { return } // already running + pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.isOnline, AuthManager.shared.isAuthenticated else { return } + await self.pollNotifications() + } + } + } + + private func stopPollTimer() { + pollTimer?.invalidate() + pollTimer = nil + } + + /// Called on logout so the next login starts a clean fetch. + func resetNotificationPoller() { + lastNotificationFetch = nil + stopPollTimer() + } + + // ── 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 the cursor to the newest notification's timestamp + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + let dates = notifications.compactMap { fmt.date(from: $0.createdAt) } + if let newest = dates.max() { + lastNotificationFetch = newest + } + + // Mark all fetched notifications as read on the server + let ids = notifications.map(\.id) + try await APIClient.shared.markNotificationsRead(ids: ids) + + } 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 { @@ -59,6 +153,12 @@ class SyncManager: ObservableObject { await processInspectionQueue(context: context) await processIssueQueue(context: context) await pullReferenceData() + await pullAssignedIssues(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() updatePendingCount(context: context) lastSyncAt = Date() @@ -87,12 +187,11 @@ class SyncManager: ObservableObject { // Update parent inspection form field value if photo.entityType == "inspection", let fieldId = photo.fieldId { let entityId = photo.entityLocalId - let inspections = try? context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.localId == entityId } - ) - ) - inspections?.first?.setValue(serverPath, forFieldId: fieldId) + // Fetch-all + filter in Swift — #Predicate with a captured String + // variable causes "LocalInspection is ambiguous" under Xcode 26 + // SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (CLAUDE.md rule 3). + let allInspections = (try? context.fetch(FetchDescriptor())) ?? [] + allInspections.first(where: { $0.localId == entityId })?.setValue(serverPath, forFieldId: fieldId) } // Update parent issue photo paths array @@ -294,4 +393,74 @@ class SyncManager: ObservableObject { .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() + guard !apiIssues.isEmpty else { return } + + // 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 } + } + + let isoFmt = ISO8601DateFormatter() + + 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 } + // Refresh photos in case they were added after first pull + var serverPaths: [String] = [] + if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } + serverPaths.append(contentsOf: api.resultPhotos) + existing.photoServerPaths = serverPaths + } 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 + // Store server photos so IssueDetailView can show them + var serverPaths: [String] = [] + if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } + serverPaths.append(contentsOf: api.resultPhotos) + local.photoServerPaths = serverPaths + if let ts = api.reportedAt, + let date = isoFmt.date(from: ts) ?? { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return f.date(from: ts) + }() { + local.createdAt = date + } + context.insert(local) + } + } + try? context.save() + + } catch APIError.notAuthenticated { + // Let AuthManager handle session expiry + } catch { + // Non-fatal — IssuesListView still shows device-created issues + } + } } diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 4d8f84a..a8608d5 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -875,6 +875,32 @@ struct IssueDetailView: View { } } } + + if !issue.photoServerPaths.isEmpty { + Section("Photos (\(issue.photoServerPaths.count))") { + ForEach(issue.photoServerPaths, id: \.self) { relativePath in + AsyncImage(url: URL(string: Constants.baseURL + "/" + relativePath)) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + case .failure: + Label("Photo unavailable", systemImage: "photo.slash") + .foregroundStyle(.secondary) + case .empty: + HStack(spacing: 8) { + ProgressView() + Text("Loading…").font(.caption).foregroundStyle(.secondary) + } + @unknown default: + EmptyView() + } + } + } + } + } } .navigationTitle("Issue Detail") .navigationBarTitleDisplayMode(.inline) diff --git a/JanitorialQC/Views/Dashboard/FlagIssueView.swift b/JanitorialQC/Views/Dashboard/FlagIssueView.swift index c9bc55f..3dbcea7 100644 --- a/JanitorialQC/Views/Dashboard/FlagIssueView.swift +++ b/JanitorialQC/Views/Dashboard/FlagIssueView.swift @@ -28,6 +28,7 @@ struct FlagIssueView: View { @State private var showCamera = false @State private var showLibrary = false + @State private var showBanner = false // success confirmation banner private let maxPhotos = 5 @@ -199,6 +200,33 @@ struct FlagIssueView: View { .fontWeight(.semibold) } } + // ── Submission confirmation banner ───────────────────────────── + .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() @@ -272,6 +300,11 @@ struct FlagIssueView: View { Task { await sync.triggerSync() } } - dismiss() + // Show confirmation banner for 2 seconds then dismiss. + withAnimation { showBanner = true } + Task { + try? await Task.sleep(for: .seconds(2)) + dismiss() + } } }