05/15 Update: show issue's images, push notifications
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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<LocalInspection>(
|
||||
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<LocalInspection>())) ?? []
|
||||
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<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")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user