Compare commits

...
10 Commits
34 changed files with 8394 additions and 1798 deletions
+4 -2
View File
@@ -425,13 +425,14 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4; MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -468,13 +469,14 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.4; MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
+159 -3
View File
@@ -2,6 +2,7 @@
import Foundation import Foundation
import Combine import Combine
import UIKit
enum APIError: Error, LocalizedError, Sendable { enum APIError: Error, LocalizedError, Sendable {
case invalidURL 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) // Fetch Issue Detail (status + assigned_to)
func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail { func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail {
@@ -277,13 +331,21 @@ actor APIClient {
// Notification polling (Phase C) // 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`. /// Fetch notifications, optionally scoped to those created after `since`.
func fetchNotifications(since: Date? = nil) async throws -> [APINotification] { func fetchNotifications(since: Date? = nil) async throws -> [APINotification] {
var ep = "/api/v1/notifications" var ep = "/api/v1/notifications"
if let since { if let since {
let fmt = DateFormatter() ep += "?since=\(Self.notifSinceFmt.string(from: since))"
fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
ep += "?since=\(fmt.string(from: since))"
} }
let result: APINotificationsResponseData = try await request(ep) let result: APINotificationsResponseData = try await request(ep)
return result.notifications return result.notifications
@@ -305,11 +367,105 @@ actor APIClient {
return result.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). /// Fetch dashboard KPI counts for the current user (Phase B).
func fetchDashboardStats() async throws -> APIDashboardStats { func fetchDashboardStats() async throws -> APIDashboardStats {
return try await request("/api/v1/stats/dashboard") 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) // Issue Comments (Phase D)
/// Fetch all comments for an issue, oldest-first. /// Fetch all comments for an issue, oldest-first.
+135 -1
View File
@@ -296,6 +296,8 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
// detail view works without a local SwiftData copy (e.g. after reinstall). // detail view works without a local SwiftData copy (e.g. after reinstall).
let formDataRaw: [String: JSONValue] let formDataRaw: [String: JSONValue]
let formSchemaRaw: [[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 // Follow-up / re-inspection
let followUpRequired: Bool let followUpRequired: Bool
let followUpNote: String? let followUpNote: String?
@@ -323,6 +325,20 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
formSchemaRaw.map { dict in dict.mapValues { $0.anyValue } } 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? { var inspectionDateParsed: Date? {
guard let str = inspectionDate else { return nil } guard let str = inspectionDate else { return nil }
// Server sends "yyyy-MM-dd'T'HH:mm:ss" with no timezone suffix. // 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) mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? "" inspectorNotes = (try? c.decode(String.self, forKey: .inspectorNotes)) ?? ""
formDataRaw = (try? c.decode([String: JSONValue].self, forKey: .formData)) ?? [:] 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)) ?? [] formSchemaRaw = (try? c.decode([[String: JSONValue]].self, forKey: .formSchema)) ?? []
followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false followUpRequired = (try? c.decode(Bool.self, forKey: .followUpRequired)) ?? false
followUpNote = try? c.decode(String.self, forKey: .followUpNote) followUpNote = try? c.decode(String.self, forKey: .followUpNote)
@@ -357,6 +374,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
case areaId, areaName, status, overallScore case areaId, areaName, status, overallScore
case inspectionDate, completedAt, mobileLocalId, inspectorNotes case inspectionDate, completedAt, mobileLocalId, inspectorNotes
case formData, formSchema case formData, formSchema
case formMedia
case followUpRequired, followUpNote, parentInspectionId case followUpRequired, followUpNote, parentInspectionId
} }
@@ -403,9 +421,21 @@ struct APIIssueDetail: Decodable, Sendable {
let verifiedAt: String? let verifiedAt: String?
let verificationNote: String? let verificationNote: String?
let reportedByName: 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 // Phase E area and assignee context
let areaName: String? let areaName: String?
let assignedToName: 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 { nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -422,14 +452,28 @@ struct APIIssueDetail: Decodable, Sendable {
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
verificationNote = try? c.decode(String.self, forKey: .verificationNote) verificationNote = try? c.decode(String.self, forKey: .verificationNote)
reportedByName = try? c.decode(String.self, forKey: .reportedByName) 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) areaName = try? c.decode(String.self, forKey: .areaName)
assignedToName = try? c.decode(String.self, forKey: .assignedToName) 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 { private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, resolvedAt case facilityId, facilityName, reportedAt, resolvedAt
case resultNotes, verifiedAt, verificationNote, reportedByName case resultNotes, verifiedAt, verificationNote, reportedByName, resultPhotos
case resultPhotoUrls
case areaName, assignedToName 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 } 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) // Notification polling (Phase C)
struct APINotification: Decodable, Identifiable, Sendable { struct APINotification: Decodable, Identifiable, Sendable {
@@ -506,6 +562,10 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
let photoPath: String? // primary evidence photo let photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad let mobilePhotoPaths: [String] // extra evidence photos from iPad
let resultPhotos: [String] // resolution photos added via web 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 // Phase A resolution details from web
let resultNotes: String? let resultNotes: String?
let verifiedAt: String? let verifiedAt: String?
@@ -514,6 +574,15 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
// Phase E area and assignee context // Phase E area and assignee context
let areaName: String? let areaName: String?
let assignedToName: 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 { nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) 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) photoPath = try? c.decode(String.self, forKey: .photoPath)
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? [] mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? [] 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) resultNotes = try? c.decode(String.self, forKey: .resultNotes)
verifiedAt = try? c.decode(String.self, forKey: .verifiedAt) verifiedAt = try? c.decode(String.self, forKey: .verifiedAt)
verificationNote = try? c.decode(String.self, forKey: .verificationNote) verificationNote = try? c.decode(String.self, forKey: .verificationNote)
reportedByName = try? c.decode(String.self, forKey: .reportedByName) reportedByName = try? c.decode(String.self, forKey: .reportedByName)
areaName = try? c.decode(String.self, forKey: .areaName) areaName = try? c.decode(String.self, forKey: .areaName)
assignedToName = try? c.decode(String.self, forKey: .assignedToName) 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 { private enum CodingKeys: String, CodingKey {
case id, status, severity, description, assignedTo case id, status, severity, description, assignedTo
case facilityId, facilityName, reportedAt, mobileLocalId case facilityId, facilityName, reportedAt, mobileLocalId
case photoPath, mobilePhotoPaths, resultPhotos case photoPath, mobilePhotoPaths, resultPhotos
case photoUrls, resultPhotoUrls
case resultNotes, verifiedAt, verificationNote, reportedByName case resultNotes, verifiedAt, verificationNote, reportedByName
case areaName, assignedToName 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 } 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) // Dashboard Stats (Phase B)
struct APIDashboardStats: Decodable, Sendable { 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
@@ -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
)
}
}
}
@@ -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
)
}
}
}
}
+4
View File
@@ -58,6 +58,10 @@ class AuthManager: ObservableObject {
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken) KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
applyUser(response.user) applyUser(response.user)
isAuthenticated = true 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) { } catch APIError.serverError(let msg) {
errorMessage = msg errorMessage = msg
} catch APIError.networkError { } catch APIError.networkError {
+32 -5
View File
@@ -180,10 +180,11 @@ Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`,
```swift ```swift
LocalFacility.self, LocalArea.self, LocalTemplate.self, 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 ### Model Reference
@@ -193,7 +194,8 @@ LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` | | `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) | | `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?) | | `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` | | `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` | | `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 | | `updateIssueStatus` | `PATCH /api/v1/issues/<id>/status` | Inspector updates status |
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor | | `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server | | `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 ### `APIAssignedIssue` fields
@@ -322,9 +326,14 @@ let reportedByName: String? // reporter display_name
// Phase E: // Phase E:
let areaName: String? // area the issue was flagged in let areaName: String? // area the issue was flagged in
let assignedToName: String? // assigned user display_name 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. `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 6364 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 ### 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. 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). | | 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. | | 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. | | 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. |
--- ---
+34
View File
@@ -7,6 +7,7 @@ struct ContentView: View {
@EnvironmentObject private var auth: AuthManager @EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager @EnvironmentObject private var sync: SyncManager
@StateObject private var updateChecker = UpdateChecker.shared @StateObject private var updateChecker = UpdateChecker.shared
@Environment(\.scenePhase) private var scenePhase
var body: some View { var body: some View {
Group { Group {
@@ -41,6 +42,39 @@ struct ContentView: View {
// any failure (offline, parse error, etc.) so it never disrupts // any failure (offline, parse error, etc.) so it never disrupts
// normal app use. // normal app use.
await updateChecker.checkForUpdate() 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) { .alert("Update Available", isPresented: $updateChecker.updateAvailable) {
Button("Update") { Button("Update") {
+1
View File
@@ -100,6 +100,7 @@ struct JanitorialQCApp: App {
LocalTemplate.self, LocalTemplate.self,
LocalInspection.self, LocalInspection.self,
LocalIssue.self, LocalIssue.self,
LocalScheduledInspection.self,
PendingPhoto.self, PendingPhoto.self,
SyncQueueEntry.self, SyncQueueEntry.self,
], isUndoEnabled: false) { result in ], isUndoEnabled: false) { result in
+130 -4
View File
@@ -23,17 +23,117 @@ final class LocalIssue {
var photoLocalPathsJSON: String = "[]" var photoLocalPathsJSON: String = "[]"
/// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...] /// JSON-encoded array of server paths after upload, e.g. ["uploads/issue_photos/abc.jpg", ...]
var photoServerPathsJSON: String = "[]" 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) /// Decoded local photo paths (up to 5)
var photoLocalPaths: [String] { var photoLocalPaths: [String] {
get { (try? JSONDecoder().decode([String].self, from: Data(photoLocalPathsJSON.utf8))) ?? [] } get {
set { photoLocalPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" } 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 /// Decoded server photo paths
var photoServerPaths: [String] { var photoServerPaths: [String] {
get { (try? JSONDecoder().decode([String].self, from: Data(photoServerPathsJSON.utf8))) ?? [] } get {
set { photoServerPathsJSON = (try? String(data: JSONEncoder().encode(newValue), encoding: .utf8)) ?? "[]" } 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 var createdAt: Date
@@ -74,6 +174,20 @@ final class LocalIssue {
/// Nil when unassigned. Updated on every pullAssignedIssues(). /// Nil when unassigned. Updated on every pullAssignedIssues().
var assignedToName: String? 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 // Explicit inverse declared so SwiftData has an unambiguous relationship
// graph at schema-build time. Without it the relationship is implicit, // graph at schema-build time. Without it the relationship is implicit,
// which can cause migration warnings or incorrect cascade behaviour on some // which can cause migration warnings or incorrect cascade behaviour on some
@@ -99,6 +213,9 @@ final class LocalIssue {
self.issueStatus = "open" self.issueStatus = "open"
self.photoLocalPathsJSON = "[]" self.photoLocalPathsJSON = "[]"
self.photoServerPathsJSON = "[]" self.photoServerPathsJSON = "[]"
self.resultPhotoServerPathsJSON = "[]"
self.photoServerUrlsJSON = "[]"
self.resultPhotoServerUrlsJSON = "[]"
self.createdAt = Date() self.createdAt = Date()
self.syncStatus = "pending" self.syncStatus = "pending"
self.syncRetryCount = 0 self.syncRetryCount = 0
@@ -112,6 +229,15 @@ final class LocalIssue {
self.reportedByName = nil self.reportedByName = nil
self.areaNameCache = nil self.areaNameCache = nil
self.assignedToName = 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 { 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 -2
View File
@@ -15,6 +15,10 @@ final class LocalTemplate {
var formSchemaJSON: String var formSchemaJSON: String
var lastSyncedAt: Date var lastSyncedAt: Date
var isActive: Bool = true // phase21 false templates excluded from picker 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) { init(from summary: APITemplateSummary) {
self.serverId = summary.id self.serverId = summary.id
@@ -26,12 +30,20 @@ final class LocalTemplate {
self.isActive = summary.isActive 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.name = summary.name
self.templateDescription = summary.description self.templateDescription = summary.description
self.frequency = summary.frequency self.frequency = summary.frequency
self.isActive = summary.isActive self.isActive = summary.isActive
self.lastSyncedAt = Date() self.lastSyncedAt = Date()
return changed
} }
func updateSchema(from template: APITemplate) { func updateSchema(from template: APITemplate) {
@@ -43,7 +55,8 @@ final class LocalTemplate {
let str = String(data: data, encoding: .utf8) { let str = String(data: data, encoding: .utf8) {
self.formSchemaJSON = str self.formSchemaJSON = str
} }
self.lastSyncedAt = Date() self.schemaFetchedAt = Date()
self.lastSyncedAt = Date()
} }
/// Decode stored JSON back into [[String: Any]] for the form renderer /// Decode stored JSON back into [[String: Any]] for the form renderer
+12 -3
View File
@@ -1,8 +1,17 @@
// Models/SyncQueueEntry.swift // Models/SyncQueueEntry.swift
// --------------------------- // ---------------------------
// SwiftData model for the outbox sync queue. // LEGACY This model is no longer used. The original design enqueued every
// Every offline write (inspection, issue, photo) enqueues an entry here. // offline write here and processed in FIFO order; the current architecture uses
// SyncManager processes entries in FIFO order when connectivity is restored. // 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 Foundation
import SwiftData import SwiftData
+175 -51
View File
@@ -118,6 +118,21 @@ class SyncManager: ObservableObject {
stopPollTask() 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. /// Call when the user opens the NotificationsView to clear the badge.
func markNotificationsViewed() { func markNotificationsViewed() {
unreadNotificationCount = 0 unreadNotificationCount = 0
@@ -136,20 +151,25 @@ class SyncManager: ObservableObject {
deliverLocalNotification(n) deliverLocalNotification(n)
} }
// Update in-app inbox state // Update in-app inbox state.
recentNotifications = notifications + recentNotifications.prefix(50 - notifications.count) // 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 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) } let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) }
if let newest = dates.max() { if let newest = dates.max() {
lastNotificationFetch = newest 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 { } catch APIError.notAuthenticated {
// Token expired and refresh failed let AuthManager handle it // Token expired and refresh failed let AuthManager handle it
} catch { } catch {
@@ -217,6 +237,7 @@ class SyncManager: ObservableObject {
await processIssueQueue(context: context) await processIssueQueue(context: context)
await pullReferenceData() await pullReferenceData()
await pullAssignedIssues(context: context) await pullAssignedIssues(context: context)
await pullScheduledInspections(context: context)
// Poll notifications immediately on every sync rather than waiting // Poll notifications immediately on every sync rather than waiting
// for the 60-second timer ensures the inspector sees assignments // for the 60-second timer ensures the inspector sees assignments
@@ -268,6 +289,16 @@ class SyncManager: ObservableObject {
dup.uploadStatus = "uploaded" 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 { for photo in pending {
do { do {
let serverPath = try await APIClient.shared.uploadPhoto( let serverPath = try await APIClient.shared.uploadPhoto(
@@ -277,20 +308,18 @@ class SyncManager: ObservableObject {
photo.serverPath = serverPath photo.serverPath = serverPath
photo.uploadStatus = "uploaded" 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 { if photo.entityType == "inspection", let fieldId = photo.fieldId {
let entityId = photo.entityLocalId let entityId = photo.entityLocalId
// Fetch-all + filter in Swift #Predicate with a captured String allInspections.first(where: { $0.localId == entityId })?
// variable causes "LocalInspection is ambiguous" under Xcode 26 .setValue(serverPath, forFieldId: fieldId)
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor (CLAUDE.md rule 3).
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
allInspections.first(where: { $0.localId == entityId })?.setValue(serverPath, forFieldId: fieldId)
} }
// Update parent issue photo paths array // Update parent issue photo paths array.
// Pre-fetched before the loop not repeated per photo.
if photo.entityType == "issue" { if photo.entityType == "issue" {
let entityId = photo.entityLocalId let entityId = photo.entityLocalId
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
if let issue = allIssues.first(where: { $0.localId == entityId }) { if let issue = allIssues.first(where: { $0.localId == entityId }) {
var paths = issue.photoServerPaths var paths = issue.photoServerPaths
if !paths.contains(serverPath) { paths.append(serverPath) } if !paths.contains(serverPath) { paths.append(serverPath) }
@@ -328,12 +357,12 @@ class SyncManager: ObservableObject {
inspection.status = "synced" inspection.status = "synced"
// Clear follow-up flag on parent. // Clear follow-up flag on parent.
if let parentLocalId = inspection.parentLocalId { // Reuses the `all` array already fetched at the top of this
let allInspections = try? context.fetch(FetchDescriptor<LocalInspection>()) // function avoids a redundant full-table fetch per inspection.
if let parent = allInspections?.first(where: { $0.localId == parentLocalId }) { if let parentLocalId = inspection.parentLocalId,
parent.followUpRequired = false let parent = all.first(where: { $0.localId == parentLocalId }) {
parent.followUpNote = nil parent.followUpRequired = false
} parent.followUpNote = nil
} }
try? context.save() try? context.save()
@@ -485,13 +514,18 @@ class SyncManager: ObservableObject {
) )
for apiSummary in templatesData.templates { for apiSummary in templatesData.templates {
let summaryChanged: Bool
if let existing = templateMap[apiSummary.id] { if let existing = templateMap[apiSummary.id] {
existing.updateSummary(from: apiSummary) summaryChanged = existing.updateSummary(from: apiSummary)
} else { } else {
context.insert(LocalTemplate(from: apiSummary)) context.insert(LocalTemplate(from: apiSummary))
summaryChanged = true // new template must fetch schema
} }
try await upsertTemplateSchema( 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 } guard last == nil || Date().timeIntervalSince(last!) >= Self.cleanupInterval else { return }
UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey) UserDefaults.standard.set(Date(), forKey: Self.lastCleanupKey)
let fm = FileManager.default // Phase 1: collect referenced paths on @MainActor (SwiftData fetches)
guard let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first else { return } // These are fast in-memory operations always runs on the main actor.
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.
var referencedPaths = Set<String>() var referencedPaths = Set<String>()
// PendingPhoto not yet uploaded // PendingPhoto not yet uploaded
@@ -568,21 +596,37 @@ class SyncManager: ObservableObject {
} }
} }
// Delete any disk file not in referencedPaths // Phase 2: FileManager enumeration + deletion on a background thread
var deletedCount = 0 // Directory enumeration and file removal are I/O-bound and can stutter
for fileURL in diskFiles { // the main thread when JQCPhotos/ contains hundreds of files. Dispatching
let path = fileURL.path // here is safe because `referencedPaths` is a value type (Set<String>)
if !referencedPaths.contains(path) { // captured by copy no shared mutable state crosses the boundary.
try? fm.removeItem(at: fileURL) Task.detached(priority: .utility) {
deletedCount += 1 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) { 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>()))? let inspCount = (try? context.fetch(FetchDescriptor<LocalInspection>()))?
.filter { $0.syncStatus == "pending" }.count ?? 0 .filter { $0.syncStatus == "pending" }.count ?? 0
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))? let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
@@ -619,18 +663,33 @@ class SyncManager: ObservableObject {
private func upsertTemplateSchema( private func upsertTemplateSchema(
id: Int, id: Int,
context: ModelContext, context: ModelContext,
templateMap: [Int: LocalTemplate] templateMap: [Int: LocalTemplate],
summaryChanged: Bool
) async throws { ) 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( let detailData: TemplateDetailResponseData = try await APIClient.shared.request(
"/api/v1/templates/\(id)" "/api/v1/templates/\(id)"
) )
if let existing = templateMap[id] { existing?.updateSchema(from: detailData.template)
existing.updateSchema(from: detailData.template)
} else {
(try? context.fetch(FetchDescriptor<LocalTemplate>()))?
.first { $0.serverId == id }?
.updateSchema(from: detailData.template)
}
} }
// Pull server-assigned issues // Pull server-assigned issues
@@ -672,18 +731,31 @@ class SyncManager: ObservableObject {
existing.reportedByName = api.reportedByName existing.reportedByName = api.reportedByName
existing.areaNameCache = api.areaName existing.areaNameCache = api.areaName
existing.assignedToName = api.assignedToName 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, if let vts = api.verifiedAt,
let date = Self.isoFormatter.date(from: vts) { let date = Self.isoFormatter.date(from: vts) {
existing.verifiedAt = date existing.verifiedAt = date
} }
// Refresh photos in case they were added after first pull // Refresh photos in case they were added after first pull
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths). // photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
// result_photos are resolution photos shown separately on the web, // result_photos are resolution photos shown separately under
// not displayed on the iPad issues list. // "Resolution Details" in resultPhotoServerPaths.
var serverPaths: [String] = [] var serverPaths: [String] = []
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
serverPaths.append(contentsOf: api.mobilePhotoPaths) serverPaths.append(contentsOf: api.mobilePhotoPaths)
existing.photoServerPaths = serverPaths existing.photoServerPaths = serverPaths
existing.resultPhotoServerPaths = api.resultPhotos
// Absolute display URLs (presigned R2 / static) parallel arrays.
existing.photoServerUrls = api.photoUrls
existing.resultPhotoServerUrls = api.resultPhotoUrls
} else { } else {
// Insert new server-pulled issue // Insert new server-pulled issue
let local = LocalIssue( let local = LocalIssue(
@@ -703,6 +775,15 @@ class SyncManager: ObservableObject {
local.reportedByName = api.reportedByName local.reportedByName = api.reportedByName
local.areaNameCache = api.areaName local.areaNameCache = api.areaName
local.assignedToName = api.assignedToName 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, if let vts = api.verifiedAt,
let date = Self.isoFormatter.date(from: vts) { let date = Self.isoFormatter.date(from: vts) {
local.verifiedAt = date local.verifiedAt = date
@@ -713,6 +794,10 @@ class SyncManager: ObservableObject {
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) } if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
serverPaths.append(contentsOf: api.mobilePhotoPaths) serverPaths.append(contentsOf: api.mobilePhotoPaths)
local.photoServerPaths = serverPaths 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, if let ts = api.reportedAt,
let date = Self.isoFormatter.date(from: ts) { let date = Self.isoFormatter.date(from: ts) {
local.createdAt = date 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 // Dashboard Stats
// Best-effort fetch a network failure silently leaves dashboardStats nil // Best-effort fetch a network failure silently leaves dashboardStats nil
// so the UI falls back to a placeholder card. Never blocks the sync pipeline. // so the UI falls back to a placeholder card. Never blocks the sync pipeline.
+11
View File
@@ -47,6 +47,16 @@ nonisolated enum ServerConfig {
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? "" let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
return ServerOption(rawValue: raw) ?? .primary 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 // MARK: - App-wide constants
@@ -62,6 +72,7 @@ nonisolated enum Constants {
static let userRole = "com.jqc.userRole" static let userRole = "com.jqc.userRole"
static let username = "com.jqc.username" static let username = "com.jqc.username"
static let displayName = "com.jqc.displayName" static let displayName = "com.jqc.displayName"
static let deviceId = "com.jqc.deviceId"
} }
static let tokenRefreshBufferMinutes: Double = 5 static let tokenRefreshBufferMinutes: Double = 5
+33 -15
View File
@@ -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 kTopY: CGFloat = kHdrH + 14 // first content Y on a fresh page
private let kBotY: CGFloat = kH - kFtrH // lowest Y before footer private let kBotY: CGFloat = kH - kFtrH // lowest Y before footer
// Image compression keeps the PDF (and thus the email attachment) small /// Downscale to 700px on the longer side and re-encode as JPEG quality 0.55,
// regardless of source photo resolution. Same values as IssuePDFGenerator. /// returning a fresh UIImage built from the compressed bytes. Applied to
private let kImgMaxPx: CGFloat = 700 /// every photo before it's embedded in the PDF keeps file size minimal.
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.
/// nonisolated: called from inside a TaskGroup (concurrent), not @MainActor. /// nonisolated: called from inside a TaskGroup (concurrent), not @MainActor.
private nonisolated func compress(_ image: UIImage) -> UIImage? { private nonisolated func compress(_ image: UIImage) -> UIImage? {
let kImgMaxPx: CGFloat = 700
let kImgJPEGQuality: CGFloat = 0.55
let size = image.size let size = image.size
guard size.width > 0, size.height > 0 else { return nil } guard size.width > 0, size.height > 0 else { return nil }
let scale = min(kImgMaxPx / size.width, kImgMaxPx / size.height, 1.0) 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 baseRowH: CGFloat = 28
let photoRowH: CGFloat = 92 // taller row to fit an embedded photo 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 rowGroups: [Int: [[String: Any]]] = [:]
var rowOrder: [Int] = [] var rowOrder: [Int] = []
var pendingSec: String? = nil var sectionQueue: [String] = [] // pending section labels, in schema order
var secForRow: [Int: String] = [:] var secForRow: [Int: String] = [:]
for f in schema { for f in sortedSchema {
let ftype = f["type"] as? String ?? "" let ftype = f["type"] as? String ?? ""
if skipTypes.contains(ftype) { continue } 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 let row = f["row"] as? Int ?? 1
if rowGroups[row] == nil { if rowGroups[row] == nil {
rowOrder.append(row) rowOrder.append(row)
rowGroups[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) 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 } guard let fields = rowGroups[origRow] else { continue }
// Section banner // Section banner
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@ struct ExecuteInspectionView: View {
let inspection: LocalInspection 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 formValues: [String: String] = [:]
@State private var showFlagIssue = false @State private var showFlagIssue = false
@State private var showSubmitAlert = false @State private var showSubmitAlert = false
@@ -65,6 +72,44 @@ struct ExecuteInspectionView: View {
private var formSchema: [[String: Any]] { template?.formSchema ?? [] } 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 // Body
var body: some View { var body: some View {
@@ -82,8 +127,33 @@ struct ExecuteInspectionView: View {
.navigationTitle(template?.name ?? "Inspection") .navigationTitle(template?.name ?? "Inspection")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .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) { 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 { .onAppear {
@@ -403,7 +473,7 @@ struct ExecuteInspectionView: View {
let existingFormData = inspection.formData let existingFormData = inspection.formData
var data: [String: Any] = [:] var data: [String: Any] = [:]
for (k, v) in formValues { 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/") { let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
data[k] = saved data[k] = saved
} else { } else {
@@ -476,7 +546,7 @@ struct ExecuteInspectionView: View {
let existingFormData = inspection.formData let existingFormData = inspection.formData
var data: [String: Any] = [:] var data: [String: Any] = [:]
for (k, v) in formValues { 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/") { let saved = existingFormData[k] as? String, saved.hasPrefix("uploads/") {
// processPhotoQueue already uploaded this photo keep the server path // processPhotoQueue already uploaded this photo keep the server path
data[k] = saved data[k] = saved
@@ -527,8 +527,15 @@ struct CameraPickerView: UIViewControllerRepresentable {
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) { ) {
if let img = info[.originalImage] as? UIImage { if let img = info[.originalImage] as? UIImage {
parent.image = img // UIImagePickerController with .camera source delivers images whose
parent.onSelected(img) // 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) 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, schema: formSchema,
formValues: savedValues formValues: savedValues
) )
.environment(\.mediaURLByPath, inspection.mediaURLByPath)
.padding(.horizontal, 24) .padding(.horizontal, 24)
} }
} }
@@ -678,9 +679,17 @@ struct ReadOnlyGridFormView: View {
let skipTypes: Set<String> = ["label", "section", let skipTypes: Set<String> = ["label", "section",
"button_submit", "button_print", "button_email"] "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 // Pass 1 answered data field IDs
var answeredIds = Set<String>() var answeredIds = Set<String>()
for f in schema { for f in sortedSchema {
guard let ftype = f["type"] as? String, !skipTypes.contains(ftype) else { continue } 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 fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
let val = formValues[fid] ?? "" let val = formValues[fid] ?? ""
@@ -691,7 +700,7 @@ struct ReadOnlyGridFormView: View {
// Pass 2 label IDs that immediately precede an answered field // Pass 2 label IDs that immediately precede an answered field
var visibleLabelIds = Set<String>() var visibleLabelIds = Set<String>()
var lbuf: [String] = [] var lbuf: [String] = []
for f in schema { for f in sortedSchema {
let ftype = f["type"] as? String ?? "" let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
if ftype == "label" { if ftype == "label" {
@@ -705,7 +714,7 @@ struct ReadOnlyGridFormView: View {
// Pass 3 section IDs that precede at least one answered field // Pass 3 section IDs that precede at least one answered field
var visibleSectionIds = Set<String>() var visibleSectionIds = Set<String>()
var pendingSecId: String? = nil var pendingSecId: String? = nil
for f in schema { for f in sortedSchema {
let ftype = f["type"] as? String ?? "" let ftype = f["type"] as? String ?? ""
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? "" let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
if ftype == "section" { 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 rowGroups: [Int: [[String: Any]]] = [:]
var rowOrder: [Int] = [] var rowOrder: [Int] = []
for f in schema { for f in sortedSchema {
let row = f["row"] as? Int ?? 1 let row = f["row"] as? Int ?? 1
if rowGroups[row] == nil { rowOrder.append(row); rowGroups[row] = [] } if rowGroups[row] == nil { rowOrder.append(row); rowGroups[row] = [] }
rowGroups[row]!.append(f) 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] = [] var result: [RowGroup] = []
for origRow in rowOrder.sorted() { for origRow in rowOrder {
guard let group = rowGroups[origRow] else { continue } guard let group = rowGroups[origRow] else { continue }
var visibleInRow: [[String: Any]] = [] var visibleInRow: [[String: Any]] = []
for f in group { for f in group {
@@ -918,6 +929,7 @@ struct ReadOnlyCellView: View {
struct PhotoThumbnailView: View { struct PhotoThumbnailView: View {
let value: String let value: String
@Environment(\.mediaURLByPath) private var mediaURLByPath
@State private var showLightbox = false @State private var showLightbox = false
var body: some View { var body: some View {
@@ -944,7 +956,7 @@ struct PhotoThumbnailView: View {
.font(.system(size: 11)).foregroundStyle(.secondary) .font(.system(size: 11)).foregroundStyle(.secondary)
} }
} else if value.hasPrefix("uploads/") { } else if value.hasPrefix("uploads/") {
let url = URL(string: "\(ServerConfig.current)/static/\(value)") let url = ServerConfig.mediaURL(absolute: mediaURLByPath[value], path: value)
thumbnailButton { thumbnailButton {
AsyncImage(url: url) { phase in AsyncImage(url: url) { phase in
switch phase { 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 }
}
}