Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user