05/15 Update: show issue's images, push notifications

This commit is contained in:
Nguyen Ngo
2026-05-15 15:41:13 -04:00
parent 9fd3f5b829
commit 7320e88dec
7 changed files with 398 additions and 14 deletions
+30
View File
@@ -219,6 +219,36 @@ actor APIClient {
return result.status 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 // Token Refresh
private func refreshAccessToken() async -> Bool { private func refreshAccessToken() async -> Bool {
+98
View File
@@ -378,3 +378,101 @@ struct APIIssueStatusUpdate: Decodable, Sendable {
} }
private enum CodingKeys: String, CodingKey { case issueId, status } 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 }
}
+15
View File
@@ -5,6 +5,7 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import BackgroundTasks import BackgroundTasks
import UserNotifications
@main @main
struct JanitorialQCApp: App { struct JanitorialQCApp: App {
@@ -14,6 +15,7 @@ struct JanitorialQCApp: App {
init() { init() {
registerBackgroundTasks() registerBackgroundTasks()
requestNotificationPermission()
} }
var body: some Scene { 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() { private func registerBackgroundTasks() {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
forTaskWithIdentifier: "com.jqc.sync", forTaskWithIdentifier: "com.jqc.sync",
+16 -3
View File
@@ -126,11 +126,24 @@ final class LocalInspection {
var earned = 0 var earned = 0
for field in scoreable { for field in scoreable {
guard let fid = field["id"] as? String ?? (field["id"].map { "\($0)" }), // Explicit cast required field["id"] arrives as Int from JSONSerialization.
let ftype = field["type"] as? String // 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 } 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 { switch ftype {
case "rating": case "rating":
+177 -8
View File
@@ -1,13 +1,15 @@
// Sync/SyncManager.swift // Sync/SyncManager.swift
// ---------------------- // ----------------------
// Manages connectivity monitoring, reference data sync (Phase A), // 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 Foundation
import Network import Network
import SwiftData import SwiftData
import SwiftUI import SwiftUI
import Combine import Combine
import UserNotifications
@MainActor @MainActor
class SyncManager: ObservableObject { class SyncManager: ObservableObject {
@@ -26,6 +28,14 @@ class SyncManager: ObservableObject {
private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor") private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor")
var modelContext: ModelContext? 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() static let shared = SyncManager()
private init() {} private init() {}
@@ -37,14 +47,98 @@ class SyncManager: ObservableObject {
guard let self else { return } guard let self else { return }
let wasOffline = !self.isOnline let wasOffline = !self.isOnline
self.isOnline = path.status == .satisfied self.isOnline = path.status == .satisfied
if wasOffline && self.isOnline { if self.isOnline {
if wasOffline {
await self.triggerSync() await self.triggerSync()
} }
self.startPollTimer()
} else {
self.stopPollTimer()
}
} }
} }
monitor.start(queue: monitorQueue) 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 // Full Sync
func triggerSync() async { func triggerSync() async {
@@ -59,6 +153,12 @@ class SyncManager: ObservableObject {
await processInspectionQueue(context: context) await processInspectionQueue(context: context)
await processIssueQueue(context: context) await processIssueQueue(context: context)
await pullReferenceData() 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) updatePendingCount(context: context)
lastSyncAt = Date() lastSyncAt = Date()
@@ -87,12 +187,11 @@ class SyncManager: ObservableObject {
// Update parent inspection form field value // Update parent inspection form field value
if photo.entityType == "inspection", let fieldId = photo.fieldId { if photo.entityType == "inspection", let fieldId = photo.fieldId {
let entityId = photo.entityLocalId let entityId = photo.entityLocalId
let inspections = try? context.fetch( // Fetch-all + filter in Swift #Predicate with a captured String
FetchDescriptor<LocalInspection>( // variable causes "LocalInspection is ambiguous" under Xcode 26
predicate: #Predicate { $0.localId == entityId } // SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (CLAUDE.md rule 3).
) let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
) allInspections.first(where: { $0.localId == entityId })?.setValue(serverPath, forFieldId: fieldId)
inspections?.first?.setValue(serverPath, forFieldId: fieldId)
} }
// Update parent issue photo paths array // Update parent issue photo paths array
@@ -294,4 +393,74 @@ class SyncManager: ObservableObject {
.updateSchema(from: detailData.template) .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<LocalIssue>())) ?? []
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
}
}
} }
@@ -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") .navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -28,6 +28,7 @@ struct FlagIssueView: View {
@State private var showCamera = false @State private var showCamera = false
@State private var showLibrary = false @State private var showLibrary = false
@State private var showBanner = false // success confirmation banner
private let maxPhotos = 5 private let maxPhotos = 5
@@ -199,6 +200,33 @@ struct FlagIssueView: View {
.fontWeight(.semibold) .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) { .fullScreenCover(isPresented: $showCamera) {
CameraPickerView(image: .constant(nil), onSelected: appendPhoto) CameraPickerView(image: .constant(nil), onSelected: appendPhoto)
.ignoresSafeArea() .ignoresSafeArea()
@@ -272,6 +300,11 @@ struct FlagIssueView: View {
Task { await sync.triggerSync() } Task { await sync.triggerSync() }
} }
// Show confirmation banner for 2 seconds then dismiss.
withAnimation { showBanner = true }
Task {
try? await Task.sleep(for: .seconds(2))
dismiss() dismiss()
} }
} }
}