Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0b264a5c | ||
|
|
6fbd3d1437 | ||
|
|
20f9a99646 | ||
|
|
c05f0029fb | ||
|
|
e6de1f01b6 | ||
|
|
fc571b3faa | ||
|
|
a1afea095d | ||
|
|
32caf735e8 | ||
|
|
47ff9f3aa5 | ||
|
|
072ea13b53 |
@@ -425,13 +425,14 @@
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UIStatusBarStyle = "";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -468,13 +469,14 @@
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UIStatusBarStyle = "";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
enum APIError: Error, LocalizedError, Sendable {
|
||||
case invalidURL
|
||||
@@ -258,6 +259,59 @@ actor APIClient {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Upload a resolution photo (entity_type = issue_result) ────────────
|
||||
// Saves to issue_result_photos subfolder on the server — same bucket
|
||||
// as photos uploaded via the web update form.
|
||||
func uploadResultPhoto(localPath: String, retrying: Bool = false) async throws -> String {
|
||||
let url = try buildURL("/api/v1/photos/upload")
|
||||
|
||||
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
||||
throw APIError.networkError("Could not read photo: \(localPath)")
|
||||
}
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
||||
let ext = (filename as NSString).pathExtension.lowercased()
|
||||
let mime = ext == "png" ? "image/png" : "image/jpeg"
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"entity_type\"\r\n\r\nissue_result\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\nContent-Type: \(mime)\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(imageData)
|
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
injectToken(&req)
|
||||
req.httpBody = body
|
||||
|
||||
let (data, response) = try await performRequest(req)
|
||||
|
||||
if shouldRefresh(response, retrying: retrying) {
|
||||
let refreshed = await refreshAccessToken()
|
||||
if refreshed { return try await uploadResultPhoto(localPath: localPath, retrying: true) }
|
||||
throw APIError.notAuthenticated
|
||||
}
|
||||
|
||||
struct PhotoResult: Decodable, Sendable { let serverPath: String }
|
||||
if let env = try? decoder.decode(_Envelope<PhotoResult>.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 {
|
||||
@@ -277,13 +331,21 @@ actor APIClient {
|
||||
|
||||
// ── 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 {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
ep += "?since=\(fmt.string(from: since))"
|
||||
ep += "?since=\(Self.notifSinceFmt.string(from: since))"
|
||||
}
|
||||
let result: APINotificationsResponseData = try await request(ep)
|
||||
return result.notifications
|
||||
@@ -305,11 +367,105 @@ actor APIClient {
|
||||
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) ?? "<binary>"
|
||||
print("[JQC] registerDevice HTTP \(status): \(raw)")
|
||||
}
|
||||
}
|
||||
print("[JQC] registerDevice error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue Comments (Phase D) ──────────────────────────────────────────
|
||||
|
||||
/// Fetch all comments for an issue, oldest-first.
|
||||
|
||||
@@ -296,6 +296,8 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
// detail view works without a local SwiftData copy (e.g. after reinstall).
|
||||
let formDataRaw: [String: JSONValue]
|
||||
let formSchemaRaw: [[String: JSONValue]]
|
||||
/// {field_id: absolute_url} for image fields (presigned R2 / absolute static).
|
||||
let formMedia: [String: String]
|
||||
// ── Follow-up / re-inspection ─────────────────────────────────────────
|
||||
let followUpRequired: Bool
|
||||
let followUpNote: String?
|
||||
@@ -323,6 +325,20 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } }
|
||||
}
|
||||
|
||||
/// {relative_path: absolute_url} for image fields, derived by joining
|
||||
/// formMedia (fieldId -> url) with the form values (fieldId -> path). Lets
|
||||
/// the image renderer resolve a presigned URL from just the stored path.
|
||||
var mediaURLByPath: [String: String] {
|
||||
var out: [String: String] = [:]
|
||||
let values = formValues
|
||||
for (fid, url) in formMedia {
|
||||
if let path = values[fid], !path.isEmpty {
|
||||
out[path] = url
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var inspectionDateParsed: Date? {
|
||||
guard let str = inspectionDate else { return nil }
|
||||
// Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix.
|
||||
@@ -347,6 +363,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
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)) ?? [:]
|
||||
formMedia = (try? c.decode([String: String].self, forKey: .formMedia)) ?? [:]
|
||||
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)
|
||||
@@ -357,6 +374,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
case areaId, areaName, status, overallScore
|
||||
case inspectionDate, completedAt, mobileLocalId, inspectorNotes
|
||||
case formData, formSchema
|
||||
case formMedia
|
||||
case followUpRequired, followUpNote, parentInspectionId
|
||||
}
|
||||
|
||||
@@ -403,9 +421,21 @@ struct APIIssueDetail: Decodable, Sendable {
|
||||
let verifiedAt: String?
|
||||
let verificationNote: String?
|
||||
let reportedByName: String?
|
||||
// Resolution photos uploaded via web or mobile resolve flow
|
||||
let resultPhotos: [String]
|
||||
let resultPhotoUrls: [String] // absolute display URLs (presigned R2 / static)
|
||||
// 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)
|
||||
@@ -422,14 +452,28 @@ struct APIIssueDetail: Decodable, Sendable {
|
||||
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
|
||||
verificationNote = try? c.decode(String.self, forKey: .verificationNote)
|
||||
reportedByName = try? c.decode(String.self, forKey: .reportedByName)
|
||||
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
||||
resultPhotoUrls = (try? c.decode([String].self, forKey: .resultPhotoUrls)) ?? []
|
||||
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
|
||||
case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
|
||||
case resultPhotoUrls
|
||||
case areaName, assignedToName
|
||||
case handlerType, handlerLabel
|
||||
case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes
|
||||
case vendorName, vendorContact, vendorNotes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +489,18 @@ struct APIIssueStatusUpdate: Decodable, Sendable {
|
||||
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 {
|
||||
@@ -506,6 +562,10 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
|
||||
let photoPath: String? // primary evidence photo
|
||||
let mobilePhotoPaths: [String] // extra evidence photos from iPad
|
||||
let resultPhotos: [String] // resolution photos added via web
|
||||
// Absolute display URLs (presigned R2 / absolute static). photoUrls order
|
||||
// mirrors the evidence merge: [photoPath] + mobilePhotoPaths.
|
||||
let photoUrls: [String]
|
||||
let resultPhotoUrls: [String]
|
||||
// Phase A — resolution details from web
|
||||
let resultNotes: String?
|
||||
let verifiedAt: String?
|
||||
@@ -514,6 +574,15 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
|
||||
// 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)
|
||||
@@ -529,19 +598,33 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
|
||||
photoPath = try? c.decode(String.self, forKey: .photoPath)
|
||||
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
|
||||
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
||||
photoUrls = (try? c.decode([String].self, forKey: .photoUrls)) ?? []
|
||||
resultPhotoUrls = (try? c.decode([String].self, forKey: .resultPhotoUrls)) ?? []
|
||||
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 photoUrls, resultPhotoUrls
|
||||
case resultNotes, verifiedAt, verificationNote, reportedByName
|
||||
case areaName, assignedToName
|
||||
case handlerType, handlerLabel
|
||||
case facilityHandlerName, facilityHandlerContact, facilityHandlerNotes
|
||||
case vendorName, vendorContact, vendorNotes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,6 +644,57 @@ struct APIAssignedIssuesResponseData: Decodable, Sendable {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
// 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<T: Decodable & Sendable>: 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<T: Decodable & Sendable>(
|
||||
_ 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<T: Decodable & Sendable>(_ 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<PhotoResult>.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<PhotoResult>.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) ?? "<binary>"
|
||||
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<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
||||
if let env = try? decoder.decode(_Envelope<T>.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
// 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<T: Decodable & Sendable>: 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 }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
// 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<Void, Never>? // 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<PendingPhoto>()) 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<String>()
|
||||
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<LocalInspection>())) ?? []
|
||||
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
|
||||
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<LocalInspection>()) 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<LocalIssue>()) 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<LocalInspection>())) ?? []
|
||||
|
||||
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<LocalFacility>())
|
||||
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<Int>()
|
||||
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<LocalTemplate>())
|
||||
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<String>()
|
||||
|
||||
// PendingPhoto — not yet uploaded
|
||||
if let pendingPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) {
|
||||
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<LocalInspection>()) {
|
||||
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<LocalIssue>()) {
|
||||
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<String>)
|
||||
// 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<LocalInspection>()))?
|
||||
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
|
||||
.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<LocalArea>()))?
|
||||
.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<LocalTemplate>()))?
|
||||
.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<LocalIssue>())) ?? []
|
||||
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<LocalScheduledInspection>())) ?? []
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
// 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<LocalInspection> { $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<LocalInspection> { $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<Content: View>(
|
||||
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<LocalTemplate>(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<LocalFacility>())) ?? []
|
||||
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<NSString, UIImage>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+348
@@ -0,0 +1,348 @@
|
||||
// Views/Dashboard/MyInspectionsView.swift
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MessageUI
|
||||
|
||||
// MARK: - My Inspections
|
||||
|
||||
struct MyInspectionsView: View {
|
||||
|
||||
@Query(
|
||||
filter: #Predicate<LocalInspection> { $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<LocalTemplate>(
|
||||
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<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||
).first?.name) ?? "Unknown Facility"
|
||||
}
|
||||
|
||||
private var templateName: String {
|
||||
let id = inspection.templateServerId
|
||||
return (try? context.fetch(
|
||||
FetchDescriptor<LocalTemplate>(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<LocalTemplate>(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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,10 @@ class AuthManager: ObservableObject {
|
||||
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
|
||||
applyUser(response.user)
|
||||
isAuthenticated = true
|
||||
// Register device immediately after login — the .task {} and
|
||||
// .onChange(scenePhase) paths both miss this case because they run
|
||||
// before login completes.
|
||||
Task { await APIClient.shared.registerDevice() }
|
||||
} catch APIError.serverError(let msg) {
|
||||
errorMessage = msg
|
||||
} catch APIError.networkError {
|
||||
|
||||
+32
-5
@@ -180,10 +180,11 @@ Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`,
|
||||
|
||||
```swift
|
||||
LocalFacility.self, LocalArea.self, LocalTemplate.self,
|
||||
LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self
|
||||
LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self,
|
||||
PendingPhoto.self, SyncQueueEntry.self
|
||||
```
|
||||
|
||||
**SwiftData lightweight migration:** New `Bool` fields require `= false` default — without it the app crashes on launch.
|
||||
**SwiftData lightweight migration:** New `Bool` fields require `= false` default — without it the app crashes on launch. New non-optional model properties need inline defaults too; new **optional** properties (e.g. the `LocalIssue` handler fields) are nil-safe. **Exception:** a `@Attribute(.unique)` key must have NO default (rule 63).
|
||||
|
||||
### Model Reference
|
||||
|
||||
@@ -193,7 +194,8 @@ LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self
|
||||
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
|
||||
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
|
||||
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `submitLatitude` (Double?), `submitLongitude` (Double?) |
|
||||
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON` |
|
||||
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
|
||||
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
|
||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
||||
|
||||
@@ -307,6 +309,8 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas
|
||||
| `updateIssueStatus` | `PATCH /api/v1/issues/<id>/status` | Inspector updates status |
|
||||
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
|
||||
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server |
|
||||
| `fetchScheduledInspections` | `GET /api/v1/scheduled-inspections` | Active scheduled assignments; inspector-scoped server-side. Pulled into `LocalScheduledInspection` |
|
||||
| `updateIssueHandler` | `PATCH /api/v1/issues/<id>/handler` | Sets "Handled By". Body `["handler_type": …]` + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) |
|
||||
|
||||
### `APIAssignedIssue` fields
|
||||
|
||||
@@ -322,9 +326,14 @@ let reportedByName: String? // reporter display_name
|
||||
// Phase E:
|
||||
let areaName: String? // area the issue was flagged in
|
||||
let assignedToName: String? // assigned user display_name
|
||||
// Handler ("Handled By", July 2026):
|
||||
let handlerType: String? // "internal" | "facility" | "vendor"
|
||||
let handlerLabel: String? // human-readable label
|
||||
let facilityHandlerName: String?, facilityHandlerContact: String?, facilityHandlerNotes: String?
|
||||
let vendorName: String?, vendorContact: String?, vendorNotes: String?
|
||||
```
|
||||
|
||||
`SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only. All Phase A/E optional fields are persisted to `LocalIssue` on both insert and update paths.
|
||||
`SyncManager.pullAssignedIssues` merges `photoPath + mobilePhotoPaths` into `photoServerPaths`. `resultPhotos` is decoded but intentionally excluded from `photoServerPaths` — resolution photos are web-only. All Phase A/E and handler optional fields are persisted to `LocalIssue` on both insert and update paths; `refreshStatusFromServer()` also refreshes the handler fields from `APIIssueDetail` (skipped while the inspector is mid-edit). The same handler fields exist on `APIIssueDetail`.
|
||||
|
||||
---
|
||||
|
||||
@@ -370,6 +379,18 @@ Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange
|
||||
|
||||
`APIClient.submitInspection` sends `submit_latitude` / `submit_longitude` only when non-nil. The `PATCH` endpoint does not accept GPS fields — creation-time (POST) capture only. The server displays a Google Maps embed in `inspections/view.html` for admin/director when both fields are present.
|
||||
|
||||
### Scheduled inspections (phase36, July 2026)
|
||||
|
||||
`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it).
|
||||
|
||||
### Inspection-start presentation (July 2026)
|
||||
|
||||
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
||||
|
||||
### "Handled By" (issue handler, phase35 → mobile July 2026)
|
||||
|
||||
`IssueDetailView` (`IssuesView.swift`) shows a "Handled By" section: current handler label + detail, and — for admin/director/PM **and the assigned inspector** — an inline editor (segmented internal/facility/vendor + name/contact/notes) that PATCHes via `updateIssueHandler` and mirrors the result onto `LocalIssue`. The inspector-writable path is a deliberate divergence from the web form (web CLAUDE.md rule 78).
|
||||
|
||||
### 3. Draft management
|
||||
|
||||
Swipe-left delete (confirmation required). Deletes draft + `PendingPhoto` records + local photo files + associated `LocalIssue` records. Only `status == "draft"` inspections may be deleted.
|
||||
@@ -652,7 +673,13 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
||||
| 58 | **`ReadOnlyGridFormView` uses `rowView` (GeometryReader + ZStack), NOT a ZStack canvas or LazyVGrid** | ZStack canvas: gaps from unanswered rows because y-offsets are absolute. LazyVGrid: ignores `col` position, flows items sequentially. Correct approach: group fields by original `row` into `RowGroup`s, render each group as a `GeometryReader` that divides width by 12 to get `colW`, positions each field with `.offset(x: colW * (col-1))` and `.frame(width: colW * colSpan)`. `VStack(spacing: 3)` between rows. Row height fixed at 36pt (section headers 28pt). |
|
||||
| 59 | **Read-only inspection detail: only answered fields are shown — filtering is 5-pass** | Pass 1: collect `answeredIds` (rating > 0, or non-empty value). Pass 2: collect `visibleLabelIds` (labels immediately before an answered field). Pass 3: collect `visibleSectionIds` (sections with at least one answered field after them). Pass 4: group all schema fields by original `row`. Pass 5: for each row group, emit only visible fields; skip rows with no visible content. |
|
||||
| 60 | **`ReadOnlyGridFormView` rows advance by 1 regardless of original `rowSpan`** | The web renders every field with `grid-row: N / span 1`. The read-only view collapses all rowSpans to 1 — no field occupies more than one row of vertical space. |
|
||||
| 61 | **`PhotoThumbnailView` owns `@State private var showLightbox`** | `ReadOnlyCellView.valueView` is a computed `@ViewBuilder` — it cannot hold `@State`. The `image` case delegates to `PhotoThumbnailView` (a separate struct) which holds its own sheet state. Thumbnail is 32×32pt; lightbox is a full-screen black sheet dismissed by tap. |
|
||||
| 62 | **Sort `schema` by `(row, col)` before grouping fields into row buckets** | The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by `row` and attaches section headers must first sort by `(f["row"], f["col"])` — exactly like the web's `sorted(key=lambda f: (f['row'], f['col']))`. Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute `(col, row)` pixel offsets (e.g. `GridFormView` ZStack, `canvasHeight()`) are unaffected — sort order only matters when grouping by row for sequential rendering. |
|
||||
|
||||
| 63 | **`@Attribute(.unique)` must NOT carry an inline default value** | A `.unique` key with a default (`@Attribute(.unique) var serverId: Int = 0`) stops the `@Model` macro from emitting a clean `PersistentModel` conformance. Symptom is misleading: the `.modelContainer(for: [ … ])` array literal fails to type-check and Xcode reports **"Cannot find '<OtherModel>' in scope" on the *other* schema elements**, not the offending one. Declare the unique key with no default (`@Attribute(.unique) var serverId: Int`) and set it in `init`, exactly like `LocalFacility`/`LocalArea`. (Bit us adding `LocalScheduledInspection`, July 2026.) |
|
||||
| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. |
|
||||
| 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name` → `facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). |
|
||||
| 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. |
|
||||
| 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail` → `LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ struct ContentView: View {
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@StateObject private var updateChecker = UpdateChecker.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -41,6 +42,39 @@ struct ContentView: View {
|
||||
// any failure (offline, parse error, etc.) so it never disrupts
|
||||
// normal app use.
|
||||
await updateChecker.checkForUpdate()
|
||||
// 5. Register device now that auth is fully resolved.
|
||||
// The .onChange(scenePhase == .active) fires BEFORE restoreSession()
|
||||
// completes on first launch, so auth.isAuthenticated is false there
|
||||
// and registration is skipped. This call covers that gap.
|
||||
if AuthManager.shared.isAuthenticated {
|
||||
await APIClient.shared.registerDevice()
|
||||
}
|
||||
}
|
||||
// Stop the 60s notification poll when the app goes to background and
|
||||
// restart it when it returns to the foreground. iOS suspends Tasks
|
||||
// automatically in the background anyway, but explicitly managing the
|
||||
// poll task here:
|
||||
// • Prevents the Task object from accumulating sleep-resume cycles
|
||||
// that never fired while suspended.
|
||||
// • Triggers an immediate sync+poll when the inspector returns to the
|
||||
// app after being away, rather than waiting up to 60s for the next
|
||||
// scheduled poll tick.
|
||||
// • Makes the lifecycle intent explicit and avoids relying on implicit
|
||||
// iOS suspension behaviour.
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
switch newPhase {
|
||||
case .background:
|
||||
SyncManager.shared.suspendPolling()
|
||||
case .active:
|
||||
SyncManager.shared.resumePolling()
|
||||
// Register / update device record on every foreground.
|
||||
// Fire-and-forget — auth guard is inside registerDevice().
|
||||
if auth.isAuthenticated {
|
||||
Task { await APIClient.shared.registerDevice() }
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.alert("Update Available", isPresented: $updateChecker.updateAvailable) {
|
||||
Button("Update") {
|
||||
|
||||
@@ -100,6 +100,7 @@ struct JanitorialQCApp: App {
|
||||
LocalTemplate.self,
|
||||
LocalInspection.self,
|
||||
LocalIssue.self,
|
||||
LocalScheduledInspection.self,
|
||||
PendingPhoto.self,
|
||||
SyncQueueEntry.self,
|
||||
], isUndoEnabled: false) { result in
|
||||
|
||||
@@ -23,17 +23,117 @@ final class LocalIssue {
|
||||
var photoLocalPathsJSON: String = "[]"
|
||||
/// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...]
|
||||
var photoServerPathsJSON: String = "[]"
|
||||
/// JSON-encoded array of resolution photo server paths (issue_result_photos bucket).
|
||||
/// Mirrors Issue.result_photos on the server — shown under "Resolution Details".
|
||||
var resultPhotoServerPathsJSON: String = "[]"
|
||||
/// JSON-encoded absolute display URLs (presigned R2 / absolute static),
|
||||
/// parallel to photoServerPaths / resultPhotoServerPaths (same order).
|
||||
/// Empty ("[]") when talking to an older server that omits the *_url fields.
|
||||
var photoServerUrlsJSON: String = "[]"
|
||||
var resultPhotoServerUrlsJSON: 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 { (try? JSONDecoder().decode([String].self, from: Data(photoLocalPathsJSON.utf8))) ?? [] }
|
||||
set { photoLocalPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
|
||||
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 { (try? JSONDecoder().decode([String].self, from: Data(photoServerPathsJSON.utf8))) ?? [] }
|
||||
set { photoServerPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute display URLs parallel to photoServerPaths (same order).
|
||||
var photoServerUrls: [String] {
|
||||
get { (try? Self.jsonDecoder.decode([String].self,
|
||||
from: Data(photoServerUrlsJSON.utf8))) ?? [] }
|
||||
set { photoServerUrlsJSON = (try? String(data: Self.jsonEncoder.encode(newValue),
|
||||
encoding: .utf8)) ?? "[]" }
|
||||
}
|
||||
|
||||
/// Absolute display URLs parallel to resultPhotoServerPaths (same order).
|
||||
var resultPhotoServerUrls: [String] {
|
||||
get { (try? Self.jsonDecoder.decode([String].self,
|
||||
from: Data(resultPhotoServerUrlsJSON.utf8))) ?? [] }
|
||||
set { resultPhotoServerUrlsJSON = (try? String(data: Self.jsonEncoder.encode(newValue),
|
||||
encoding: .utf8)) ?? "[]" }
|
||||
}
|
||||
|
||||
var createdAt: Date
|
||||
@@ -74,6 +174,20 @@ final class LocalIssue {
|
||||
/// 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
|
||||
@@ -99,6 +213,9 @@ final class LocalIssue {
|
||||
self.issueStatus = "open"
|
||||
self.photoLocalPathsJSON = "[]"
|
||||
self.photoServerPathsJSON = "[]"
|
||||
self.resultPhotoServerPathsJSON = "[]"
|
||||
self.photoServerUrlsJSON = "[]"
|
||||
self.resultPhotoServerUrlsJSON = "[]"
|
||||
self.createdAt = Date()
|
||||
self.syncStatus = "pending"
|
||||
self.syncRetryCount = 0
|
||||
@@ -112,6 +229,15 @@ final class LocalIssue {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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.
|
||||
//
|
||||
// Follows the same pattern as LocalFacility / LocalArea: a `.unique` serverId
|
||||
// WITHOUT an inline default (a default on the unique key breaks @Model's
|
||||
// PersistentModel conformance) and a full init(from:)/update(from:) pair.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class LocalScheduledInspection {
|
||||
|
||||
/// Server ID of the ScheduledInspection row — stable unique identity.
|
||||
@Attribute(.unique) var serverId: Int
|
||||
|
||||
var facilityServerId: Int
|
||||
var facilityName: String
|
||||
var templateServerId: Int
|
||||
var templateName: String
|
||||
var inspectorId: Int?
|
||||
|
||||
var frequency: String // once | daily | weekly | monthly
|
||||
var frequencyLabel: String
|
||||
|
||||
/// Raw server date string "YYYY-MM-DD" — sortable (ISO strings sort
|
||||
/// chronologically) and the source for the parsed `nextDue`.
|
||||
var dueDateString: String
|
||||
|
||||
var isOverdue: Bool
|
||||
var notes: String?
|
||||
|
||||
/// Last time this row was refreshed from the server pull.
|
||||
var updatedAt: Date
|
||||
|
||||
/// Parsed due date for display. Computed properties are not persisted by
|
||||
/// SwiftData; sort on `dueDateString` (not this) in @Query.
|
||||
var nextDue: Date? {
|
||||
Self.dateOnlyFormatter.date(from: dueDateString)
|
||||
}
|
||||
|
||||
private static let dateOnlyFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
init(from api: APIScheduledInspection) {
|
||||
self.serverId = api.id
|
||||
self.facilityServerId = api.facilityId
|
||||
self.facilityName = api.facilityName ?? ""
|
||||
self.templateServerId = api.templateId
|
||||
self.templateName = api.templateName ?? ""
|
||||
self.inspectorId = api.inspectorId
|
||||
self.frequency = api.frequency
|
||||
self.frequencyLabel = api.frequencyLabel ?? ""
|
||||
self.dueDateString = api.nextDueDate ?? ""
|
||||
self.isOverdue = api.isOverdue
|
||||
self.notes = api.notes
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
|
||||
func update(from api: APIScheduledInspection) {
|
||||
self.facilityServerId = api.facilityId
|
||||
self.facilityName = api.facilityName ?? ""
|
||||
self.templateServerId = api.templateId
|
||||
self.templateName = api.templateName ?? ""
|
||||
self.inspectorId = api.inspectorId
|
||||
self.frequency = api.frequency
|
||||
self.frequencyLabel = api.frequencyLabel ?? ""
|
||||
self.dueDateString = api.nextDueDate ?? ""
|
||||
self.isOverdue = api.isOverdue
|
||||
self.notes = api.notes
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ final class LocalTemplate {
|
||||
var formSchemaJSON: String
|
||||
var lastSyncedAt: Date
|
||||
var isActive: Bool = true // phase21 — false templates excluded from picker
|
||||
/// Timestamp of the last successful schema fetch (GET /api/v1/templates/{id}).
|
||||
/// Nil when the schema has never been fetched (e.g. template just inserted).
|
||||
/// Used to skip redundant detail calls when summary fields are unchanged.
|
||||
var schemaFetchedAt: Date? = nil
|
||||
|
||||
init(from summary: APITemplateSummary) {
|
||||
self.serverId = summary.id
|
||||
@@ -26,12 +30,20 @@ final class LocalTemplate {
|
||||
self.isActive = summary.isActive
|
||||
}
|
||||
|
||||
func updateSummary(from summary: APITemplateSummary) {
|
||||
/// Update summary fields and return whether any field changed.
|
||||
/// Used by pullReferenceData to skip schema re-fetching when nothing changed.
|
||||
@discardableResult
|
||||
func updateSummary(from summary: APITemplateSummary) -> Bool {
|
||||
let changed = name != summary.name
|
||||
|| templateDescription != summary.description
|
||||
|| frequency != summary.frequency
|
||||
|| isActive != summary.isActive
|
||||
self.name = summary.name
|
||||
self.templateDescription = summary.description
|
||||
self.frequency = summary.frequency
|
||||
self.isActive = summary.isActive
|
||||
self.lastSyncedAt = Date()
|
||||
return changed
|
||||
}
|
||||
|
||||
func updateSchema(from template: APITemplate) {
|
||||
@@ -43,7 +55,8 @@ final class LocalTemplate {
|
||||
let str = String(data: data, encoding: .utf8) {
|
||||
self.formSchemaJSON = str
|
||||
}
|
||||
self.lastSyncedAt = Date()
|
||||
self.schemaFetchedAt = Date()
|
||||
self.lastSyncedAt = Date()
|
||||
}
|
||||
|
||||
/// Decode stored JSON back into [[String: Any]] for the form renderer
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
// Models/SyncQueueEntry.swift
|
||||
// ---------------------------
|
||||
// SwiftData model for the outbox sync queue.
|
||||
// Every offline write (inspection, issue, photo) enqueues an entry here.
|
||||
// SyncManager processes entries in FIFO order when connectivity is restored.
|
||||
// LEGACY — This model is no longer used. The original design enqueued every
|
||||
// offline write here and processed in FIFO order; the current architecture uses
|
||||
// LocalInspection.syncStatus / LocalIssue.syncStatus / PendingPhoto.uploadStatus
|
||||
// directly (simpler, fewer moving parts, no double-bookkeeping).
|
||||
//
|
||||
// The model is kept registered in the SwiftData container solely to maintain
|
||||
// schema compatibility with existing installs — removing it from modelContainer
|
||||
// would trigger a migration failure on devices that already have the table.
|
||||
// A future dedicated migration (phase N) can drop the table explicitly using
|
||||
// op.execute("DROP TABLE IF EXISTS SyncQueueEntry") once it's safe to do so.
|
||||
//
|
||||
// DO NOT add new code that reads or writes this model.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@@ -118,6 +118,21 @@ class SyncManager: ObservableObject {
|
||||
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
|
||||
@@ -136,20 +151,25 @@ class SyncManager: ObservableObject {
|
||||
deliverLocalNotification(n)
|
||||
}
|
||||
|
||||
// Update in-app inbox state
|
||||
recentNotifications = notifications + recentNotifications.prefix(50 - notifications.count)
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -217,6 +237,7 @@ class SyncManager: ObservableObject {
|
||||
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
|
||||
@@ -268,6 +289,16 @@ class SyncManager: ObservableObject {
|
||||
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<LocalInspection>())) ?? []
|
||||
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
|
||||
for photo in pending {
|
||||
do {
|
||||
let serverPath = try await APIClient.shared.uploadPhoto(
|
||||
@@ -277,20 +308,18 @@ class SyncManager: ObservableObject {
|
||||
photo.serverPath = serverPath
|
||||
photo.uploadStatus = "uploaded"
|
||||
|
||||
// Update parent inspection form field value
|
||||
// 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
|
||||
// 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)
|
||||
allInspections.first(where: { $0.localId == entityId })?
|
||||
.setValue(serverPath, forFieldId: fieldId)
|
||||
}
|
||||
|
||||
// Update parent issue photo paths array
|
||||
// Update parent issue photo paths array.
|
||||
// Pre-fetched before the loop — not repeated per photo.
|
||||
if photo.entityType == "issue" {
|
||||
let entityId = photo.entityLocalId
|
||||
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
if let issue = allIssues.first(where: { $0.localId == entityId }) {
|
||||
var paths = issue.photoServerPaths
|
||||
if !paths.contains(serverPath) { paths.append(serverPath) }
|
||||
@@ -328,12 +357,12 @@ class SyncManager: ObservableObject {
|
||||
inspection.status = "synced"
|
||||
|
||||
// Clear follow-up flag on parent.
|
||||
if let parentLocalId = inspection.parentLocalId {
|
||||
let allInspections = try? context.fetch(FetchDescriptor<LocalInspection>())
|
||||
if let parent = allInspections?.first(where: { $0.localId == parentLocalId }) {
|
||||
parent.followUpRequired = false
|
||||
parent.followUpNote = nil
|
||||
}
|
||||
// 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()
|
||||
@@ -485,13 +514,18 @@ class SyncManager: ObservableObject {
|
||||
)
|
||||
|
||||
for apiSummary in templatesData.templates {
|
||||
let summaryChanged: Bool
|
||||
if let existing = templateMap[apiSummary.id] {
|
||||
existing.updateSummary(from: apiSummary)
|
||||
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
|
||||
id: apiSummary.id,
|
||||
context: context,
|
||||
templateMap: templateMap,
|
||||
summaryChanged: summaryChanged
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,14 +569,8 @@ class SyncManager: ObservableObject {
|
||||
guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return }
|
||||
UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey)
|
||||
|
||||
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 }
|
||||
|
||||
// Collect all local paths that are still in use.
|
||||
// ── Phase 1: collect referenced paths on @MainActor (SwiftData fetches) ──
|
||||
// These are fast in-memory operations — always runs on the main actor.
|
||||
var referencedPaths = Set<String>()
|
||||
|
||||
// PendingPhoto — not yet uploaded
|
||||
@@ -568,21 +596,37 @@ class SyncManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete any disk file not in referencedPaths
|
||||
var deletedCount = 0
|
||||
for fileURL in diskFiles {
|
||||
let path = fileURL.path
|
||||
if !referencedPaths.contains(path) {
|
||||
try? fm.removeItem(at: fileURL)
|
||||
deletedCount += 1
|
||||
// ── 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<String>)
|
||||
// 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)")
|
||||
}
|
||||
}
|
||||
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<LocalInspection>()))?
|
||||
.filter { $0.syncStatus == "pending" }.count ?? 0
|
||||
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
|
||||
@@ -619,18 +663,33 @@ class SyncManager: ObservableObject {
|
||||
private func upsertTemplateSchema(
|
||||
id: Int,
|
||||
context: ModelContext,
|
||||
templateMap: [Int: LocalTemplate]
|
||||
templateMap: [Int: LocalTemplate],
|
||||
summaryChanged: Bool
|
||||
) async throws {
|
||||
let existing = templateMap[id] ?? (try? context.fetch(FetchDescriptor<LocalTemplate>()))?
|
||||
.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)"
|
||||
)
|
||||
if let existing = templateMap[id] {
|
||||
existing.updateSchema(from: detailData.template)
|
||||
} else {
|
||||
(try? context.fetch(FetchDescriptor<LocalTemplate>()))?
|
||||
.first { $0.serverId == id }?
|
||||
.updateSchema(from: detailData.template)
|
||||
}
|
||||
existing?.updateSchema(from: detailData.template)
|
||||
}
|
||||
|
||||
// ── Pull server-assigned issues ───────────────────────────────────────
|
||||
@@ -672,18 +731,31 @@ class SyncManager: ObservableObject {
|
||||
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 on the web,
|
||||
// not displayed on the iPad issues list.
|
||||
// result_photos are resolution photos — shown separately under
|
||||
// "Resolution Details" in resultPhotoServerPaths.
|
||||
var serverPaths: [String] = []
|
||||
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
||||
serverPaths.append(contentsOf: api.mobilePhotoPaths)
|
||||
existing.photoServerPaths = serverPaths
|
||||
existing.resultPhotoServerPaths = api.resultPhotos
|
||||
// Absolute display URLs (presigned R2 / static) — parallel arrays.
|
||||
existing.photoServerUrls = api.photoUrls
|
||||
existing.resultPhotoServerUrls = api.resultPhotoUrls
|
||||
} else {
|
||||
// Insert new server-pulled issue
|
||||
let local = LocalIssue(
|
||||
@@ -703,6 +775,15 @@ class SyncManager: ObservableObject {
|
||||
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
|
||||
@@ -713,6 +794,10 @@ class SyncManager: ObservableObject {
|
||||
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
||||
serverPaths.append(contentsOf: api.mobilePhotoPaths)
|
||||
local.photoServerPaths = serverPaths
|
||||
local.resultPhotoServerPaths = api.resultPhotos
|
||||
// Absolute display URLs (presigned R2 / static) — parallel arrays.
|
||||
local.photoServerUrls = api.photoUrls
|
||||
local.resultPhotoServerUrls = api.resultPhotoUrls
|
||||
if let ts = api.reportedAt,
|
||||
let date = Self.isoFormatter.date(from: ts) {
|
||||
local.createdAt = date
|
||||
@@ -748,6 +833,45 @@ class SyncManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<LocalScheduledInspection>())) ?? []
|
||||
var byServerId: [Int: LocalScheduledInspection] = [:]
|
||||
for row in allLocal { byServerId[row.serverId] = row }
|
||||
|
||||
for api in apiRows {
|
||||
if let existing = byServerId[api.id] {
|
||||
existing.update(from: api)
|
||||
} else {
|
||||
context.insert(LocalScheduledInspection(from: api))
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -47,6 +47,16 @@ nonisolated enum ServerConfig {
|
||||
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
|
||||
return ServerOption(rawValue: raw) ?? .primary
|
||||
}
|
||||
|
||||
/// Resolve a photo URL, preferring an absolute server-provided URL
|
||||
/// (presigned R2 on the s3 backend, absolute-static on local) and falling
|
||||
/// back to building one from the relative 'uploads/...' key for older
|
||||
/// servers that don't send the *_url fields.
|
||||
nonisolated static func mediaURL(absolute: String?, path: String) -> URL? {
|
||||
if let a = absolute, !a.isEmpty { return URL(string: a) }
|
||||
guard !path.isEmpty else { return nil }
|
||||
return URL(string: current + "/static/" + path)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App-wide constants
|
||||
@@ -62,6 +72,7 @@ nonisolated enum Constants {
|
||||
static let userRole = "com.jqc.userRole"
|
||||
static let username = "com.jqc.username"
|
||||
static let displayName = "com.jqc.displayName"
|
||||
static let deviceId = "com.jqc.deviceId"
|
||||
}
|
||||
|
||||
static let tokenRefreshBufferMinutes: Double = 5
|
||||
|
||||
@@ -29,17 +29,13 @@ private let kFtrH: CGFloat = 39.6 // footer area height
|
||||
private let kTopY: CGFloat = kHdrH + 14 // first content Y on a fresh page
|
||||
private let kBotY: CGFloat = kH - kFtrH // lowest Y before footer
|
||||
|
||||
// Image compression — keeps the PDF (and thus the email attachment) small
|
||||
// regardless of source photo resolution. Same values as IssuePDFGenerator.
|
||||
private let kImgMaxPx: CGFloat = 700
|
||||
private let kImgJPEGQuality: CGFloat = 0.55
|
||||
|
||||
/// Downscale to kImgMaxPx on the longer side and re-encode as JPEG at
|
||||
/// kImgJPEGQuality, returning a fresh UIImage built from the compressed
|
||||
/// bytes. Applied to every photo before it's embedded in the PDF — this is
|
||||
/// the main lever for keeping file size minimal.
|
||||
/// Downscale to 700px on the longer side and re-encode as JPEG quality 0.55,
|
||||
/// returning a fresh UIImage built from the compressed bytes. Applied to
|
||||
/// every photo before it's embedded in the PDF — keeps file size minimal.
|
||||
/// nonisolated: called from inside a TaskGroup (concurrent), not @MainActor.
|
||||
private nonisolated func compress(_ image: UIImage) -> UIImage? {
|
||||
let kImgMaxPx: CGFloat = 700
|
||||
let kImgJPEGQuality: CGFloat = 0.55
|
||||
let size = image.size
|
||||
guard size.width > 0, size.height > 0 else { return nil }
|
||||
let scale = min(kImgMaxPx / size.width, kImgMaxPx / size.height, 1.0)
|
||||
@@ -412,26 +408,48 @@ private func drawFormFields(_ pctx: PDFContext,
|
||||
let baseRowH: CGFloat = 28
|
||||
let photoRowH: CGFloat = 92 // taller row to fit an embedded photo
|
||||
|
||||
// Group by original row
|
||||
// Sort fields by (row, col) before grouping — mirrors the web PDF generator:
|
||||
// form_fields = sorted(schema, key=lambda f: (f['row'], f['col']))
|
||||
// Without this, schema fields arrive in JSON-array/editor-insertion order,
|
||||
// which can place section headers after the data rows they belong to.
|
||||
let sortedSchema = schema.sorted {
|
||||
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
|
||||
if r0 != r1 { return r0 < r1 }
|
||||
let c0 = $0["col"] as? Int ?? 0, c1 = $1["col"] as? Int ?? 0
|
||||
return c0 < c1
|
||||
}
|
||||
|
||||
// Group by original row, tracking which section header precedes each new row.
|
||||
// Uses a queue (not a single pendingSec variable) so that when a section
|
||||
// field is encountered but the next few fields are on rows already in
|
||||
// rowGroups, the section label is not silently dropped or overwritten by the
|
||||
// next section before it was consumed.
|
||||
var rowGroups: [Int: [[String: Any]]] = [:]
|
||||
var rowOrder: [Int] = []
|
||||
var pendingSec: String? = nil
|
||||
var sectionQueue: [String] = [] // pending section labels, in schema order
|
||||
var secForRow: [Int: String] = [:]
|
||||
|
||||
for f in schema {
|
||||
for f in sortedSchema {
|
||||
let ftype = f["type"] as? String ?? ""
|
||||
if skipTypes.contains(ftype) { continue }
|
||||
if ftype == "section" { pendingSec = f["label"] as? String ?? ""; continue }
|
||||
if ftype == "section" {
|
||||
sectionQueue.append(f["label"] as? String ?? "")
|
||||
continue
|
||||
}
|
||||
let row = f["row"] as? Int ?? 1
|
||||
if rowGroups[row] == nil {
|
||||
rowOrder.append(row)
|
||||
rowGroups[row] = []
|
||||
if let s = pendingSec { secForRow[row] = s; pendingSec = nil }
|
||||
// Assign the oldest pending section label to this new row.
|
||||
if !sectionQueue.isEmpty {
|
||||
secForRow[row] = sectionQueue.removeFirst()
|
||||
}
|
||||
}
|
||||
rowGroups[row]!.append(f)
|
||||
}
|
||||
|
||||
for origRow in rowOrder.sorted() {
|
||||
// Iterate in rowOrder (which is now schema-sorted insertion order = row order)
|
||||
for origRow in rowOrder {
|
||||
guard let fields = rowGroups[origRow] else { continue }
|
||||
|
||||
// Section banner
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@ struct ExecuteInspectionView: View {
|
||||
|
||||
let inspection: LocalInspection
|
||||
|
||||
/// True when presented as the root of a fullScreenCover/sheet (e.g. the
|
||||
/// dashboard "Resume" banner) rather than pushed onto a NavigationStack.
|
||||
/// In that case there is no navigation back button, so a leading "Close"
|
||||
/// button is shown so the inspector can return home without submitting.
|
||||
/// Work is preserved either way — .onDisappear calls saveDraft().
|
||||
var isModallyPresented: Bool = false
|
||||
|
||||
@State private var formValues: [String: String] = [:]
|
||||
@State private var showFlagIssue = false
|
||||
@State private var showSubmitAlert = false
|
||||
@@ -65,6 +72,44 @@ struct ExecuteInspectionView: View {
|
||||
|
||||
private var formSchema: [[String: Any]] { template?.formSchema ?? [] }
|
||||
|
||||
/// Live score computed directly from `formValues` (in-memory SwiftUI state)
|
||||
/// so it updates as the inspector fills in each field — without waiting for
|
||||
/// `saveDraft()` to flush to SwiftData and `computeScore()` to run.
|
||||
/// Returns nil when the schema has no scoreable fields.
|
||||
private var liveScore: Double? {
|
||||
let scoreable = formSchema.filter {
|
||||
["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "")
|
||||
}
|
||||
guard !scoreable.isEmpty else { return nil }
|
||||
|
||||
var total = 0; var earned = 0
|
||||
for field in scoreable {
|
||||
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 = formValues[fid] ?? ""
|
||||
|
||||
switch ftype {
|
||||
case "rating":
|
||||
if let v = Int(val), v > 0 { earned += v; total += field["max"] as? Int ?? 5 }
|
||||
case "checkbox":
|
||||
total += 1; if val == "true" { earned += 1 }
|
||||
case "radio":
|
||||
total += 1
|
||||
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 }
|
||||
case "pass_fail":
|
||||
guard !val.isEmpty else { continue }
|
||||
total += 1
|
||||
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
guard total > 0 else { return nil }
|
||||
return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2)
|
||||
}
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────
|
||||
|
||||
var body: some View {
|
||||
@@ -82,8 +127,33 @@ struct ExecuteInspectionView: View {
|
||||
.navigationTitle(template?.name ?? "Inspection")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if isModallyPresented {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
// Root of a modal presentation — no nav back button exists.
|
||||
// Draft is saved on disappear, so closing loses nothing.
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ConnectivityBadge()
|
||||
HStack(spacing: 10) {
|
||||
// Live score — updates on every field change via formValues binding.
|
||||
// Only shown when the schema has at least one scoreable field.
|
||||
if let score = liveScore {
|
||||
let color: Color = score >= 80 ? .green : score >= 60 ? .orange : .red
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(color)
|
||||
Text(String(format: "%.0f%%", score))
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(color.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
ConnectivityBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -403,7 +473,7 @@ struct ExecuteInspectionView: View {
|
||||
let existingFormData = inspection.formData
|
||||
var data: [String: Any] = [:]
|
||||
for (k, v) in formValues {
|
||||
if let s = v as? String, s.hasPrefix("local://"),
|
||||
if v.hasPrefix("local://"),
|
||||
let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
|
||||
data[k] = saved
|
||||
} else {
|
||||
@@ -476,7 +546,7 @@ struct ExecuteInspectionView: View {
|
||||
let existingFormData = inspection.formData
|
||||
var data: [String: Any] = [:]
|
||||
for (k, v) in formValues {
|
||||
if let s = v as? String, s.hasPrefix("local://"),
|
||||
if v.hasPrefix("local://"),
|
||||
let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
|
||||
// processPhotoQueue already uploaded this photo — keep the server path
|
||||
data[k] = saved
|
||||
|
||||
@@ -527,8 +527,15 @@ struct CameraPickerView: UIViewControllerRepresentable {
|
||||
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
||||
) {
|
||||
if let img = info[.originalImage] as? UIImage {
|
||||
parent.image = img
|
||||
parent.onSelected(img)
|
||||
// UIImagePickerController with .camera source delivers images whose
|
||||
// imageOrientation reflects the physical device orientation at capture
|
||||
// time. On iPad in landscape the raw UIImage is rotated 90° relative
|
||||
// to what the user sees in the viewfinder. Drawing into a new context
|
||||
// at the display size bakes the transform into the pixel buffer,
|
||||
// producing a correctly-oriented image regardless of how it was held.
|
||||
let normalised = img.normalised()
|
||||
parent.image = normalised
|
||||
parent.onSelected(normalised)
|
||||
}
|
||||
picker.dismiss(animated: true)
|
||||
}
|
||||
@@ -715,3 +722,23 @@ struct TableFieldView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIImage orientation normalisation
|
||||
// UIImagePickerController delivers camera photos whose imageOrientation encodes
|
||||
// the device tilt at capture time. Consumers (savePhotoToDisk, PDF generator,
|
||||
// issue thumbnails) all call .jpegData() which honours the EXIF orientation —
|
||||
// but some downstream renderers (UIGraphicsImageRenderer, PDF drawing) ignore it
|
||||
// and display the raw rotated pixels. This extension bakes the orientation
|
||||
// transform into the pixel buffer so all consumers see an upright image.
|
||||
|
||||
extension UIImage {
|
||||
/// Returns a copy of the image with imageOrientation == .up, redrawing
|
||||
/// the pixels into a new context if the orientation is not already correct.
|
||||
func normalised() -> UIImage {
|
||||
guard imageOrientation != .up else { return self }
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { _ in
|
||||
draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
// Views/Dashboard/MyInspectionsView.swift
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MessageUI
|
||||
|
||||
// MARK: - My Inspections
|
||||
|
||||
struct MyInspectionsView: View {
|
||||
|
||||
@Query(
|
||||
filter: #Predicate<LocalInspection> { $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")
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(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<LocalTemplate>(
|
||||
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<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||
).first?.name) ?? "Unknown Facility"
|
||||
}
|
||||
|
||||
private var templateName: String {
|
||||
let id = inspection.templateServerId
|
||||
return (try? context.fetch(
|
||||
FetchDescriptor<LocalTemplate>(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<LocalTemplate>(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)
|
||||
.fullScreenCover(isPresented: $showReInspect) {
|
||||
StartInspectionView(
|
||||
preFillTemplateId: inspection.templateServerId,
|
||||
preFillFacilityId: inspection.facilityServerId,
|
||||
parentServerId: inspection.serverId,
|
||||
parentLocalId: inspection.localId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Views/Dashboard/NotificationsView.swift
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MessageUI
|
||||
|
||||
// MARK: - Notifications Inbox
|
||||
// Shows the most recent notifications fetched during polling.
|
||||
// Notifications are already marked read on the server by pollNotifications().
|
||||
|
||||
struct NotificationsView: View {
|
||||
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if sync.recentNotifications.isEmpty {
|
||||
if !sync.isOnline {
|
||||
ContentUnavailableView(
|
||||
"Offline",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text("Notifications are delivered when you go online.")
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Notifications",
|
||||
systemImage: "bell.slash",
|
||||
description: Text("You\'re all caught up.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
List(sync.recentNotifications) { notif in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: iconName(for: notif.eventType))
|
||||
.foregroundStyle(iconColor(for: notif.eventType))
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(notif.title)
|
||||
.font(.callout.bold())
|
||||
.lineLimit(2)
|
||||
Text(notif.body)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
if let date = SyncManager.isoFormatter.date(from: notif.createdAt) {
|
||||
Text(date.formatted(.relative(presentation: .named)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
sync.markNotificationsViewed()
|
||||
}
|
||||
.refreshable {
|
||||
await sync.pollNotifications()
|
||||
sync.markNotificationsViewed()
|
||||
}
|
||||
}
|
||||
|
||||
private func iconName(for eventType: String?) -> String {
|
||||
switch eventType {
|
||||
case "inspection_completed": return "checkmark.circle.fill"
|
||||
case "issue_flagged": return "exclamationmark.triangle.fill"
|
||||
case "issue_resolved": return "checkmark.seal.fill"
|
||||
case "sla_alert": return "clock.badge.exclamationmark"
|
||||
case "follow_up_required": return "exclamationmark.arrow.circlepath"
|
||||
default: return "bell.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func iconColor(for eventType: String?) -> Color {
|
||||
switch eventType {
|
||||
case "inspection_completed": return .green
|
||||
case "issue_flagged": return .orange
|
||||
case "issue_resolved": return .green
|
||||
case "sla_alert": return .red
|
||||
case "follow_up_required": return .orange
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Views/Dashboard/SettingsView.swift
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MessageUI
|
||||
|
||||
// MARK: - Templates
|
||||
|
||||
struct TemplatesListView: View {
|
||||
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if templates.isEmpty {
|
||||
ContentUnavailableView("No Templates", systemImage: "doc.text.magnifyingglass",
|
||||
description: Text("Connect to the internet to sync inspection templates."))
|
||||
} else {
|
||||
List(templates) { template in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(template.name).font(.headline)
|
||||
if !template.templateDescription.isEmpty {
|
||||
Text(template.templateDescription)
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
HStack {
|
||||
if !template.frequency.isEmpty {
|
||||
Label(template.frequencyLabel, systemImage: "clock")
|
||||
.font(.caption2).foregroundStyle(.blue)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")")
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Templates (\(templates.count))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@EnvironmentObject private var appearance: AppearanceManager
|
||||
@StateObject private var updateChecker = UpdateChecker.shared
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var showClearCacheAlert = false
|
||||
@State private var cacheCleared = false
|
||||
@State private var settingsServer: ServerOption = ServerConfig.selectedOption
|
||||
@State private var pendingServer: ServerOption? = nil
|
||||
@State private var showServerSwitchAlert = false
|
||||
@State private var hasCheckedOnce = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Account") {
|
||||
LabeledContent("Username", value: auth.currentUsername)
|
||||
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
||||
}
|
||||
|
||||
Section("Appearance") {
|
||||
Picker("Theme", selection: $appearance.mode) {
|
||||
ForEach(AppearanceMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section("Sync") {
|
||||
Button {
|
||||
Task { await sync.triggerSync() }
|
||||
} label: {
|
||||
Label("Sync Now", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!sync.isOnline || sync.isSyncing)
|
||||
|
||||
if let error = sync.syncError {
|
||||
Text(error).font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
|
||||
if let lastSync = sync.lastSyncAt {
|
||||
LabeledContent("Last Sync",
|
||||
value: lastSync.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
}
|
||||
|
||||
Section("Cache") {
|
||||
Button {
|
||||
showClearCacheAlert = true
|
||||
} label: {
|
||||
Label("Clear Reference Cache", systemImage: "trash")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Text("Clears locally cached facilities, areas, and templates. Your pending inspections are not affected. Data will re-sync on the next connection.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if cacheCleared {
|
||||
Label("Cache cleared.", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
// Do NOT clear server-pulled data on a plain logout —
|
||||
// the user is logging out of the same server, so cached
|
||||
// facilities, issues, and templates are still valid on
|
||||
// their next login. Clearing here leaves the issues list
|
||||
// empty until a full sync succeeds, which breaks offline use.
|
||||
// Server-pulled data is only cleared when switching servers
|
||||
// (see the Switch & Log Out alert below).
|
||||
sync.resetNotificationPoller()
|
||||
await auth.logout()
|
||||
}
|
||||
} label: {
|
||||
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Server", selection: $settingsServer) {
|
||||
ForEach(ServerOption.allCases, id: \.self) { option in
|
||||
Text(option.displayName).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: settingsServer) { _, newValue in
|
||||
// Don't commit yet — ask user to confirm logout first.
|
||||
// Revert the picker visually until confirmed.
|
||||
pendingServer = newValue
|
||||
settingsServer = ServerConfig.selectedOption // snap back
|
||||
showServerSwitchAlert = true
|
||||
}
|
||||
} header: {
|
||||
Text("Server")
|
||||
} footer: {
|
||||
Text(ServerConfig.current)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Section("App Info") {
|
||||
LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))")
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await updateChecker.checkForUpdate(force: true)
|
||||
hasCheckedOnce = true
|
||||
}
|
||||
} label: {
|
||||
if updateChecker.isChecking {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Checking…")
|
||||
}
|
||||
} else {
|
||||
Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
.disabled(updateChecker.isChecking)
|
||||
|
||||
if hasCheckedOnce, !updateChecker.isChecking, !updateChecker.updateAvailable {
|
||||
Label("You're on the latest version.", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.alert("Clear Reference Cache?", isPresented: $showClearCacheAlert) {
|
||||
Button("Clear", role: .destructive) { clearCache() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.")
|
||||
}
|
||||
.alert("Switch Server?", isPresented: $showServerSwitchAlert) {
|
||||
Button("Switch & Log Out", role: .destructive) {
|
||||
if let chosen = pendingServer {
|
||||
ServerConfig.select(chosen)
|
||||
settingsServer = chosen
|
||||
pendingServer = nil
|
||||
Task {
|
||||
clearServerPulledData()
|
||||
sync.resetNotificationPoller()
|
||||
await auth.logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingServer = nil
|
||||
}
|
||||
} message: {
|
||||
if let chosen = pendingServer {
|
||||
Text("Switching to \(chosen.displayName) will log you out. All cached server data will be cleared. You will need to log in again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearCache() {
|
||||
// Delete only reference data — never touch LocalInspection, LocalIssue, PendingPhoto
|
||||
let facilities = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
|
||||
let templates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
let areas = (try? context.fetch(FetchDescriptor<LocalArea>())) ?? []
|
||||
|
||||
facilities.forEach { context.delete($0) }
|
||||
templates.forEach { context.delete($0) }
|
||||
areas.forEach { context.delete($0) }
|
||||
|
||||
try? context.save()
|
||||
cacheCleared = true
|
||||
|
||||
// Re-pull immediately if online
|
||||
if sync.isOnline {
|
||||
Task { await sync.pullReferenceData() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete every LocalIssue that has ever been assigned a serverId.
|
||||
/// This covers two categories:
|
||||
/// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced")
|
||||
/// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil)
|
||||
/// — their serverIds are meaningless on a different server, so they must go too.
|
||||
/// The only records preserved are truly pending device-created issues
|
||||
/// (serverId == nil, syncStatus == "pending") that have never reached any server.
|
||||
private func clearServerPulledData() {
|
||||
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
allIssues
|
||||
.filter { $0.serverId != nil }
|
||||
.forEach { context.delete($0) }
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Views/Dashboard/SyncStatusView.swift
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MessageUI
|
||||
|
||||
// MARK: - Sync Status View
|
||||
|
||||
struct SyncStatusView: View {
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@Query(
|
||||
filter: #Predicate<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
||||
sort: \LocalInspection.createdAt
|
||||
) private var pendingInspections: [LocalInspection]
|
||||
|
||||
@Query(
|
||||
filter: #Predicate<LocalIssue> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
||||
sort: \LocalIssue.createdAt
|
||||
) private var pendingIssues: [LocalIssue]
|
||||
|
||||
private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } }
|
||||
private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } }
|
||||
private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Status") {
|
||||
HStack {
|
||||
Circle().fill(sync.isOnline ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(sync.isOnline ? "Online" : "Offline")
|
||||
}
|
||||
if let lastSync = sync.lastSyncAt {
|
||||
LabeledContent("Last Sync",
|
||||
value: lastSync.formatted(date: .abbreviated, time: .shortened))
|
||||
}
|
||||
if sync.isSyncing {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Syncing…").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let error = sync.syncError {
|
||||
Text(error).foregroundStyle(.red).font(.callout)
|
||||
}
|
||||
Button {
|
||||
Task { await sync.triggerSync() }
|
||||
} label: {
|
||||
Label("Sync Now", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(!sync.isOnline || sync.isSyncing)
|
||||
|
||||
// Retry Failed Items — resets syncStatus back to "pending" so
|
||||
// the next triggerSync() will re-attempt them. Once an item
|
||||
// reaches syncStatus = "failed" (after 5 consecutive errors)
|
||||
// triggerSync() stops picking it up — this is the only way
|
||||
// to re-queue it without manual server intervention.
|
||||
if hasFailedItems {
|
||||
Button {
|
||||
retryAllFailed()
|
||||
} label: {
|
||||
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))",
|
||||
systemImage: "exclamationmark.arrow.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.disabled(!sync.isOnline || sync.isSyncing)
|
||||
}
|
||||
}
|
||||
|
||||
if !pendingInspections.isEmpty {
|
||||
Section("Pending Inspections (\(pendingInspections.count))") {
|
||||
ForEach(pendingInspections) { insp in
|
||||
SyncRowView(title: "Inspection", status: insp.syncStatus,
|
||||
retryCount: insp.syncRetryCount,
|
||||
error: insp.syncErrorMessage, date: insp.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !pendingIssues.isEmpty {
|
||||
Section("Pending Issues (\(pendingIssues.count))") {
|
||||
ForEach(pendingIssues) { issue in
|
||||
SyncRowView(title: "\(issue.severity.capitalized) Issue",
|
||||
status: issue.syncStatus, retryCount: issue.syncRetryCount,
|
||||
error: issue.syncErrorMessage, date: issue.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing {
|
||||
Section {
|
||||
Label("All items synced.", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pending Sync")
|
||||
}
|
||||
|
||||
/// Reset all failed items back to pending so the next sync pass picks them up.
|
||||
/// Also clears syncRetryCount and syncErrorMessage so the retry counter
|
||||
/// starts fresh — prevents them immediately hitting the 5-retry cap again
|
||||
/// without any actual new attempt.
|
||||
private func retryAllFailed() {
|
||||
for insp in failedInspections {
|
||||
insp.syncStatus = "pending"
|
||||
insp.syncRetryCount = 0
|
||||
insp.syncErrorMessage = nil
|
||||
}
|
||||
for issue in failedIssues {
|
||||
issue.syncStatus = "pending"
|
||||
issue.syncRetryCount = 0
|
||||
issue.syncErrorMessage = nil
|
||||
}
|
||||
try? context.save()
|
||||
Task { await sync.triggerSync() }
|
||||
}
|
||||
}
|
||||
|
||||
struct SyncRowView: View {
|
||||
let title: String
|
||||
let status: String
|
||||
let retryCount: Int
|
||||
let error: String?
|
||||
let date: Date
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(title).font(.callout)
|
||||
Spacer()
|
||||
Text(status.capitalized).font(.caption2)
|
||||
.foregroundStyle(status == "failed" ? .red : .orange)
|
||||
}
|
||||
Text(date.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
if let err = error {
|
||||
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2)
|
||||
}
|
||||
if retryCount > 0 {
|
||||
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,6 +452,7 @@ struct HistoryDetailView: View {
|
||||
schema: formSchema,
|
||||
formValues: savedValues
|
||||
)
|
||||
.environment(\.mediaURLByPath, inspection.mediaURLByPath)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
@@ -678,9 +679,17 @@ struct ReadOnlyGridFormView: View {
|
||||
let skipTypes: Set<String> = ["label", "section",
|
||||
"button_submit", "button_print", "button_email"]
|
||||
|
||||
// Sort schema by (row, col) once — same as web PDF and InspectionPDFGenerator.
|
||||
let sortedSchema = schema.sorted {
|
||||
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
|
||||
if r0 != r1 { return r0 < r1 }
|
||||
let c0 = $0["col"] as? Int ?? 0, c1 = $1["col"] as? Int ?? 0
|
||||
return c0 < c1
|
||||
}
|
||||
|
||||
// Pass 1 — answered data field IDs
|
||||
var answeredIds = Set<String>()
|
||||
for f in schema {
|
||||
for f in sortedSchema {
|
||||
guard let ftype = f["type"] as? String, !skipTypes.contains(ftype) else { continue }
|
||||
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||
let val = formValues[fid] ?? ""
|
||||
@@ -691,7 +700,7 @@ struct ReadOnlyGridFormView: View {
|
||||
// Pass 2 — label IDs that immediately precede an answered field
|
||||
var visibleLabelIds = Set<String>()
|
||||
var lbuf: [String] = []
|
||||
for f in schema {
|
||||
for f in sortedSchema {
|
||||
let ftype = f["type"] as? String ?? ""
|
||||
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||
if ftype == "label" {
|
||||
@@ -705,7 +714,7 @@ struct ReadOnlyGridFormView: View {
|
||||
// Pass 3 — section IDs that precede at least one answered field
|
||||
var visibleSectionIds = Set<String>()
|
||||
var pendingSecId: String? = nil
|
||||
for f in schema {
|
||||
for f in sortedSchema {
|
||||
let ftype = f["type"] as? String ?? ""
|
||||
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||
if ftype == "section" {
|
||||
@@ -715,18 +724,20 @@ struct ReadOnlyGridFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4 — group fields by original row, keep schema order
|
||||
// Pass 4 — group fields by original row, keep schema order.
|
||||
// sortedSchema already computed above — reuse it.
|
||||
var rowGroups: [Int: [[String: Any]]] = [:]
|
||||
var rowOrder: [Int] = []
|
||||
for f in schema {
|
||||
for f in sortedSchema {
|
||||
let row = f["row"] as? Int ?? 1
|
||||
if rowGroups[row] == nil { rowOrder.append(row); rowGroups[row] = [] }
|
||||
rowGroups[row]!.append(f)
|
||||
}
|
||||
|
||||
// Pass 5 — for each row, collect visible fields; skip rows with none
|
||||
// Pass 5 — for each row, collect visible fields; skip rows with none.
|
||||
// Iterate in rowOrder (insertion order of sorted schema = row order).
|
||||
var result: [RowGroup] = []
|
||||
for origRow in rowOrder.sorted() {
|
||||
for origRow in rowOrder {
|
||||
guard let group = rowGroups[origRow] else { continue }
|
||||
var visibleInRow: [[String: Any]] = []
|
||||
for f in group {
|
||||
@@ -918,6 +929,7 @@ struct ReadOnlyCellView: View {
|
||||
|
||||
struct PhotoThumbnailView: View {
|
||||
let value: String
|
||||
@Environment(\.mediaURLByPath) private var mediaURLByPath
|
||||
@State private var showLightbox = false
|
||||
|
||||
var body: some View {
|
||||
@@ -944,7 +956,7 @@ struct PhotoThumbnailView: View {
|
||||
.font(.system(size: 11)).foregroundStyle(.secondary)
|
||||
}
|
||||
} else if value.hasPrefix("uploads/") {
|
||||
let url = URL(string: "\(ServerConfig.current)/static/\(value)")
|
||||
let url = ServerConfig.mediaURL(absolute: mediaURLByPath[value], path: value)
|
||||
thumbnailButton {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
@@ -1032,3 +1044,19 @@ struct MailComposeView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Media URL environment
|
||||
// Injects a {relative_path: absolute_url} map (from APIInspectionSummary.mediaURLByPath)
|
||||
// so image cells deep inside the read-only grid can resolve presigned R2 URLs
|
||||
// without threading field IDs through every layer. Empty map → the resolver
|
||||
// falls back to building a /static/ URL from the relative path.
|
||||
private struct MediaURLByPathKey: EnvironmentKey {
|
||||
static let defaultValue: [String: String] = [:]
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var mediaURLByPath: [String: String] {
|
||||
get { self[MediaURLByPathKey.self] }
|
||||
set { self[MediaURLByPathKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user