Compare commits
10
Commits
93ee02ea18
...
2a0b264a5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0b264a5c | ||
|
|
6fbd3d1437 | ||
|
|
20f9a99646 | ||
|
|
c05f0029fb | ||
|
|
e6de1f01b6 | ||
|
|
fc571b3faa | ||
|
|
a1afea095d | ||
|
|
32caf735e8 | ||
|
|
47ff9f3aa5 | ||
|
|
072ea13b53 |
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
+348
@@ -0,0 +1,348 @@
|
|||||||
|
// Views/Dashboard/MyInspectionsView.swift
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
import MessageUI
|
||||||
|
|
||||||
|
// MARK: - My Inspections
|
||||||
|
|
||||||
|
struct MyInspectionsView: View {
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
||||||
|
sort: \LocalInspection.lastModifiedAt,
|
||||||
|
order: .reverse
|
||||||
|
) private var inspections: [LocalInspection]
|
||||||
|
|
||||||
|
/// Scheduled assignments (phase36) — rendered as the top section and used
|
||||||
|
/// for the empty-state decision. Sorted by due date (ISO strings sort
|
||||||
|
/// chronologically).
|
||||||
|
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||||
|
private var scheduledAll: [LocalScheduledInspection]
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
@State private var showNewInspection = false
|
||||||
|
@State private var scheduledStartTarget: LocalScheduledInspection?
|
||||||
|
|
||||||
|
// Deletion confirmation state
|
||||||
|
@State private var pendingDelete: LocalInspection?
|
||||||
|
@State private var showDeleteAlert = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if inspections.isEmpty && scheduledAll.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Inspections",
|
||||||
|
systemImage: "checklist",
|
||||||
|
description: Text("Tap + to start a new inspection.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
// Scheduled assignments (phase36) — self-hides when empty.
|
||||||
|
if !scheduledAll.isEmpty {
|
||||||
|
Section("Scheduled") {
|
||||||
|
ForEach(scheduledAll) { s in
|
||||||
|
Button { scheduledStartTarget = s } label: {
|
||||||
|
ScheduledRow(schedule: s)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inspections.isEmpty {
|
||||||
|
Section("In Progress") {
|
||||||
|
ForEach(inspections) { inspection in
|
||||||
|
NavigationLink(value: inspection) {
|
||||||
|
InspectionRowView(inspection: inspection, context: context)
|
||||||
|
}
|
||||||
|
// Only drafts may be deleted — submitted/pending-sync inspections are kept
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
if inspection.status == "draft" {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
pendingDelete = inspection
|
||||||
|
showDeleteAlert = true
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cover attached to the stable List, not a Section.
|
||||||
|
.fullScreenCover(item: $scheduledStartTarget) { s in
|
||||||
|
StartInspectionView(
|
||||||
|
preFillTemplateId: s.templateServerId,
|
||||||
|
preFillFacilityId: s.facilityServerId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("My Inspections")
|
||||||
|
// Confirmation before deletion — destructive action cannot be undone
|
||||||
|
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
||||||
|
Button("Delete", role: .destructive) { deleteDraft(inspection) }
|
||||||
|
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||||
|
} message: { inspection in
|
||||||
|
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showNewInspection = true } label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewInspection) {
|
||||||
|
StartInspectionView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func draftName(_ inspection: LocalInspection) -> String {
|
||||||
|
let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(
|
||||||
|
predicate: #Predicate { $0.serverId == templateId }
|
||||||
|
)
|
||||||
|
).first?.name) ?? "this inspection"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteDraft(_ inspection: LocalInspection) {
|
||||||
|
// Delete associated pending photos from disk and SwiftData
|
||||||
|
for photo in inspection.pendingPhotos {
|
||||||
|
try? FileManager.default.removeItem(atPath: photo.localFilePath)
|
||||||
|
context.delete(photo)
|
||||||
|
}
|
||||||
|
// Delete associated local issues
|
||||||
|
for issue in inspection.localIssues {
|
||||||
|
for path in issue.photoLocalPaths {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
context.delete(issue)
|
||||||
|
}
|
||||||
|
context.delete(inspection)
|
||||||
|
try? context.save()
|
||||||
|
pendingDelete = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InspectionRowView: View {
|
||||||
|
let inspection: LocalInspection
|
||||||
|
let context: ModelContext
|
||||||
|
|
||||||
|
private var facilityName: String {
|
||||||
|
let id = inspection.facilityServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Unknown Facility"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var templateName: String {
|
||||||
|
let id = inspection.templateServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Unknown Template"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(templateName).font(.headline)
|
||||||
|
Spacer()
|
||||||
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
||||||
|
}
|
||||||
|
Text(facilityName).font(.callout).foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
if let score = inspection.overallScore {
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f%%", score))
|
||||||
|
.font(.caption).fontWeight(.medium)
|
||||||
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ── Follow-up badge ────────────────────────────────────────────
|
||||||
|
if inspection.followUpRequired {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "exclamationmark.arrow.circlepath")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("Follow-up Required")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(Color.orange.opacity(0.15))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusBadge: View {
|
||||||
|
let status: String
|
||||||
|
let syncStatus: String
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch status {
|
||||||
|
case "draft": return "Draft"
|
||||||
|
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
||||||
|
case "failed": return "Sync Failed"
|
||||||
|
default: return status.capitalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color: Color {
|
||||||
|
switch status {
|
||||||
|
case "draft": return .blue
|
||||||
|
case "completed": return syncStatus == "pending" ? .orange : .green
|
||||||
|
case "failed": return .red
|
||||||
|
default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption2)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(color.opacity(0.15))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Completed Inspection (read-only)
|
||||||
|
|
||||||
|
struct CompletedInspectionView: View {
|
||||||
|
let inspection: LocalInspection
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
@State private var showReInspect = false
|
||||||
|
|
||||||
|
private var templateName: String {
|
||||||
|
let id = inspection.templateServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Inspection"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|
||||||
|
// ── Follow-up required banner ──────────────────────────────
|
||||||
|
if inspection.followUpRequired {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.arrow.circlepath")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.font(.title3)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Follow-up Inspection Required")
|
||||||
|
.font(.callout.bold())
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
if let note = inspection.followUpNote, !note.isEmpty {
|
||||||
|
Text(note)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
showReInspect = true
|
||||||
|
} label: {
|
||||||
|
Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill")
|
||||||
|
.font(.callout.bold())
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.orange)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Is a re-inspection — parent link ───────────────────────
|
||||||
|
if let parentId = inspection.parentServerId {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "arrow.uturn.right.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Re-inspection of inspection #\(parentId)")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let score = inspection.overallScore {
|
||||||
|
HStack {
|
||||||
|
Text("Overall Score").font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "%.1f%%", score))
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let completedAt = inspection.completedAt {
|
||||||
|
HStack {
|
||||||
|
Text("Completed").font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Sync Status").font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
||||||
|
}
|
||||||
|
if let error = inspection.syncErrorMessage {
|
||||||
|
Text("Error: \(error)").font(.caption).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if !inspection.localIssues.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Flagged Issues (\(inspection.localIssues.count))")
|
||||||
|
.font(.headline).padding(.horizontal)
|
||||||
|
ForEach(inspection.localIssues) { issue in
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(issue.severity == "critical" ? Color.red :
|
||||||
|
issue.severity == "high" ? Color.orange :
|
||||||
|
issue.severity == "medium" ? Color.yellow : Color.blue)
|
||||||
|
.frame(width: 8, height: 8).padding(.top, 4)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(issue.severity.capitalized)
|
||||||
|
.font(.caption.bold()).foregroundStyle(.secondary)
|
||||||
|
Text(issue.issueDescription).font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.navigationTitle(templateName)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.sheet(isPresented: $showReInspect) {
|
||||||
|
StartInspectionView(
|
||||||
|
preFillTemplateId: inspection.templateServerId,
|
||||||
|
preFillFacilityId: inspection.facilityServerId,
|
||||||
|
parentServerId: inspection.serverId,
|
||||||
|
parentLocalId: inspection.localId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
// Views/Dashboard/ScheduledInspectionsView.swift
|
||||||
|
// ----------------------------------------------
|
||||||
|
// Displays the inspector's planned/recurring inspection assignments (phase36),
|
||||||
|
// pulled read-only from GET /api/v1/scheduled-inspections by
|
||||||
|
// SyncManager.pullScheduledInspections().
|
||||||
|
//
|
||||||
|
// Two consumers share one ScheduledRow:
|
||||||
|
// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView
|
||||||
|
// • MyInspectionsView renders its own "Scheduled" List section inline,
|
||||||
|
// reusing ScheduledRow, with the start cover attached to the List.
|
||||||
|
// Both self-hide when there are no scheduled inspections and present
|
||||||
|
// StartInspectionView (facility + template preselected) when a row is tapped.
|
||||||
|
// The schedule lifecycle (fulfil / roll-forward) stays server-driven; tapping
|
||||||
|
// "Start" simply seeds the normal new-inspection flow.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
// MARK: - Shared row
|
||||||
|
|
||||||
|
struct ScheduledRow: View {
|
||||||
|
let schedule: LocalScheduledInspection
|
||||||
|
|
||||||
|
private var dueText: String {
|
||||||
|
if let d = schedule.nextDue {
|
||||||
|
return d.formatted(date: .abbreviated, time: .omitted)
|
||||||
|
}
|
||||||
|
return schedule.dueDateString.isEmpty ? "—" : schedule.dueDateString
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: "calendar.badge.clock")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(schedule.isOverdue ? .red : .blue)
|
||||||
|
.padding(.top, 2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(schedule.templateName.isEmpty ? "Inspection" : schedule.templateName)
|
||||||
|
.font(.callout.bold())
|
||||||
|
Text(schedule.facilityName.isEmpty ? "Facility" : schedule.facilityName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if schedule.isOverdue {
|
||||||
|
Text("Overdue")
|
||||||
|
.font(.caption2.bold())
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(Color.red.opacity(0.15))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
Text("Due \(dueText)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(schedule.isOverdue ? .red : .secondary)
|
||||||
|
if !schedule.frequencyLabel.isEmpty {
|
||||||
|
Text("· \(schedule.frequencyLabel)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Label("Start", systemImage: "play.fill")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||||
|
.background(schedule.isOverdue ? Color.red : Color.blue)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dashboard card (VStack)
|
||||||
|
|
||||||
|
struct ScheduledInspectionsCard: View {
|
||||||
|
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||||
|
private var scheduled: [LocalScheduledInspection]
|
||||||
|
|
||||||
|
@State private var startTarget: LocalScheduledInspection? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !scheduled.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("SCHEDULED")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.tracking(1)
|
||||||
|
|
||||||
|
ForEach(scheduled) { s in
|
||||||
|
Button { startTarget = s } label: {
|
||||||
|
ScheduledRow(schedule: s)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cover attached to the stable VStack root (mirrors DraftResumeBanner).
|
||||||
|
.fullScreenCover(item: $startTarget) { s in
|
||||||
|
StartInspectionView(
|
||||||
|
preFillTemplateId: s.templateServerId,
|
||||||
|
preFillFacilityId: s.facilityServerId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,10 @@ class AuthManager: ObservableObject {
|
|||||||
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
|
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
@@ -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 63–64 for the model + cover pitfalls hit while building it).
|
||||||
|
|
||||||
|
### Inspection-start presentation (July 2026)
|
||||||
|
|
||||||
|
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
||||||
|
|
||||||
|
### "Handled By" (issue handler, phase35 → mobile July 2026)
|
||||||
|
|
||||||
|
`IssueDetailView` (`IssuesView.swift`) shows a "Handled By" section: current handler label + detail, and — for admin/director/PM **and the assigned inspector** — an inline editor (segmented internal/facility/vendor + name/contact/notes) that PATCHes via `updateIssueHandler` and mirrors the result onto `LocalIssue`. The inspector-writable path is a deliberate divergence from the web form (web CLAUDE.md rule 78).
|
||||||
|
|
||||||
### 3. Draft management
|
### 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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,6 +55,7 @@ final class LocalTemplate {
|
|||||||
let str = String(data: data, encoding: .utf8) {
|
let str = String(data: data, encoding: .utf8) {
|
||||||
self.formSchemaJSON = str
|
self.formSchemaJSON = str
|
||||||
}
|
}
|
||||||
|
self.schemaFetchedAt = Date()
|
||||||
self.lastSyncedAt = Date()
|
self.lastSyncedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,13 +357,13 @@ 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,
|
||||||
|
let parent = all.first(where: { $0.localId == parentLocalId }) {
|
||||||
parent.followUpRequired = false
|
parent.followUpRequired = false
|
||||||
parent.followUpNote = nil
|
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,11 +596,22 @@ class SyncManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete any disk file not in referencedPaths
|
// ── 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
|
var deletedCount = 0
|
||||||
for fileURL in diskFiles {
|
for fileURL in diskFiles {
|
||||||
let path = fileURL.path
|
if !referencedPaths.contains(fileURL.path) {
|
||||||
if !referencedPaths.contains(path) {
|
|
||||||
try? fm.removeItem(at: fileURL)
|
try? fm.removeItem(at: fileURL)
|
||||||
deletedCount += 1
|
deletedCount += 1
|
||||||
}
|
}
|
||||||
@@ -581,8 +620,13 @@ class SyncManager: ObservableObject {
|
|||||||
print("[JQC] Sync | cleanupOrphanedPhotos | removed \(deletedCount) file(s)")
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,10 +127,35 @@ 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) {
|
||||||
|
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()
|
ConnectivityBadge()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
formValues = inspection.formData.compactMapValues { "\($0)" }
|
formValues = inspection.formData.compactMapValues { "\($0)" }
|
||||||
// Request Location permission (and start acquiring a fix) the moment
|
// Request Location permission (and start acquiring a fix) the moment
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user