Jul 21 - Update to support photos' timestamp/location

This commit is contained in:
2026-07-21 16:22:13 -04:00
parent 2a0b264a5c
commit 1921f95faf
18 changed files with 261 additions and 5172 deletions
@@ -1,566 +0,0 @@
// 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)
}
}
}
@@ -1,767 +0,0 @@
// 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 }
}
@@ -1,182 +0,0 @@
// 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)
}
@@ -1,228 +0,0 @@
// 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"
}
}
}
@@ -1,47 +0,0 @@
// 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
}
}
@@ -1,903 +0,0 @@
// 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
}
}
}
@@ -1,644 +0,0 @@
// 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
@@ -1,348 +0,0 @@
// 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
)
}
}
}
@@ -1,113 +0,0 @@
// 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
)
}
}
}
}