Compare commits
18
Commits
2a0b264a5c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
308710538b | ||
|
|
ca7c09f982 | ||
|
|
3f30e2c7bd | ||
|
|
e31cd7e1ff | ||
|
|
7cbc514c39 | ||
|
|
607da7ae47 | ||
|
|
436ef5dafb | ||
|
|
0ded0cf11e | ||
|
|
5a01d141f4 | ||
|
|
49fa39fe98 | ||
|
|
d9a6ffcca2 | ||
|
|
dac7e6c597 | ||
|
|
b7990cc9ba | ||
|
|
9926739cb6 | ||
|
|
4132373fee | ||
|
|
ad91a92ff1 | ||
|
|
5999871f77 | ||
|
|
1921f95faf |
@@ -416,11 +416,10 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
||||
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection and when you take a photo, so the time and place are stamped onto the photo as evidence.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
@@ -432,7 +431,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6;
|
||||
MARKETING_VERSION = 1.12;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -460,11 +459,10 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
||||
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take photos to document inspection issues.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection to verify it was completed on-site.";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "JQC records your GPS location when you submit an inspection and when you take a photo, so the time and place are stamped onto the photo as evidence.";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Attach photos from your library to inspection issues.";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
@@ -476,7 +474,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6;
|
||||
MARKETING_VERSION = 1.12;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -38,8 +38,35 @@ private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
|
||||
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.
|
||||
// Envelope header only — `ok` and `error`, never the payload.
|
||||
//
|
||||
// Split from _Envelope so `decode()` can tell "the server reported a failure"
|
||||
// apart from "the server succeeded but we could not read the payload". Those
|
||||
// were indistinguishable while `data` was decoded with `try?`: any schema
|
||||
// mismatch produced data == nil and surfaced as serverError("Unknown server
|
||||
// error"), pointing every investigation at the backend.
|
||||
private struct _EnvelopeMeta: Decodable, Sendable {
|
||||
let ok: Bool
|
||||
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)
|
||||
error = try? c.decode(String.self, forKey: .error)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case ok, error }
|
||||
}
|
||||
|
||||
// Payload only, decoded STRICTLY so the failure reason propagates.
|
||||
private struct _EnvelopePayload<T: Decodable & Sendable>: Decodable, Sendable {
|
||||
let data: T
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
data = try c.decode(T.self, forKey: .data) // deliberately not `try?`
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case data }
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -134,10 +161,27 @@ actor APIClient {
|
||||
|
||||
// ── Photo Upload ──────────────────────────────────────────────────────
|
||||
|
||||
func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String {
|
||||
/// Upload a photo and return its server path.
|
||||
///
|
||||
/// `capturedAt` / `latitude` / `longitude` drive the timestamp + GPS overlay
|
||||
/// the server burns into the image. They are optional on the wire, but this
|
||||
/// app must send them: photos are re-encoded on save (jpegData), which
|
||||
/// strips EXIF, so the server has no other way to learn the true capture
|
||||
/// moment — it would fall back to upload time, which is wrong for anything
|
||||
/// captured offline. See Utils/PhotoCapture.swift.
|
||||
func uploadPhoto(localPath: String,
|
||||
entityType: String,
|
||||
capturedAt: Date? = nil,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil,
|
||||
retrying: Bool = false) async throws -> String {
|
||||
let url = try buildURL("/api/v1/photos/upload")
|
||||
|
||||
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
||||
// Read via PhotoStore, not the raw path. Stored paths embed the app
|
||||
// container UUID, which iOS reassigns on every app update — the file
|
||||
// survives, the path does not, and a raw read then fails forever on a
|
||||
// photo that is sitting right there on disk (rule 91).
|
||||
guard let imageData = PhotoStore.contents(at: localPath) else {
|
||||
throw APIError.networkError("Could not read photo: \(localPath)")
|
||||
}
|
||||
|
||||
@@ -148,6 +192,9 @@ actor APIClient {
|
||||
|
||||
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(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary))
|
||||
body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary))
|
||||
body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary))
|
||||
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)!)
|
||||
@@ -162,16 +209,38 @@ actor APIClient {
|
||||
|
||||
if shouldRefresh(response, retrying: retrying) {
|
||||
let refreshed = await refreshAccessToken()
|
||||
if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType, retrying: true) }
|
||||
if refreshed {
|
||||
return try await uploadPhoto(localPath: localPath, entityType: entityType,
|
||||
capturedAt: capturedAt, latitude: latitude,
|
||||
longitude: longitude, retrying: true)
|
||||
}
|
||||
throw APIError.notAuthenticated
|
||||
}
|
||||
|
||||
struct PhotoResult: Decodable, Sendable { let serverPath: String }
|
||||
// `stamped` / `capturedAt` / `captureSource` are also returned; decoded
|
||||
// as optionals so older servers (which omit them) still parse.
|
||||
struct PhotoResult: Decodable, Sendable {
|
||||
let serverPath: String
|
||||
let stamped: Bool?
|
||||
let captureSource: String?
|
||||
}
|
||||
if let env = try? decoder.decode(_Envelope<PhotoResult>.self, from: data),
|
||||
env.ok, let r = env.data { return r.serverPath }
|
||||
env.ok, let r = env.data {
|
||||
if r.stamped == false {
|
||||
print("[JQC] Photo stored unstamped (source=\(r.captureSource ?? "?")): \(r.serverPath)")
|
||||
}
|
||||
return r.serverPath
|
||||
}
|
||||
throw APIError.serverError("Photo upload failed")
|
||||
}
|
||||
|
||||
/// Build one multipart text field, or empty Data when the value is nil.
|
||||
private static func formField(_ name: String, _ value: String?, _ boundary: String) -> Data {
|
||||
guard let value else { return Data() }
|
||||
return "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n"
|
||||
.data(using: .utf8) ?? Data()
|
||||
}
|
||||
|
||||
// ── Submit Inspection ─────────────────────────────────────────────────
|
||||
|
||||
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
|
||||
@@ -200,6 +269,11 @@ actor APIClient {
|
||||
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 }
|
||||
// Links the submission back to the schedule it was started from so the
|
||||
// server fulfils it (clears the banner) and badges it as "Scheduled".
|
||||
if let schedId = inspection.scheduledInspectionServerId {
|
||||
body["scheduled_inspection_id"] = schedId
|
||||
}
|
||||
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 }
|
||||
@@ -236,20 +310,41 @@ actor APIClient {
|
||||
// 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.
|
||||
// All evidence photos go in this ONE request.
|
||||
//
|
||||
// `photo_path` is the primary; `result_photos` carries the rest and is
|
||||
// stored server-side in `mobile_photo_paths`, so they display under
|
||||
// "Photo Evidence" rather than "Resolution Details"
|
||||
// (app/api/issues.py, create_issue).
|
||||
//
|
||||
// These used to be split: create sent photo_path only, then
|
||||
// processIssueQueue fired a follow-up PATCH for the extras. The extras
|
||||
// were always known before create — processPhotoQueue fully populates
|
||||
// photoServerPaths first — so the second call bought nothing and cost a
|
||||
// window in which the issue was already `synced` while its photos were
|
||||
// not attached. Sending them together makes attachment atomic with
|
||||
// creation, and the endpoint's mobile_local_id idempotency covers a
|
||||
// retry of the whole thing (rule 85).
|
||||
if let first = issue.photoServerPaths.first { body["photo_path"] = first }
|
||||
let extraPhotos = Array(issue.photoServerPaths.dropFirst())
|
||||
if !extraPhotos.isEmpty { body["result_photos"] = extraPhotos }
|
||||
|
||||
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.
|
||||
// ── Attach a LATE evidence photo to an already-created issue ──────────
|
||||
//
|
||||
// NOT part of the normal path: submitIssue() sends every evidence photo in
|
||||
// the create request, and reintroducing a routine post-create call is
|
||||
// exactly what rule 85 forbids. This exists only for recovery — a photo
|
||||
// that exhausted its upload attempts, was submitted without, and later
|
||||
// succeeded via Pending Sync → Retry Failed Items. By then the create
|
||||
// request is long gone and this is the only way across.
|
||||
//
|
||||
// Server-side (app/api/issues.py, update_issue_photos) this merges
|
||||
// idempotently into mobile_photo_paths, so repeating a path is a no-op.
|
||||
func updateIssuePhotos(issueId: Int, resultPhotos: [String]) async throws {
|
||||
struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int }
|
||||
let _: R = try await request(
|
||||
@@ -259,13 +354,46 @@ actor APIClient {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Attach a late photo to an ALREADY-SUBMITTED inspection ────────────
|
||||
// The inspection twin of updateIssuePhotos above, and the piece that was
|
||||
// missing: an inspection photo that exhausted its upload attempts was
|
||||
// submitted with its field blanked, and nothing could ever put it back.
|
||||
// attachServerPath() wrote the recovered path into LOCAL form data only,
|
||||
// so the server copy stayed empty forever even after a successful retry.
|
||||
//
|
||||
// Server-side (app/api/inspections.py, update_inspection) form_data is
|
||||
// merged field-by-field via _merge_form_data: a non-empty incoming value
|
||||
// wins, and existing 'uploads/...' paths are never blanked. Sending only
|
||||
// the recovered fields is therefore safe and idempotent.
|
||||
//
|
||||
// `status` is deliberately NOT sent: including it would re-run the
|
||||
// draft→completed transition server-side, which is what fulfils a linked
|
||||
// schedule. Omitting it leaves status, score and schedule untouched.
|
||||
func updateInspectionFormData(inspectionId: Int,
|
||||
fields: [String: String]) async throws {
|
||||
struct R: Decodable, Sendable { let id: Int }
|
||||
let _: R = try await request(
|
||||
"/api/v1/inspections/\(inspectionId)",
|
||||
method: "PATCH",
|
||||
body: ["form_data": fields]
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
func uploadResultPhoto(localPath: String,
|
||||
capturedAt: Date? = nil,
|
||||
latitude: Double? = nil,
|
||||
longitude: Double? = nil,
|
||||
retrying: Bool = false) async throws -> String {
|
||||
let url = try buildURL("/api/v1/photos/upload")
|
||||
|
||||
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
||||
// Read via PhotoStore, not the raw path. Stored paths embed the app
|
||||
// container UUID, which iOS reassigns on every app update — the file
|
||||
// survives, the path does not, and a raw read then fails forever on a
|
||||
// photo that is sitting right there on disk (rule 91).
|
||||
guard let imageData = PhotoStore.contents(at: localPath) else {
|
||||
throw APIError.networkError("Could not read photo: \(localPath)")
|
||||
}
|
||||
|
||||
@@ -276,6 +404,9 @@ actor APIClient {
|
||||
|
||||
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(Self.formField("captured_at", capturedAt.map { PhotoCaptureFormat.iso8601.string(from: $0) }, boundary))
|
||||
body.append(Self.formField("latitude", latitude.map { String(format: "%.6f", $0) }, boundary))
|
||||
body.append(Self.formField("longitude", longitude.map { String(format: "%.6f", $0) }, boundary))
|
||||
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)!)
|
||||
@@ -290,7 +421,11 @@ actor APIClient {
|
||||
|
||||
if shouldRefresh(response, retrying: retrying) {
|
||||
let refreshed = await refreshAccessToken()
|
||||
if refreshed { return try await uploadResultPhoto(localPath: localPath, retrying: true) }
|
||||
if refreshed {
|
||||
return try await uploadResultPhoto(localPath: localPath, capturedAt: capturedAt,
|
||||
latitude: latitude, longitude: longitude,
|
||||
retrying: true)
|
||||
}
|
||||
throw APIError.notAuthenticated
|
||||
}
|
||||
|
||||
@@ -375,6 +510,56 @@ actor APIClient {
|
||||
return result.scheduled
|
||||
}
|
||||
|
||||
/// Plan a follow-up re-inspection of `parentInspectionId` for `dueDate`
|
||||
/// (phase45) — the deferred twin of "Re-inspect Now" in history detail.
|
||||
///
|
||||
/// Only the parent and the date are sent: the server derives facility,
|
||||
/// template and assignee from the parent inspection, so a follow-up can
|
||||
/// only ever target the thing it is a follow-up of. The schedule it creates
|
||||
/// carries `parent_inspection_id`, which the inspection started from it
|
||||
/// inherits — that is what makes the eventual run a linked re-inspection.
|
||||
///
|
||||
/// Idempotent server-side: retrying re-dates the existing active follow-up
|
||||
/// for this parent instead of creating a second one.
|
||||
///
|
||||
/// `dueDate` must be formatted `yyyy-MM-dd`; the server rejects a past date.
|
||||
func createScheduledFollowUp(
|
||||
parentInspectionId: Int,
|
||||
dueDate: String,
|
||||
notes: String?
|
||||
) async throws -> APIScheduledInspection {
|
||||
var body: [String: Any] = [
|
||||
"parent_inspection_id": parentInspectionId,
|
||||
"due_date": dueDate,
|
||||
]
|
||||
// Raw snake_case body keys — JSONSerialization applies no key strategy
|
||||
// (rule 65).
|
||||
if let n = notes?.trimmingCharacters(in: .whitespacesAndNewlines), !n.isEmpty {
|
||||
body["notes"] = n
|
||||
}
|
||||
let result: APIScheduledFollowUpResponseData = try await post(
|
||||
"/api/v1/scheduled-inspections/follow-up", body: body
|
||||
)
|
||||
return result.scheduled
|
||||
}
|
||||
|
||||
// ── Follow-up Requests ────────────────────────────────────────────────
|
||||
|
||||
/// Inspections a director/admin has flagged as needing a follow-up.
|
||||
///
|
||||
/// Same endpoint and response shape as `fetchInspectionHistory`, but with
|
||||
/// the `follow_up_required` filter so the server returns the complete
|
||||
/// outstanding set rather than the recent page history shows. The limit is
|
||||
/// the endpoint's maximum for the same reason — this list drives actionable
|
||||
/// work, and a follow-up raised on a months-old inspection must still
|
||||
/// appear. Inspector-scoped server-side.
|
||||
func fetchFollowUpRequests() async throws -> [APIInspectionSummary] {
|
||||
let result: InspectionHistoryResponseData = try await request(
|
||||
"/api/v1/inspections?follow_up_required=true&limit=200"
|
||||
)
|
||||
return result.inspections
|
||||
}
|
||||
|
||||
// ── Issue Handler ("Handled By") ──────────────────────────────────────
|
||||
|
||||
/// Set who handles an issue. `details` carries any of the optional
|
||||
@@ -488,7 +673,32 @@ actor APIClient {
|
||||
|
||||
// ── Token Refresh ─────────────────────────────────────────────────────
|
||||
|
||||
/// The refresh currently in flight, if any.
|
||||
///
|
||||
/// `APIClient` being an actor is NOT enough on its own: `refreshAccessToken`
|
||||
/// suspends at `await`, which releases the actor and lets a second caller
|
||||
/// enter. Two requests 401-ing at once would then each POST /auth/refresh
|
||||
/// with the SAME refresh token — the server rotates it on the first, so the
|
||||
/// second presents an already-spent token, fails, and the user is signed
|
||||
/// out mid-sync. Easy to hit: pollNotifications and registerDevice both run
|
||||
/// alongside triggerSync.
|
||||
///
|
||||
/// Coalescing here means concurrent callers await one shared result. The
|
||||
/// check-and-store below spans no `await`, so it is atomic within the actor.
|
||||
private var refreshTask: Task<Bool, Never>?
|
||||
|
||||
private func refreshAccessToken() async -> Bool {
|
||||
if let inFlight = refreshTask {
|
||||
return await inFlight.value
|
||||
}
|
||||
let task = Task { await self.performTokenRefresh() }
|
||||
refreshTask = task
|
||||
let result = await task.value
|
||||
refreshTask = nil
|
||||
return result
|
||||
}
|
||||
|
||||
private func performTokenRefresh() async -> Bool {
|
||||
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
|
||||
let url = URL(string: ServerConfig.current + "/api/v1/auth/refresh")
|
||||
else { return false }
|
||||
@@ -553,14 +763,50 @@ actor APIClient {
|
||||
}
|
||||
|
||||
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")
|
||||
// Read the envelope header first, so a server-reported failure and an
|
||||
// unreadable payload cannot be confused for one another.
|
||||
if let meta = try? decoder.decode(_EnvelopeMeta.self, from: data) {
|
||||
guard meta.ok else {
|
||||
throw APIError.serverError(meta.error ?? "Unknown server error")
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(_EnvelopePayload<T>.self, from: data).data
|
||||
} catch {
|
||||
// ok == true, so this is OUR problem, not the server's — a
|
||||
// contract drift between this build and the deployment.
|
||||
throw APIError.decodingError(Self.describe(error, as: T.self))
|
||||
}
|
||||
}
|
||||
// Not an envelope — a few endpoints return the object bare.
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error.localizedDescription)
|
||||
throw APIError.decodingError(Self.describe(error, as: T.self))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a `DecodingError` into something that names the offending field.
|
||||
///
|
||||
/// `error.localizedDescription` on a DecodingError is always the useless
|
||||
/// "The data couldn't be read because it isn't in the correct format",
|
||||
/// which is what the old path surfaced — so a renamed or retyped API field
|
||||
/// gave no clue which one it was.
|
||||
private static func describe<T>(_ error: Error, as type: T.Type) -> String {
|
||||
func path(_ context: DecodingError.Context) -> String {
|
||||
let keys = context.codingPath.map(\.stringValue).filter { !$0.isEmpty }
|
||||
return keys.isEmpty ? "\(type)" : "\(type).\(keys.joined(separator: "."))"
|
||||
}
|
||||
switch error as? DecodingError {
|
||||
case .keyNotFound(let key, let ctx):
|
||||
return "missing field '\(key.stringValue)' in \(path(ctx))"
|
||||
case .typeMismatch(let expected, let ctx):
|
||||
return "\(path(ctx)) has the wrong type (expected \(expected))"
|
||||
case .valueNotFound(let expected, let ctx):
|
||||
return "\(path(ctx)) was null (expected \(expected))"
|
||||
case .dataCorrupted(let ctx):
|
||||
return "\(path(ctx)) is malformed: \(ctx.debugDescription)"
|
||||
default:
|
||||
return "could not read \(type): \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,12 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
let followUpRequired: Bool
|
||||
let followUpNote: String?
|
||||
let parentInspectionId: Int?
|
||||
/// phase53 — who was asked to perform the follow-up. nil means it belongs
|
||||
/// to the inspection's own inspector, which is what it always meant.
|
||||
/// The list endpoint already returns only follow-ups this user OWNS, so
|
||||
/// these are for display, not filtering.
|
||||
let followUpAssignedTo: Int?
|
||||
let followUpAssignedToName: String?
|
||||
|
||||
/// Form field values as [fieldId: stringValue] for the grid renderer.
|
||||
var formValues: [String: String] {
|
||||
@@ -368,6 +374,8 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
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)
|
||||
followUpAssignedTo = try? c.decode(Int.self, forKey: .followUpAssignedTo)
|
||||
followUpAssignedToName = try? c.decode(String.self, forKey: .followUpAssignedToName)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, templateId, templateName, facilityId, facilityName
|
||||
@@ -376,6 +384,7 @@ struct APIInspectionSummary: Decodable, Identifiable, Hashable, Sendable {
|
||||
case formData, formSchema
|
||||
case formMedia
|
||||
case followUpRequired, followUpNote, parentInspectionId
|
||||
case followUpAssignedTo, followUpAssignedToName
|
||||
}
|
||||
|
||||
// Explicit Hashable — formDataRaw/formSchemaRaw contain JSONValue which
|
||||
@@ -658,6 +667,11 @@ struct APIScheduledInspection: Decodable, Identifiable, Sendable {
|
||||
let nextDueDate: String? // ISO date "YYYY-MM-DD"
|
||||
let isOverdue: Bool
|
||||
let notes: String?
|
||||
/// Set when this schedule is a planned follow-up of a specific inspection
|
||||
/// (phase45, "Schedule Follow-up"). Carried onto the inspection started
|
||||
/// from it so the run lands as a linked re-inspection. Nil for an ordinary
|
||||
/// schedule, and on servers older than phase45.
|
||||
let parentInspectionId: Int?
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
@@ -672,10 +686,12 @@ struct APIScheduledInspection: Decodable, Identifiable, Sendable {
|
||||
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)
|
||||
parentInspectionId = try? c.decode(Int.self, forKey: .parentInspectionId)
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, facilityId, facilityName, templateId, templateName
|
||||
case inspectorId, frequency, frequencyLabel, nextDueDate, isOverdue, notes
|
||||
case parentInspectionId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,6 +711,21 @@ struct APIScheduledInspectionsResponseData: Decodable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey { case scheduled, total, limit, offset }
|
||||
}
|
||||
|
||||
/// Response of `POST /api/v1/scheduled-inspections/follow-up` (phase45).
|
||||
/// `created` is false when the server re-dated an existing follow-up for the
|
||||
/// same parent instead of adding a second one.
|
||||
struct APIScheduledFollowUpResponseData: Decodable, Sendable {
|
||||
let scheduled: APIScheduledInspection
|
||||
let created: Bool
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scheduled = try c.decode(APIScheduledInspection.self, forKey: .scheduled)
|
||||
created = (try? c.decode(Bool.self, forKey: .created)) ?? true
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case scheduled, created }
|
||||
}
|
||||
|
||||
// ── Dashboard Stats (Phase B) ─────────────────────────────────────────────────
|
||||
|
||||
struct APIDashboardStats: Decodable, Sendable {
|
||||
|
||||
@@ -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
-348
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-113
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ class AuthManager: ObservableObject {
|
||||
do {
|
||||
let response: MeResponseData = try await APIClient.shared.request("/api/v1/auth/me")
|
||||
applyUser(response.user)
|
||||
reconcileSessionScope(userId: response.user.id)
|
||||
isAuthenticated = true
|
||||
} catch APIError.notAuthenticated {
|
||||
KeychainHelper.clearAll()
|
||||
@@ -57,6 +58,9 @@ class AuthManager: ObservableObject {
|
||||
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
|
||||
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
|
||||
applyUser(response.user)
|
||||
// BEFORE isAuthenticated flips, so DashboardView is never rendered
|
||||
// holding the previous inspector's data.
|
||||
reconcileSessionScope(userId: response.user.id)
|
||||
isAuthenticated = true
|
||||
// Register device immediately after login — the .task {} and
|
||||
// .onChange(scenePhase) paths both miss this case because they run
|
||||
@@ -97,6 +101,39 @@ class AuthManager: ObservableObject {
|
||||
KeychainHelper.set(user.displayName, forKey: Constants.Keychain.displayName)
|
||||
}
|
||||
|
||||
/// Purge local data when this session is scoped to a different
|
||||
/// `(server, user)` pair than the database currently holds.
|
||||
///
|
||||
/// This is the check that was missing: logout deliberately keeps the cache
|
||||
/// so the same inspector can work offline after signing back in, but
|
||||
/// nothing verified that the next sign-in *was* the same inspector. A
|
||||
/// different one inherited their issues; see `SessionScope` and rule 88.
|
||||
///
|
||||
/// Runs on both `login()` and `restoreSession()` — a session can also be
|
||||
/// restored into a changed scope after a server switch.
|
||||
private func reconcileSessionScope(userId: Int) {
|
||||
let server = ServerConfig.current
|
||||
let stored = SessionScope.stored
|
||||
|
||||
// No marker: either a genuinely fresh install, or an upgrade from a
|
||||
// build that predates this. Those are indistinguishable, and purging on
|
||||
// the guess would delete an in-progress draft belonging to the user
|
||||
// signing in right now — so adopt the existing data and start tracking
|
||||
// from here. Every subsequent identity change is then covered.
|
||||
guard let stored else {
|
||||
SessionScope.record(userId: userId)
|
||||
return
|
||||
}
|
||||
|
||||
guard stored.server != server || stored.userId != userId else { return }
|
||||
|
||||
SyncManager.shared.purgeSessionScopedData(
|
||||
keepingUserId: userId,
|
||||
sameServer: stored.server == server
|
||||
)
|
||||
SessionScope.record(userId: userId)
|
||||
}
|
||||
|
||||
private func restoreUserFromKeychain() {
|
||||
currentUserId = Int(KeychainHelper.get(Constants.Keychain.userId) ?? "0") ?? 0
|
||||
currentUserRole = KeychainHelper.get(Constants.Keychain.userRole) ?? ""
|
||||
|
||||
+236
-31
@@ -62,7 +62,7 @@ Core capabilities:
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Language | Swift 5.10+ |
|
||||
| UI | SwiftUI (iPad-only, all four orientations) |
|
||||
| UI | SwiftUI. iPad is the primary target; iPhone (compact width) is supported — see rules 76–77. All four orientations. `TARGETED_DEVICE_FAMILY = "1,2"`, so the app installs on iPhone and must stay usable there |
|
||||
| Local storage | SwiftData (iOS 17+ required) |
|
||||
| Networking | URLSession async/await |
|
||||
| Connectivity detection | NWPathMonitor (Network.framework) |
|
||||
@@ -89,7 +89,7 @@ JanitorialQC/
|
||||
│
|
||||
├── API/
|
||||
│ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload
|
||||
│ │ # updateIssuePhotos() — PATCH /issues/<id>/photos
|
||||
│ │ # submitIssue() sends photo_path + result_photos (rule 85)
|
||||
│ └── APIModels.swift # All Codable/Sendable response DTOs
|
||||
│ # APIAssignedIssue has photoPath + mobilePhotoPaths + resultPhotos
|
||||
│
|
||||
@@ -181,6 +181,7 @@ Stored keys (all prefixed `com.jqc.`): `accessToken`, `refreshToken`, `userId`,
|
||||
```swift
|
||||
LocalFacility.self, LocalArea.self, LocalTemplate.self,
|
||||
LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self,
|
||||
LocalFollowUpRequest.self, LocalNotification.self,
|
||||
PendingPhoto.self, SyncQueueEntry.self
|
||||
```
|
||||
|
||||
@@ -193,10 +194,12 @@ PendingPhoto.self, SyncQueueEntry.self
|
||||
| `LocalFacility` | Read-only cached facility reference | `serverId` (`@Attribute(.unique)`), `name`, `projectId`, `projectName`, `areas` (cascade) |
|
||||
| `LocalArea` | Read-only cached area reference | `serverId`, `facilityServerId`, `name`, `areaType` |
|
||||
| `LocalTemplate` | Cached template + raw JSON schema | `serverId`, `formSchemaJSON`, `formSchema` (computed) |
|
||||
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `submitLatitude` (Double?), `submitLongitude` (Double?) |
|
||||
| `LocalInspection` | Inspector-authored inspection record | `localId` (UUID, unique), `serverId`, `status`, `syncStatus`, `formDataJSON`, `followUpRequired`, `parentLocalId`, `parentServerId`, `scheduledInspectionServerId` (Int?, inline default — links the submission to the schedule it fulfils), `submitLatitude` (Double?), `submitLongitude` (Double?) |
|
||||
| `LocalIssue` | Issue record | `localId` (UUID, unique), `serverId`, `inspectionLocalId` (`""` for standalone/server-pulled), `facilityServerId`, `severity`, `syncStatus`, `photoLocalPathsJSON`, `photoServerPathsJSON`, **handler fields** (`handlerType`, `handlerLabel`, `facilityHandler*`, `vendor*` — all optional, synced from server, inspector-editable) |
|
||||
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
|
||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||
| `LocalScheduledInspection` | Read-only cached scheduled/recurring assignment (phase36) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `inspectorId`, `frequency`, `frequencyLabel`, `dueDateString` (sort key), `isOverdue`, `nextDue` (computed), `parentInspectionServerId` (`Int?`, phase45 — set when the schedule is a planned follow-up; becomes the run's `parentServerId`). Pulled by `pullScheduledInspections()`; `init(from:)`/`update(from:)` like `LocalFacility` |
|
||||
| `LocalFollowUpRequest` | Read-only cached follow-up request raised on the web (July 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63; this is the *flagged parent* inspection's id and the `parentServerId` the re-inspection links to), `facilityServerId`, `facilityName`, `templateServerId`, `templateName`, `overallScore`, `inspectionDateString` (sort key), `followUpNote`, `note` (computed, trimmed/nil-ed), `inspectedOn` (computed, parses the `yyyy-MM-dd` prefix only — see the file comment), `fulfilledLocally` (`= false`, rule 71), `parentFormDataJSON` (`= "{}"`, the parent's answers cached for re-inspection prefill — rule 79), `parentFormData` (computed). Pulled by `pullFollowUpRequests()` |
|
||||
| `LocalNotification` | In-app notification inbox (Aug 2026) | `serverId` (`@Attribute(.unique)`, **no default** — rule 63), `title`, `body`, `eventType` (nil pre-phase17), `issueId`, `createdAt`, `receivedAt`, `isRead` (`= false`), `readAt`, `readSyncPending` (`= false`). Upserted by `pollNotifications()`; the local store IS the inbox, because the API only returns unread — rule 92 |
|
||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `uploadRetryCount` (`Int = 0`, rule 83 — the row stays `"pending"` until it hits 5), `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
||||
|
||||
### LocalInspection Status Flow
|
||||
@@ -246,25 +249,125 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e
|
||||
|
||||
2. **`processInspectionQueue`** — submits completed inspections when all `pendingPhotos` are settled.
|
||||
|
||||
3. **`processIssueQueue`** — guards against submitting when parent inspection `syncStatus == "failed"`. After successful submit: sets `syncStatus = "synced"`, **clears `photoLocalPaths = []`** (prevents duplicate photo sections in `IssueDetailView`), then calls `updateIssuePhotos(issueId:resultPhotos:)` for any extra photos beyond the first (`Array(photoServerPaths.dropFirst())`).
|
||||
3. **`processIssueQueue`** — guards against submitting when parent inspection `syncStatus == "failed"`, **and waits for the issue's own photos to settle** (same rule as inspections — see rule 83). `submitIssue()` sends every evidence photo in the one create request (rule 85). After successful submit: sets `syncStatus = "synced"` and clears `photoLocalPaths = []` **only when every photo uploaded** (rule 84).
|
||||
|
||||
4. **`pullReferenceData`** — fetches facilities, areas, templates. **Deduplicates facility response by `id` using `seenFacilityIds = Set<Int>()`** before upserting — prevents duplicate buildings in pickers when server returns same facility ID multiple times.
|
||||
|
||||
**Prunes facilities the server no longer returns (Aug 2026).** `/api/v1/facilities` is already scoped server-side, but cached rows were never removed, so a facility survived locally after the inspector's contract was unassigned, after it was deactivated, or after a **different user signed in on the same iPad**. Every picker derives its **contract** list from these rows (`StartInspectionView.contracts`, `IssuesView.contracts` both map over `LocalFacility`), so one stale facility kept a whole contract in the Start Inspection picker forever — which is how this surfaced. Templates already had this prune; facilities were the gap.
|
||||
|
||||
Two rules in the prune:
|
||||
- Deletion only runs after **both** requests succeeded, so a failed sync can never empty the cache (it throws first).
|
||||
- A facility still referenced by **unsynced** local work (a `LocalInspection` or `LocalIssue` with `syncStatus != "synced"`) is **kept but marked `isActive = false`** instead of deleted. `ExecuteInspectionView`/`MyInspectionsView` resolve the facility name by `serverId` and the issue *detail* view has no `facilityNameCache` fallback, so deleting it would turn an in-progress draft into "Unknown Facility". The row is pruned on a later sync once that work has been submitted, and `update(from:)` flips `isActive` back to true if the facility returns to scope.
|
||||
|
||||
**Every picker must therefore filter on `isActive`** — both views expose `availableFacilities` for this and derive `contracts` / `filteredFacilities` from it, never from the raw `@Query`. A retained out-of-scope row is for *display only*; offering it would let an inspector start work the server then rejects.
|
||||
|
||||
5. **`pullAssignedIssues`** — fetches `GET /api/v1/issues`. Merges `api.photoPath` + `api.mobilePhotoPaths` into `photoServerPaths`. **Does NOT include `api.resultPhotos`** — resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response).
|
||||
|
||||
6. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor.
|
||||
6. **`pullFollowUpRequests`** — fetches `GET /api/v1/inspections?follow_up_required=true`. Upserts `LocalFollowUpRequest` by `serverId`, deletes rows the server no longer returns, and mirrors `followUpRequired`/`followUpNote` onto the matching `LocalInspection` so the history badge agrees with the card. Best-effort — never blocks the pipeline. Runs after `processInspectionQueue`, so the section clears on the same sync that submits the re-inspection.
|
||||
|
||||
7. **`pollNotifications`** — fetches new notifications since `lastNotificationFetch` cursor.
|
||||
|
||||
### Photo loss on inspections — the recovery loop (Aug 2026)
|
||||
|
||||
An inspection can reach the server with its photo fields BLANK while the files
|
||||
sit safely on the device. The chain:
|
||||
|
||||
1. `processPhotoQueue` upload fails → `uploadRetryCount++`; after
|
||||
`maxPhotoUploadAttempts` (5) the row goes `uploadStatus = "failed"`, which is
|
||||
**terminal**.
|
||||
2. `processInspectionQueue` treats `"failed"` as ready — deliberate, so a dead
|
||||
photo cannot block a submission forever — and submits.
|
||||
3. `APIClient.submitInspection` rewrites any surviving `local://` value to `""`,
|
||||
so the field lands **blank on the server**.
|
||||
4. The inspection is marked `synced`. Nothing revisits it.
|
||||
|
||||
**Step 4 was a dead end until this fix.** "Retry Failed Items" resets the photo
|
||||
to `pending` and the re-upload can succeed, but `attachServerPath()` writes the
|
||||
recovered path into **local** form data only — and the inspection is already
|
||||
synced, so nothing carried it across. The photo was recoverable in principle and
|
||||
unreachable in practice, which is what the Photo Diagnostic screen reports as
|
||||
*"LOST ON SERVER … File present at stored path — recoverable"*.
|
||||
|
||||
`pushLateInspectionPhotoIfNeeded()` closes it, mirroring
|
||||
`pushLateIssuePhotoIfNeeded()`:
|
||||
|
||||
| | Issue | Inspection |
|
||||
|---|---|---|
|
||||
| late-attach call | `PATCH /api/v1/issues/<id>/photos` | `PATCH /api/v1/inspections/<id>` with `form_data` |
|
||||
| helper | `pushLateIssuePhotoIfNeeded` | `pushLateInspectionPhotoIfNeeded` |
|
||||
|
||||
Server-side `_merge_form_data` makes this safe: a non-empty incoming value wins,
|
||||
and an existing `uploads/...` path is never blanked by an empty one — so the
|
||||
PATCH is idempotent and cannot erase a good path. **`status` is deliberately not
|
||||
sent**: including it would re-run the draft→completed transition, which is what
|
||||
fulfils a linked schedule.
|
||||
|
||||
Normal path is unaffected — `processPhotoQueue` runs before
|
||||
`processInspectionQueue`, so a first-time inspection has no `serverId` yet and
|
||||
the helper no-ops; only a recovery reaches it.
|
||||
|
||||
`PendingPhoto.lastUploadError` records **why** the last attempt failed. Nothing
|
||||
recorded it before: a photo could burn all five attempts with the reason visible
|
||||
nowhere — the device showed only "failed", and the server logged only
|
||||
*successful* uploads (now fixed: `app/api/photos.py` logs every rejection and any
|
||||
storage-write failure at WARNING/ERROR with the username).
|
||||
|
||||
### Server-pulled issue identification
|
||||
|
||||
Records inserted by `pullAssignedIssues` are identified by: `syncStatus == "synced"` AND `inspectionLocalId == ""`. These are the only records safe to delete during reconciliation.
|
||||
|
||||
### clearServerPulledData() — on logout / server switch
|
||||
### purgeSessionScopedData() — on identity change
|
||||
|
||||
Deletes every `LocalIssue` where `serverId != nil`. This covers:
|
||||
- Server-pulled assigned issues (`inspectionLocalId == ""`, `syncStatus == "synced"`)
|
||||
- Inspector-created issues that already synced (`inspectionLocalId != ""`, `serverId != nil`)
|
||||
Replaces `clearServerPulledData()`, which deleted `LocalIssue` only and was called
|
||||
from the wrong place. See rule 88.
|
||||
|
||||
Preserves only truly pending device-created issues (`serverId == nil`, `syncStatus == "pending"`).
|
||||
**Trigger is a change of `SessionScope` — the `(server, userId)` pair the database is
|
||||
scoped to — not logout.** `AuthManager.reconcileSessionScope()` compares the incoming
|
||||
session against the recorded scope on every `login()` and `restoreSession()`, and purges
|
||||
only when they differ.
|
||||
|
||||
| Model | Kept |
|
||||
|---|---|
|
||||
| `LocalFacility` / `LocalArea` / `LocalTemplate` / `LocalScheduledInspection` / `LocalFollowUpRequest` | Nothing — pure caches, re-pulled on the next sync |
|
||||
| `LocalIssue` | Nothing — the model has no author field, so an unsent issue cannot be attributed and must not be submitted under a different inspector's name |
|
||||
| `LocalInspection` | Only rows whose `inspectorUserId` matches the incoming user, **and** only when the server is unchanged |
|
||||
| `PendingPhoto` | Only rows belonging to a kept inspection; the JPEGs of the rest are deleted from disk too |
|
||||
|
||||
A plain logout still purges nothing: the same inspector signing back into the same server
|
||||
keeps their cache and stays usable offline. That was always the right call — the defect was
|
||||
that nothing checked whether the next sign-in was the same person.
|
||||
|
||||
**`SessionScope.stored == nil` adopts the existing data rather than purging.** A fresh
|
||||
install and an upgrade from a build without the marker are indistinguishable, and guessing
|
||||
"purge" would delete an in-progress draft belonging to the person signing in right then.
|
||||
Every identity change after that first login is covered.
|
||||
|
||||
### Notification inbox (Aug 2026)
|
||||
|
||||
Notifications are persisted as `LocalNotification` and the inbox reads that store,
|
||||
not the poll response. **Why:** `GET /api/v1/notifications` is a poller, not an inbox —
|
||||
it filters to `is_read = False` and never sends the flag, so the list was all-unread by
|
||||
construction (every row looked identical, which is the defect this fixed) and a
|
||||
notification became invisible the moment it was read.
|
||||
|
||||
- `pollNotifications()` **upserts** by `serverId` and never deletes. A row missing from a
|
||||
response means nothing: it may have been read on the web, or just predate the cursor.
|
||||
- A local banner fires **only for newly-inserted rows**. Previously every polled item was
|
||||
delivered, so a cold launch (cursor nil → server returns the whole unread backlog)
|
||||
re-banner'd all of it on every app start.
|
||||
- `unreadNotificationCount` is **derived** by `refreshUnreadNotificationCount()`, not
|
||||
tallied as items arrive — read state changes from both ends now.
|
||||
- `markNotificationsViewed()` is **gone**. It zeroed the badge because the screen had been
|
||||
opened, which cannot coexist with real read state (badge 0, every row still unread).
|
||||
- Reading is explicit: tap a row, swipe, or **Mark All Read**. Local first
|
||||
(`LocalNotification.markRead()` sets `readSyncPending`), then pushed by
|
||||
`pushNotificationReadState()` — so it works offline and drains on reconnect.
|
||||
- `pruneReadNotifications()` drops **read** rows older than 30 days. Unread rows are never
|
||||
pruned at any age; nothing else deletes a row, so without this the store grows forever.
|
||||
|
||||
`NotificationDetailView` shows the full text and links to the referenced issue when that
|
||||
issue is cached locally, and says so plainly when it is not — the issue may belong to
|
||||
another inspector or simply not be pulled yet.
|
||||
|
||||
### Notification polling
|
||||
|
||||
@@ -284,6 +387,25 @@ let x = all.filter { ... }
|
||||
|
||||
---
|
||||
|
||||
### Role gates — `Constants.Roles` (Aug 2026)
|
||||
|
||||
**Never write `role == "inspector"` in a view.** `external_inspector` ("Customer Inspector" — an inspector employed by the customer) has the same powers as our own `inspector` and the API scopes it identically, so a literal equality check locks that account out of actions the server would happily accept. It fails **silently**: no error, no 403 to debug — the control simply is not drawn.
|
||||
|
||||
That is exactly what happened to Update Status, Handled By and Start Follow-up, which were three separate hand-written lists in two files:
|
||||
|
||||
| Site | Was | Now |
|
||||
|---|---|---|
|
||||
| `IssuesView.canUpdateStatus` | `admin \| director \| inspector` | `Constants.Roles.issueActors` |
|
||||
| `IssuesView.canEditHandler` | `admin \| director \| inspector \| project_manager` | same |
|
||||
| `InspectionHistoryView.canStartFollowUp` | `admin \| director \| inspector \| project_manager` | same |
|
||||
|
||||
`Constants.Roles` in `Utils/Constants.swift` is the single definition, mirroring `User.INSPECTOR_ROLES` / `User.is_inspector` on the server (server rule 87):
|
||||
|
||||
- `inspectorRoles` = `{inspector, external_inspector}` — test membership, never `==`.
|
||||
- `issueActors` = `{admin, director, project_manager} ∪ inspectorRoles` — a **subset** of the API's `_ALLOWED_ROLES` for these endpoints, so every role it admits is one the server accepts. `auditor` is deliberately excluded (read-only in the app).
|
||||
|
||||
The server stays the authority and additionally enforces facility scope; these gates only decide whether to draw the control.
|
||||
|
||||
## 9. API Client (APIClient)
|
||||
|
||||
`actor APIClient` — singleton via `APIClient.shared`. All methods are `async throws`.
|
||||
@@ -294,22 +416,28 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas
|
||||
|
||||
`decoder.keyDecodingStrategy = .convertFromSnakeCase` — snake_case server fields map to camelCase automatically. Server POST body keys are snake_case (`photo_path`, `result_photos`, `facility_id`, etc.).
|
||||
|
||||
**`decode()` reads the envelope header before the payload.** `_EnvelopeMeta` (`ok` + `error`) is decoded first; only if `ok` is true is the payload decoded **strictly** via `_EnvelopePayload<T>`. This separates "the server reported a failure" from "the server succeeded and we could not read it" — previously `data` was decoded with `try?`, so *any* schema drift produced `data == nil` and surfaced as `serverError("Unknown server error")`, sending every investigation to the backend for what was a client-side contract mismatch. Failures now report the offending field (`missing field 'x' in APIFoo.bar`) via `describe(_:as:)`, because `DecodingError.localizedDescription` is always the useless "data couldn't be read" string.
|
||||
|
||||
**Token refresh is coalesced (`refreshTask`).** Being an `actor` is not sufficient: `refreshAccessToken()` suspends at `await`, releasing the actor, so two requests 401-ing at once each POSTed `/auth/refresh` with the *same* refresh token. The server rotates on the first, so the second presented a spent token, failed, and signed the user out mid-sync — reachable because `pollNotifications` and `registerDevice` run alongside `triggerSync`. Concurrent callers now await one shared `Task`.
|
||||
|
||||
### Key methods
|
||||
|
||||
| Method | Endpoint | Notes |
|
||||
|---|---|---|
|
||||
| `request<T>` | Any | Generic; 401 auto-refresh once |
|
||||
| `request<T>` | Any | Generic; 401 auto-refresh once (refresh is coalesced — see above) |
|
||||
| `post<T>` | Any | POST convenience |
|
||||
| `uploadPhoto` | `POST /api/v1/photos/upload` | Multipart form-data; `entity_type="issue"` → `uploads/issue_photos/` |
|
||||
| `submitInspection` | `POST /api/v1/inspections` | Sanitises `local://` paths |
|
||||
| `submitIssue` | `POST /api/v1/issues` | Sends `photo_path` = first server photo only |
|
||||
| `updateIssuePhotos` | `PATCH /api/v1/issues/<id>/photos` | Sends `{ "result_photos": [extra paths] }`; stored server-side in `mobile_photo_paths` |
|
||||
| `updateIssuePhotos` | `PATCH /api/v1/issues/<id>/photos` | **Recovery only** — called from `pushLateIssuePhotoIfNeeded()` for a photo that succeeded *after* its issue was already created. The normal path sends every evidence photo inside `submitIssue`'s create request (rule 85); do not call this from it. Merges idempotently into `mobile_photo_paths`. |
|
||||
| `fetchAssignedIssues` | `GET /api/v1/issues` | Returns issues assigned to OR reported by current user |
|
||||
| `fetchIssueDetail` | `GET /api/v1/issues/<id>` | Fetches current status |
|
||||
| `updateIssueStatus` | `PATCH /api/v1/issues/<id>/status` | Inspector updates status |
|
||||
| `fetchNotifications` | `GET /api/v1/notifications` | Optional `since: Date` cursor |
|
||||
| `markNotificationsRead` | `PATCH /api/v1/notifications/mark-read` | Marks IDs read on server |
|
||||
| `fetchScheduledInspections` | `GET /api/v1/scheduled-inspections` | Active scheduled assignments; inspector-scoped server-side. Pulled into `LocalScheduledInspection` |
|
||||
| `createScheduledFollowUp` | `POST /api/v1/scheduled-inspections/follow-up` | Plans a follow-up re-inspection for a later date (phase45). Body is only `parent_inspection_id` + `due_date` (`yyyy-MM-dd`) + optional `notes` — the server derives facility/template/assignee from the parent. Idempotent: a retry re-dates the existing active follow-up. Inspector-writable (deliberate divergence — the web is `@project_manager_required`). Online-only; see rule 80 |
|
||||
| `fetchFollowUpRequests` | `GET /api/v1/inspections?follow_up_required=true&limit=200` | Outstanding follow-up requests; same response shape as `fetchInspectionHistory`. Limit is the endpoint max on purpose — a follow-up raised on a months-old inspection must still appear. Pulled into `LocalFollowUpRequest`. See rule 78 for what the server-side filter must mean |
|
||||
| `updateIssueHandler` | `PATCH /api/v1/issues/<id>/handler` | Sets "Handled By". Body `["handler_type": …]` + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) |
|
||||
|
||||
### `APIAssignedIssue` fields
|
||||
@@ -359,6 +487,8 @@ JanitorialQCApp
|
||||
|
||||
**`NavigationSplitView` constraint:** `init(selection:content:)` unavailable on iPadOS 17. Use `@State var selectedTab: SidebarTab` with `Button` handlers. **Never add a `selection:` binding.**
|
||||
|
||||
**Compact width takes a different tree entirely.** That Button-driven sidebar cannot navigate once the split view collapses (rule 76), so `DashboardView.body` branches on `horizontalSizeClass`: `regularBody` is the `NavigationSplitView` above, `compactBody` is a `NavigationStack` whose rows are `NavigationLink`s. Both share `sidebarRowLabel(_:tinted:)` and `detailRoot(for:)` — the latter returns destination content *without* a `NavigationStack` wrapper so the call site can supply one (iPad) or push it (iPhone). Add new destinations to `sidebarTabs` + both helpers, never to one body only.
|
||||
|
||||
**`+` button placement:** The new inspection `+` button lives on `MyInspectionsView` (not the sidebar) so it remains accessible when the sidebar is collapsed. The new issue `+` button lives on `IssuesListView`.
|
||||
|
||||
---
|
||||
@@ -381,12 +511,28 @@ Contract → Facility cascade pickers (same as `StandaloneIssueView`). `onChange
|
||||
|
||||
### Scheduled inspections (phase36, July 2026)
|
||||
|
||||
`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it).
|
||||
`ScheduledInspectionsView.swift` holds `ScheduledRow` + `ScheduledInspectionsCard`. The card renders on the Dashboard (`DashboardStatsView`); `MyInspectionsView` renders its own inline "Scheduled" `List` section reusing `ScheduledRow`. Both query `LocalScheduledInspection` (sorted by `dueDateString`), self-hide when empty, and tap-to-Start opens `StartInspectionView(preFillTemplateId:preFillFacilityId:preFillScheduleId:)`. Data is pulled read-only by `pullScheduledInspections()` (see rules 63–64 for the model + cover pitfalls hit while building it).
|
||||
|
||||
**Fulfilling the schedule (July 2026 fix).** `preFillScheduleId` is the schedule's `serverId`; `startInspection()` copies it onto `LocalInspection.scheduledInspectionServerId`, and `submitInspection()` sends it as **`scheduled_inspection_id`**. The server then fulfils the schedule (one-time → deactivated, recurring → rolled forward) in the same commit as the inspection.
|
||||
|
||||
Without it — the original bug — the schedule was never fulfilled: the banner stayed on the inspector's Dashboard and My Inspections, it stayed on the web dashboard for admin/director, and the web inspection list showed no "Scheduled" badge. **Both** Start call sites must pass `preFillScheduleId` (`ScheduledInspectionsCard` and the `MyInspectionsView` inline section); re-inspection launches correctly leave it nil.
|
||||
|
||||
No SyncManager change was needed: `pullScheduledInspections()` already runs after `processInspectionQueue()` in the same `triggerSync()` pass and deletes rows the server no longer returns, so the section clears on the same sync that submits the inspection.
|
||||
|
||||
**Second fix (July 2026) — the `+` path and the vanishing cover.** The above only covers inspections *started from a Scheduled row*. The iPad's `+` button reaches the identical form with the facility and template hand-picked, and that path leaves `scheduledInspectionServerId` nil — the server stores `scheduled_inspection_id = NULL`, never calls `_fulfill_schedule()`, and the schedule stays **Active** on the web. On the web this cannot happen: `scheduled_inspections.start` is the only way in. `ExecuteInspectionView.resolveAndFulfillSchedule()` (called from `submitInspection()`, before `context.save()`) restores parity — see rule 69 for the match criteria and why the cached row is deleted rather than rolled forward.
|
||||
|
||||
**Correction (July 2026).** That first cut *deleted* the cached row at submit. It shipped, and recurring schedules then stopped picking up their new due date — see rule 71. `resolveAndFulfillSchedule()` now sets `fulfilledLocally` instead, and both scheduled lists filter on it. The cover-ownership work below still stands: `pullScheduledInspections()` continues to delete rows for one-time schedules, so consumers must still hold value snapshots rather than the model.
|
||||
|
||||
Flagging that row at submit time exposed a second problem: `ScheduledInspectionsCard` self-hid the moment its `@Query` emptied, tearing down the `.fullScreenCover` it owned — with the inspector's form inside it. Cover ownership therefore moved to the parents (`DashboardStatsView`'s `ScrollView`, `MyInspectionsView`'s `Group`) and the cover item became the value type `ScheduledStartTarget`. See rule 68.
|
||||
|
||||
**Instructions (July 2026).** The manager-authored text on a schedule is labelled **"Instructions"** everywhere the user sees it, but remains `notes` on the wire, in `APIScheduledInspection`, and in `LocalScheduledInspection`. `LocalScheduledInspection.instructions` is a *computed* accessor over `notes` that trims and nils-out blank text — no stored property, so no schema change and no migration. Three surfaces: a one-line preview on `ScheduledRow`, a full Section at the top of `StartInspectionView` (passed in as `preFillScheduleInstructions` via `ScheduledStartTarget.instructions`), and a collapsible banner above the form in `ExecuteInspectionView` (looked up from SwiftData, so it also works on the draft-resume path where no parameter is threaded). See rule 70.
|
||||
|
||||
### Inspection-start presentation (July 2026)
|
||||
|
||||
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
||||
|
||||
**Leaving after submit — `ExecuteInspectionView.onFinished`.** `StartInspectionView` *pushes* the form onto the NavigationStack inside its own cover, so `dismiss()` there only pops: the inspector finished an inspection and landed back on the "New Inspection" form that started it, with Cancel as the only way out. `StartInspectionView` passes its own dismiss as `onFinished` so the whole cover closes. Left nil everywhere popping is correct — the My Inspections row (pushed onto the list's stack) and the dashboard Resume banner (this view *is* the cover root).
|
||||
|
||||
### "Handled By" (issue handler, phase35 → mobile July 2026)
|
||||
|
||||
`IssueDetailView` (`IssuesView.swift`) shows a "Handled By" section: current handler label + detail, and — for admin/director/PM **and the assigned inspector** — an inline editor (segmented internal/facility/vendor + name/contact/notes) that PATCHes via `updateIssueHandler` and mirrors the result onto `LocalIssue`. The inspector-writable path is a deliberate divergence from the web form (web CLAUDE.md rule 78).
|
||||
@@ -451,6 +597,19 @@ else { continue }
|
||||
|
||||
`CompletedInspectionView` shows orange banner with **Start Re-inspection** when `followUpRequired == true`. Opens `StartInspectionView` with `preFillTemplateId`, `preFillFacilityId`, `parentServerId`, `parentLocalId`.
|
||||
|
||||
The **Follow-up Requested** card / section (July 2026) is the second trigger, and reaches the same view with `parentServerId` plus `preFillFollowUpNote` and `preFillParentFormDataJSON`.
|
||||
|
||||
**History detail** (`HistoryDetailView`) is the third and fourth: a **Re-inspect Now** toolbar button (immediate, passing this response's own answers as `preFillParentFormDataJSON` — history is served from the API, so the parent is usually not local) and **Schedule Follow-up**, which plans it for a later date via `createScheduledFollowUp`. The scheduled row then starts as a linked re-inspection because it carries `parentInspectionServerId`. See rule 80.
|
||||
|
||||
### Parent pre-fill — two sources
|
||||
|
||||
`StartInspectionView.startInspection()` copies the parent's answers forward, excluding `rating`, `pass_fail`, `image`, `signature` so every scoreable item is re-evaluated fresh and the parent's photos stay with the parent. This mirrors the web's `inspections.execute` prefill.
|
||||
|
||||
`resolvedParentFormData()` resolves the source in order:
|
||||
|
||||
1. The local `LocalInspection` with a matching `serverId` — the `CompletedInspectionView` path, where the inspector just finished it on this iPad. Used only when it actually holds values, so an empty local shell can't shadow source 2.
|
||||
2. `preFillParentFormDataJSON` — snapshotted from the server onto `LocalFollowUpRequest.parentFormDataJSON` at pull time. This is the follow-up-request path. See rule 79.
|
||||
|
||||
### followUpRequired clearing — three points
|
||||
|
||||
1. Immediately on Submit in `ExecuteInspectionView.submitInspection()` via `clearParentFollowUpFlag()`.
|
||||
@@ -478,24 +637,38 @@ else { continue }
|
||||
### PendingPhoto lifecycle
|
||||
|
||||
```
|
||||
Created (uploadStatus="pending")
|
||||
Created (uploadStatus="pending", uploadRetryCount=0)
|
||||
↓ SyncManager.processPhotoQueue()
|
||||
Uploaded (uploadStatus="uploaded", serverPath set)
|
||||
↓ Parent record updated
|
||||
Inspection image fields: LocalInspection.formData[fieldId] = serverPath
|
||||
Issues: LocalIssue.photoServerPaths.append(serverPath)
|
||||
├─ success → uploadStatus="uploaded", serverPath set
|
||||
│ ↓ Parent record updated (attachServerPath)
|
||||
│ Inspection image fields: LocalInspection.formData[fieldId] = serverPath
|
||||
│ Issues: LocalIssue.photoServerPaths.append(serverPath)
|
||||
└─ error → uploadRetryCount += 1, STAYS "pending" (retried next sync)
|
||||
└─ only at maxPhotoUploadAttempts (5) → uploadStatus="failed"
|
||||
```
|
||||
|
||||
**`"failed"` is terminal and means "every attempt was used", not "one error happened"** — see rule 83. `SyncStatusView` → Retry Failed Items resets these back to `"pending"`; it is the only thing that does.
|
||||
|
||||
### Multi-photo issue submission sequence
|
||||
|
||||
```
|
||||
1. processPhotoQueue: uploads all N photos → appends each serverPath to issue.photoServerPaths
|
||||
2. processIssueQueue: submitIssue(issue) → sends photo_path = photoServerPaths[0]
|
||||
issue.photoLocalPaths = [] (clear local paths — prevents duplicate sections)
|
||||
updateIssuePhotos(issueId, photoServerPaths.dropFirst())
|
||||
→ PATCH /issues/<id>/photos with extras
|
||||
(a shared local file is uploaded ONCE; every PendingPhoto row
|
||||
pointing at it gets the same serverPath — rule 86)
|
||||
2. processIssueQueue: waits until all N photos are "uploaded" or "failed" ← rule 83
|
||||
submitIssue(issue) → photo_path = photoServerPaths[0]
|
||||
result_photos = the rest ← rule 85
|
||||
(server stores these in mobile_photo_paths)
|
||||
issue.photoLocalPaths = [] ONLY if all N uploaded ← rule 84
|
||||
```
|
||||
|
||||
**One request, not two.** There is no post-create PATCH on the normal path — see rule 85.
|
||||
Two related calls are NOT exceptions to that:
|
||||
- `pushLateIssuePhotoIfNeeded()` fires only when `issue.serverId` is already set, i.e. a
|
||||
photo recovered after the issue was created (rule 83's residual case).
|
||||
- `PATCH /issues/<id>/result_photos` is `IssueDetailView` attaching *resolution* photos,
|
||||
which genuinely are added after the fact.
|
||||
|
||||
### Photo display in IssueDetailView
|
||||
|
||||
Gated on `syncStatus`:
|
||||
@@ -539,13 +712,15 @@ While a photo is pending upload, the inspection form field value is `"local://<p
|
||||
|
||||
| Type | Rule |
|
||||
|---|---|
|
||||
| `rating` | `0` = unanswered → excluded. Each answered rating: `value / 5` of 1.0 |
|
||||
| `rating` | `0` = unanswered → excluded. Each answered rating: `value / 5` of 1.0. **The denominator is a flat 5, never the field's `max`** — `_compute_score_from_form()` hardcodes it, so anything reading `max` disagrees with the score the server stores |
|
||||
| `checkbox` | `"true"` = pass |
|
||||
| `radio` | Pass: `pass`, `yes`, `ok`, `good`, `acceptable`, `compliant` (case-insensitive) |
|
||||
| `pass_fail` | Same keywords. Empty = unanswered → excluded |
|
||||
|
||||
Returns `nil` if no scoreable fields or all unanswered.
|
||||
|
||||
**Two implementations must agree.** `ExecuteInspectionView.liveScore` recomputes the same thing from in-memory `formValues` to drive the toolbar badge as the inspector fills the form. It read `field["max"] ?? 5` for the rating denominator while `computeScore` hardcoded 5, so any template with `max != 5` showed one percentage in the toolbar and submitted another. Change both together, and check `_compute_score_from_form()` in `app/routes/inspections.py` — it is the authority.
|
||||
|
||||
---
|
||||
|
||||
## 19. Background Sync
|
||||
@@ -563,7 +738,7 @@ Requirements: `requiresNetworkConnectivity = true`, `requiresExternalPower = fal
|
||||
- **Sync Now** — triggers `triggerSync()`; disabled when offline or syncing.
|
||||
- **Clear Reference Cache** — deletes `LocalFacility`, `LocalArea`, `LocalTemplate` only. Never touches `LocalInspection`, `LocalIssue`, `PendingPhoto`. Triggers `pullReferenceData()` if online.
|
||||
- **Server picker** — see §21.
|
||||
- **Log Out** — calls `clearServerPulledData()` + `resetNotificationPoller()` + `auth.logout()`.
|
||||
- **Log Out** — calls `resetNotificationPoller()` + `auth.logout()`. Purges **nothing**: the cache stays so the same inspector can work offline after signing back in. A *different* inspector signing in is handled at login by `reconcileSessionScope()` (rule 88).
|
||||
- App version + current server URL (from `ServerConfig.current`).
|
||||
|
||||
---
|
||||
@@ -598,14 +773,19 @@ Segmented picker above the credential fields. `onChange` calls `ServerConfig.sel
|
||||
Segmented picker in a "Server" section. `onChange` snaps the picker back to the current saved server, stores intent in `pendingServer`, and shows `Alert("Switch Server?")`.
|
||||
|
||||
**Alert actions:**
|
||||
- **Switch & Log Out (destructive):** `ServerConfig.select(chosen)` → `clearServerPulledData()` → `resetNotificationPoller()` → `auth.logout()`.
|
||||
- **Switch & Log Out (destructive):** `ServerConfig.select(chosen)` → `purgeSessionScopedData(keepingUserId: nil, sameServer: false)` → `SessionScope.clear()` → `resetNotificationPoller()` → `auth.logout()`. Erases **everything**, unsynced work included — the alert says so. Previously this cleared `LocalIssue` alone and left `LocalInspection` rows holding the other server's facility/template ids.
|
||||
- **Cancel:** clears `pendingServer`, picker stays on original.
|
||||
|
||||
**Why logout is required on server switch:** `serverId` values are server-specific. A `LocalIssue` with `serverId = 48` from `jqc.ltservicesinc.com` has no meaning on `jqc1.ltservicesinc.com`. Keeping stale records causes "Issue not found" errors on every status fetch/update.
|
||||
|
||||
### clearServerPulledData() boundary
|
||||
### Scope boundary
|
||||
|
||||
Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` records (pending, never synced). This is the correct boundary — not `syncStatus == "synced" && inspectionLocalId == ""` (the old incorrect filter that missed inspector-created synced issues).
|
||||
There is no partial boundary any more. `serverId` is not the only server-specific value —
|
||||
`facilityServerId`, `templateServerId`, `areaServerId` and `parentServerId` all name rows in
|
||||
one particular database, and inspector facility scope differs per user on top of that. So a
|
||||
scope change purges wholesale rather than filtering (rule 88); the only thing carried across
|
||||
is the incoming user's own unsent `LocalInspection` rows, and only when the server is
|
||||
unchanged. See §8, `purgeSessionScopedData()`.
|
||||
|
||||
---
|
||||
|
||||
@@ -654,7 +834,7 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
||||
| 39 | **Server photo URLs include `/static/` prefix** | Server stores at `app/static/uploads/`; Flask serves at `/static/uploads/`. URL = `ServerConfig.current + "/static/" + relativePath`. Missing `/static/` returns 404. |
|
||||
| 40 | **Use `RetryablePhotoView` for all server photo loads** | `AsyncImage` has no retry — once in `.failure` it stays there for the view's lifetime. `RetryablePhotoView` allows tap-to-retry by toggling `.id(reloadToken)`. |
|
||||
| 41 | **Only use SF Symbols available on iOS 17** | `photo.slash` and `photo.badge.exclamationmark` are absent on some devices. Use `exclamationmark.triangle` for all photo-error states. |
|
||||
| 42 | **`clearServerPulledData()` boundary is `serverId != nil`** | Old boundary `syncStatus == "synced" && inspectionLocalId == ""` missed inspector-created synced issues, leaving stale serverIds that caused "Issue not found" after server switch. |
|
||||
| 42 | ~~**`clearServerPulledData()` boundary is `serverId != nil`**~~ | **Superseded by rule 88.** The function is gone; `purgeSessionScopedData()` replaces it and no longer filters by `serverId` at all. The history is still worth knowing: the boundary was widened twice (from `syncStatus == "synced" && inspectionLocalId == ""` to `serverId != nil`) and was wrong both times, because the problem was never which *issues* to delete — it was that issues are not the only server-scoped model, and logout is not the moment that matters. |
|
||||
| 43 | **`processIssueQueue` clears `photoLocalPaths` after successful submit** | Prevents `IssueDetailView` from rendering a duplicate "local photos" section alongside the server photos section for synced issues. |
|
||||
| 44 | **`StandaloneIssueView` uses `inspectionLocalId = ""`** | Same pattern as server-pulled issues. `processIssueQueue`'s parent-inspection guard evaluates `parent?.syncStatus == "failed"` → `false` for `""`, so standalone issues submit normally. |
|
||||
| 45 | **Facility lists deduplicate by `serverId` at both storage and display layers** | Storage: `pullReferenceData()` deduplicates server response before upsert. Display: `filteredFacilities` in both `StartInspectionView` and `StandaloneIssueView` uses `filter { seen.insert($0.serverId).inserted }`. |
|
||||
@@ -676,10 +856,35 @@ Deletes `LocalIssue` where `serverId != nil`. Preserves `serverId == nil` record
|
||||
| 62 | **Sort `schema` by `(row, col)` before grouping fields into row buckets** | The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by `row` and attaches section headers must first sort by `(f["row"], f["col"])` — exactly like the web's `sorted(key=lambda f: (f['row'], f['col']))`. Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute `(col, row)` pixel offsets (e.g. `GridFormView` ZStack, `canvasHeight()`) are unaffected — sort order only matters when grouping by row for sequential rendering. |
|
||||
|
||||
| 63 | **`@Attribute(.unique)` must NOT carry an inline default value** | A `.unique` key with a default (`@Attribute(.unique) var serverId: Int = 0`) stops the `@Model` macro from emitting a clean `PersistentModel` conformance. Symptom is misleading: the `.modelContainer(for: [ … ])` array literal fails to type-check and Xcode reports **"Cannot find '<OtherModel>' in scope" on the *other* schema elements**, not the offending one. Declare the unique key with no default (`@Attribute(.unique) var serverId: Int`) and set it in `init`, exactly like `LocalFacility`/`LocalArea`. (Bit us adding `LocalScheduledInspection`, July 2026.) |
|
||||
| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack` root instead. The dashboard scheduled card puts its cover on the VStack; `MyInspectionsView` puts the scheduled "Start" cover on the `List`. |
|
||||
| 64 | **Attach `.fullScreenCover` / `.sheet` to a stable view, NEVER to a `Section`** | A `Section` inside a `List` is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing `List`/`ScrollView`/`VStack`/`Group` root instead — and that root must also **outlive the data that drives the presentation** (see rule 68). The dashboard scheduled cover lives on `DashboardStatsView`'s `ScrollView`; `MyInspectionsView` puts the scheduled "Start" cover on its enclosing `Group`. |
|
||||
| 65 | **`CodingKeys` stay plain camelCase (decoder uses `.convertFromSnakeCase`); request-body keys are raw snake_case** | The shared `JSONDecoder` sets `keyDecodingStrategy = .convertFromSnakeCase`, converting JSON `facility_handler_name` → `facilityHandlerName` **before** matching — so `CodingKeys` must be bare camelCase; adding an explicit `= "facility_handler_name"` raw value double-converts and breaks decode. Conversely PATCH/POST bodies are `[String: Any]` encoded with `JSONSerialization` (no key strategy), so body keys must be the literal snake_case the server reads (`"handler_type"`, `"vendor_name"`, …). |
|
||||
| 66 | **Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free** | `ExecuteInspectionView` takes `isModallyPresented` and shows a leading `Close` only when true (draft-resume from the dashboard is the root of its `NavigationStack`, no back button). All inspection-start flows now use `.fullScreenCover` for a consistent full-screen form: draft-resume → `ExecuteInspectionView(isModallyPresented: true)`; scheduled/new/re-inspection → `StartInspectionView` (its own `.cancellationAction` Cancel). `.onDisappear`/auto-save preserves work, so Close is always safe. |
|
||||
| 67 | **Render server photos through `ServerConfig.mediaURL(absolute:path:)`, never by hand-building `current + "/static/" + path`** | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): `photo_urls`/`result_photo_urls` on issues (`APIAssignedIssue`/`APIIssueDetail` → `LocalIssue.photoServerUrls`/`resultPhotoServerUrls`, parallel to the path arrays), and `form_media` `{fieldId: url}` on inspection detail (`APIInspectionSummary.mediaURLByPath`, injected into the read-only grid via the `\.mediaURLByPath` environment for `PhotoThumbnailView`). The resolver prefers the absolute URL and falls back to `/static/` for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. |
|
||||
| 68 | **A `.fullScreenCover` owner must outlive the rows that trigger it; carry a value snapshot, not the `@Model` object** | `ScheduledInspectionsCard` self-hides on `scheduled.isEmpty`, and submitting the last scheduled inspection empties that `@Query` **while the cover is still on screen** — the card disappears and takes its cover (and the inspector's form) with it. Fix: the card is presentational and reports taps via `onStart`; `DashboardStatsView` owns the cover on its always-present `ScrollView`, `MyInspectionsView` on its `Group`. The cover item is `ScheduledStartTarget` (three plain `Int`s), never `LocalScheduledInspection` — reading a deleted `PersistentModel` traps. |
|
||||
| 69 | **`ExecuteInspectionView.resolveAndFulfillSchedule()` links the submission to its schedule and invalidates the cached row — it never computes the next due date** | Two entry points reach the identical form (Scheduled row → prefilled; `+` → hand-picked), but only the first sets `scheduledInspectionServerId`, so a `+`-started inspection lands with `scheduled_inspection_id = NULL` and the schedule stays **Active** on the web. The fallback matches an active `LocalScheduledInspection` on facility + template, gated to `dueDateString <= today`, assignee `nil`-or-self, earliest due first, and skipped for re-inspections. Roll-forward stays server-side: the local model has no phase43 recurrence detail (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the row is **flagged `fulfilledLocally`, never deleted** (see rule 71), and `pullScheduledInspections()` writes the authoritative `next_due_date` via `update(from:)` — which also self-heals a submission that never lands. |
|
||||
| 70 | **Snapshot schedule instructions into `@State` in `onAppear` — never read them from SwiftData during `body`** | `ExecuteInspectionView` shows the schedule's instructions above the form, but `resolveAndFulfillSchedule()` **deletes** that `LocalScheduledInspection` the instant Submit is tapped and the view stays up for another 2.5 s showing the success banner. A computed lookup would re-read a deleted `PersistentModel` in that window and trap. `loadScheduleInstructions()` copies the `String` once, at appear. Same reasoning as `ScheduledStartTarget` (rule 68): once the fulfilment path can delete a cached row mid-flow, every consumer must hold a value, not the model. |
|
||||
| 71 | **Never delete a cached row the server is going to send again — flag it** | The first cut of `resolveAndFulfillSchedule()` deleted the `LocalScheduledInspection` at submit. One-time schedules were fine (the server deactivates them and never returns them again), but every **recurring** schedule is returned again on its next occurrence, so each completion became delete-then-reinsert against an `@Attribute(.unique)` serverId — and the reinserted row did not reliably carry the rolled-forward date. A daily schedule kept showing today's date after being completed. Fix: `fulfilledLocally: Bool = false` hides the row locally; `init(from:)`/`update(from:)` clear it, so the pull remains the only thing that ever writes a cached schedule's dates. Deletion of schedule rows now happens in exactly one place — the "server no longer returns it" branch of `pullScheduledInspections()`. |
|
||||
| 72 | **A toolbar `Label` needs `.labelStyle(.titleAndIcon)` or SwiftUI renders it icon-only** | `IssuesView`'s New Issue button was written as `Label("New Issue", systemImage: "plus")` and still appeared on the iPad as a bare "+" — SwiftUI decides toolbar label styling itself and drops the title. Having the text in the source is not enough; state the style explicitly. Both creation entry points (`MyInspectionsView` → New Inspection, `IssuesView` → New Issue) now pin `.titleAndIcon` alongside `.borderedProminent`. |
|
||||
| 73 | **Both background-sync Info.plist keys must live in `Info.plist` itself — `INFOPLIST_KEY_*` cannot express them** | `BGTaskSchedulerPermittedIdentifiers` and `UIBackgroundModes` are both **arrays**. `INFOPLIST_KEY_*` build settings only merge Xcode's recognised key list and only as **strings**, so `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync` never produced a valid entry — it sat inert in the pbxproj while `BGTaskScheduler.submit()` failed with `.notPermitted` under a `try?`. Adding `UIBackgroundModes` then made App Store Connect check, and the upload was rejected with **error 90771**. Both keys now live in `JanitorialQC/Info.plist` as arrays and the build setting is deleted from both configurations. Do not reintroduce it: a build setting overwrites the file's value at merge time. Keep the identifier string in sync with `BGTaskScheduler.register` / `BGProcessingTaskRequest` in `JanitorialQCApp`. Verify a build before uploading: `plutil -p <built .app>/Info.plist \| grep -A2 BGTask` must show an array. |
|
||||
| 74 | **Two different background mechanisms — do not confuse them** | `BGProcessingTask` (JanitorialQCApp) asks iOS to **wake us later**: opportunistic, typically charging + Wi-Fi + idle, not a heartbeat. `beginBackgroundTask` (`SyncManager.beginSyncBackgroundTask`) asks iOS **not to suspend us right now**: ~30 s, covers submit-then-lock. The expiration handler must end the assertion or iOS terminates the app, and it needs `MainActor.assumeIsolated` because the closure is nonisolated under `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. |
|
||||
| 75 | **A background launch has no ContentView and may have no ModelContainer** | `restoreSession()` runs from `ContentView.task{}` and the container comes from the `.modelContainer` scene modifier — neither happens on a cold BGTaskScheduler launch, so `triggerSync()`'s `isAuthenticated` / `modelContext` guards silently no-op. `handleBackgroundSync()` now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the *suspended-but-resident* case; cold relaunch needs the container hoisted out of the scene modifier. |
|
||||
| 76 | **A collapsed `NavigationSplitView` shows only its sidebar — Button-driven rows navigate nowhere on iPhone** | On compact width the split view collapses to a stack rooted at the sidebar, and the `detail:` column is presented only when something *pushes* it. Rule 2 forbids a `selection:` binding, so the rows are plain `Button`s that mutate `@State` — and a state change alone cannot push the detail column. The app installs on iPhone (`TARGETED_DEVICE_FAMILY = "1,2"`), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting `selectedTab` programmatically still rendered only the sidebar. `DashboardView` now branches on `horizontalSizeClass` and gives compact width a real `NavigationStack` with `NavigationLink` rows. Never "fix" this by adding a `selection:` binding — that breaks iPadOS 17 (rule 2). |
|
||||
| 77 | **The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it** | `GridFormView` positions cells absolutely from `cellW = (W - 32 - 88) / 12`. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's `overflow: visible`), so the excess draws *on top of* the row below and the form becomes an unreadable pile of overlapping controls. Below `GridFormView.minGridWidth` (600 pt, keeping a column at >=40 pt) the view switches to `stackedLayout`: fields sorted by `(row, col)` (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (`textarea`, `signature`, `table`, `image`) get floors from `stackedMinH` or they collapse to nothing. In the stacked branch the card must be a `.background` modifier, **not** a `ZStack` sibling — as a sibling the flexible `RoundedRectangle` competes with the `VStack` for the container's size and the card ends up shorter than its own content, cutting off the last fields. `ReadOnlyGridFormView` (history detail) has the same 12-column assumption and the same compact branch, keyed off `horizontalSizeClass`. |
|
||||
| 78 | **"Outstanding follow-up" is three conditions, not one — `follow_up_required` alone is not the definition** | Every web surface (`inspections.list` / `reports` `status_filter == 'follow_up'`, `stats.pending_followups`) means **flagged AND `status == 'completed'` AND `~follow_ups.any()`**. The reason the third clause exists: the **web execute route never clears `follow_up_required` on the parent** — it only stops *listing* the parent once a child re-inspection exists. (The mobile POST path *does* clear the parent flag, `app/api/inspections.py`, so only web-completed re-inspections leave a stale flag.) The first cut of the API's `?follow_up_required=true` filter matched the flag alone, which would have returned follow-ups already satisfied on the web — and on the iPad those rows are **undismissable**: `pullFollowUpRequests()` keeps receiving them, `update(from:)` deliberately resets `fulfilledLocally = false` (the server is authoritative), so FOLLOW-UP REQUESTED would never clear and the only way out is a duplicate re-inspection. Fixed server-side so one definition serves every client. Never re-narrow this filter to the bare flag, and never "fix" a stuck row on the client — `fulfilledLocally` is a display flag, not state. |
|
||||
| 79 | **A re-inspection's parent is usually NOT on the device — prefill must fall back to the cached snapshot, and never prefill without the template schema** | `startInspection()`'s prefill originally matched only a local `LocalInspection` by `serverId`. That works for `CompletedInspectionView` (the inspector just finished it here) but **not for a follow-up raised on the web**: that parent synced long ago and is routinely absent (reinstall, second iPad, follow-up raised weeks later — the same premise `LocalFollowUpRequest` exists for). The lookup found nothing, the whole block silently no-opped, and the form opened blank where the web pre-fills it. Fix: `LocalFollowUpRequest.parentFormDataJSON` caches the parent's answers at pull time — free, because `GET /api/v1/inspections` already returns `form_data` on every row via `_inspection_payload`, so there is no extra request and prefill works offline. Store `formDataRaw.mapValues(\.anyValue)`, **not** `formValues`: the latter joins arrays into `"a, b"`, which would be written back as one bogus string. Second trap, only reachable once prefill actually runs: the exclude set is derived from the template schema, so an unresolved schema (`?? []`) yields **no exclusions and copies everything** — including the parent's `image` paths, attaching its photos as this inspection's evidence. Guard on `!schema.isEmpty` and copy nothing instead. |
|
||||
| 80 | **"Schedule Follow-up" is a server-side plan — it is the one action in the app that cannot work offline, and its link must survive the client forgetting it** | History detail (`HistoryDetailView`) carries three toolbar actions: **Re-inspect Now** (immediate, opens the linked re-inspection), **Schedule Follow-up** (deferred), and the existing email button. Starting an inspection writes locally and syncs later, but scheduling writes a `ScheduledInspection` row that only the server can create — there is no local record to queue, so the button is `.disabled(!sync.isOnline)` and failures report inline instead of dismissing as though they worked. Do not "fix" this by faking a local schedule: `pullScheduledInspections()` deletes any row the server doesn't return, so it would vanish on the next sync. The link itself is `scheduled_inspections.parent_inspection_id` (phase45): both start paths inherit it onto the inspection (`ScheduledStartTarget.parentServerId` on iPad, the web's `scheduled_inspections.start`), **and** the API's create-inspection endpoint re-derives it from the schedule when the client sends none — a belt-and-braces step that matters because an older build or a resumed draft would otherwise submit a plain inspection and leave the parent flagged forever. Creation is inspector-writable, a deliberate divergence from the web's `@project_manager_required`, and the endpoint is deliberately narrow: it takes only a parent + date and derives facility/template/assignee, so a follow-up can only ever target the thing it follows up on. |
|
||||
| 81 | **Never commit a local-dev override — repointing `ServerOption.primary` at localhost took production login down** | July 2026: `primary` was changed from `https://jqc.ltservicesinc.com` to `http://127.0.0.1:5055` for local API work, with `NSAllowsArbitraryLoads=true` added to `Info.plist` for cleartext. Both shipped in `dac7e6c`, so the "Primary" entry in the server picker dialled a developer laptop and **every inspector failed to log in** — only the untouched secondary worked. Symptom in the device log is unmistakable and is *not* an auth problem: `NSErrorFailingURLStringKey=http://127.0.0.1:5055/...` with `Connection refused [61]`. Revert a dev override in the same session that adds it; being aware of it is not a safeguard. Prefer an override that *cannot* be committed — a debug-only scheme argument, an xcconfig, or `#if DEBUG` — over editing this shared production constant. One thing that saved us: `ServerConfig.current` validates the stored UserDefaults string through `ServerOption(rawValue:)` and falls back to `.primary`, so a stale localhost selection self-heals on update — keep that round-trip validation. ATS exceptions must be scoped to the host (`NSExceptionDomains` for `127.0.0.1`/`localhost`); `NSAllowsArbitraryLoads` disables TLS validation for the *production* servers too and is an App Store review trigger. |
|
||||
| 82 | **A shared `static` read from `APIClient` (or any nonisolated context) must be declared `nonisolated`** | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` makes **every** type implicitly `@MainActor`, including a bare constants-holder `enum`. `APIClient` is an `actor`, so reading such a static from it warns *"Main actor-isolated static property 'X' can not be referenced from a nonisolated context"* — and that becomes a **hard error** under the Swift 6 language mode, so it will block a toolchain move. `PhotoCaptureFormat.iso8601` hit this from the two `captured_at` multipart call sites. Mark the enclosing enum `nonisolated`, matching `Constants`, `ServerConfig`, `PhotoCaptureFormat` and `SyncManager.isoFormatter` (rule 35). Do **not** reach for `nonisolated(unsafe)` (used nowhere here — it hides the problem) or allocate a formatter per call (rule 35 exists because that cost is real on the upload/sync paths). Foundation formatters are thread-safe for formatting, so one shared instance is correct. |
|
||||
| 83 | **A photo upload error is TRANSIENT — leave the row `"pending"`. `"failed"` means every attempt was used, and nothing else may set it** | Aug 2026, the lost-photo defect. `processPhotoQueue` marked `uploadStatus = "failed"` on the *first* error; `processInspectionQueue`'s `photosReady` accepted `"failed"` as settled and submitted anyway; `APIClient.submitInspection` rewrote the surviving `local://` value to `""`; the inspection was then marked `synced` forever. **And nothing anywhere ever moved a row off `"failed"`** — `SyncStatusView.retryAllFailed()` reset `LocalInspection`/`LocalIssue` only. One dropped connection therefore destroyed an evidence photo permanently and silently, with the sync reported as successful. Now: `uploadRetryCount` increments and the row stays `"pending"` until `SyncManager.maxPhotoUploadAttempts` (5), so the next sync retries it **and** the parent keeps waiting. The cost is that a completed inspection can sit in the outbox for a few sync cycles while a photo retries — that is the correct trade; submitting first is what caused the loss. `retryAllFailed()` now resets photos too, and is the only escape hatch from terminal `"failed"`. `PhotoDiagnosticView` exists to size the damage already done and must stay read-only. |
|
||||
| 84 | **Clear `LocalIssue.photoLocalPaths` only when EVERY photo reached the server** | The clear was unconditional after a successful submit, so a partial upload left the JPEGs on disk with nothing referencing them — invisible to `IssueDetailView` and to `PhotoDiagnosticView` alike. Keeping them costs a duplicate photo section in the detail view at worst (rule 34's cosmetic concern); dropping them costs the evidence. `processIssueQueue` now guards on `issuePhotos.allSatisfy { $0.uploadStatus == "uploaded" }`. |
|
||||
| 85 | **Send an issue's evidence photos IN the create request — never in a follow-up call after it is marked `"synced"`** | `submitIssue()` sent `photo_path` only, then `processIssueQueue` fired `PATCH /issues/<id>/photos` for the rest with `try? await`. By then `syncStatus == "synced"`, so `processIssueQueue` never revisited the issue: one failed PATCH silently cost every photo after the first, and the loss became invisible on device too once `pullAssignedIssues` overwrote `photoServerPaths` with the server's copy. The split was never necessary — `POST /api/v1/issues` already accepts `result_photos` and stores it in `mobile_photo_paths` (`app/api/issues.py`, `create_issue`), and `processPhotoQueue` fully populates `photoServerPaths` *before* `processIssueQueue` runs, so the extras were always known at create time. `submitIssue()` now sends `photo_path` + `result_photos` together: attachment is atomic with creation, there is no `synced`-but-unattached window to reconcile, and `mobile_local_id` idempotency covers retrying the whole request. The generalisation holds beyond photos — if a second call is needed after a record is marked synced, either fold it into the first or persist the debt; `try?` there means silent permanent loss. |
|
||||
| 86 | **Two `PendingPhoto` rows sharing a local file must both receive the uploaded `serverPath`** | The de-dup pass marked the duplicates `"uploaded"` without ever setting `serverPath`, so the same image attached to two form fields submitted the second field blank. `processPhotoQueue` now uploads once and settles every row from a `localFilePath -> serverPath` map (a row whose twin failed stays `"pending"` so both retry together). Uploading once still matters independently: two uploads of one file yield two server filenames and duplicate the photo in the evidence and the PDF. |
|
||||
| 87 | **`cleanupOrphanedPhotos()` sweeps `JQC/Photos` only, references EVERY surviving `local://` path, and never deletes a file younger than 7 days** | It pointed at `Documents/JQCPhotos`, which no writer has ever used — `contentsOfDirectory` failed, the `guard` returned, and it silently deleted nothing for its entire life while photos accumulated. Correcting the path is only safe alongside rule 83, and only with the reference set widened: a `local://` sentinel surviving on a *submitted* inspection means that photo never reached the server, so the file is the only copy left and is exactly what `PhotoDiagnosticView` reports as recoverable — the old draft-only filter would have deleted it. `JQC/ResultPhotos` is deliberately **not** swept: those files are staged in `IssueDetailView`'s `@State` with no database row, so nothing can prove one is unused. The 7-day age floor covers that flow and the window between writing a JPEG and saving the record that points at it. |
|
||||
| 88 | **Local data is scoped to a `(server, userId)` pair — purge on an identity CHANGE, never on logout** | Two defects, one cause. (a) Logout deliberately kept the cache so the same inspector could work offline after signing back in — correct — but nothing checked that the next sign-in *was* the same inspector. `pullAssignedIssues`' reconciliation only deletes rows with `inspectionLocalId == ""`, so device-authored synced issues survived indefinitely and a different inspector on the same iPad simply inherited them. (b) The server switch cleared `LocalIssue` alone, leaving `LocalInspection` rows carrying `facilityServerId`/`templateServerId` values that name different rows on the server being switched to — ready to be submitted against it. `SessionScope` (UserDefaults, **not** Keychain — it must outlive `KeychainHelper.clearAll()`) records the pair; `AuthManager.reconcileSessionScope()` compares on every `login()`/`restoreSession()` and calls `SyncManager.purgeSessionScopedData()` only on a mismatch, before `isAuthenticated` flips so no view ever renders the previous user's data. `LocalInspection` is the only model with an author (`inspectorUserId`), so it is the only one whose unsent rows can be handed back; `LocalIssue` has none, and submitting one under a different inspector's credentials would put a false name on a QC record. A nil marker adopts the existing data rather than purging — fresh install and pre-marker upgrade are indistinguishable, and guessing wrong would delete the signing-in user's own draft. |
|
||||
| 89 | **Never raise a second alert from inside the first one's button action** | Both alerts hang off the same view, so the new presentation is discarded while the first is still tearing down. `ExecuteInspectionView`'s Submit set `showNoGPSAlert = true` from inside the confirm alert's action, and the warning simply never appeared — tapping Submit without a GPS fix did *nothing at all*: no alert, no submission, no feedback. Park the intent in a `@State` flag and act on it from `onChange(of:)` when the first alert's binding flips false, with a short hop so the dismissal animation has finished. Applies to `.sheet`/`.confirmationDialog` chained onto one view too. |
|
||||
| 90 | **`date` form fields are `"yyyy-MM-dd"`, and an unanswered one must render as unanswered** | Two defects in one widget, both in `CellDatePicker` and `DateFieldView`. (a) They stored `ISO8601DateFormatter().string(...)` — a full `2026-08-18T14:30:00Z` timestamp — into a field the web writes with `<input type="date">` and both the read-only grid and the PDF print verbatim. `FormDateFormat` (UTC + POSIX, `yyyy-MM-dd`) is now the single definition, and parses a leading date out of legacy timestamp values. (b) A `DatePicker` bound to an empty value still displays TODAY, so the field looked answered — but the setter only fires on a *change*, so selecting the already-shown date wrote nothing and `missingRequiredFields()` reported it missing with a date visible on screen. An explicit "Set date" affordance replaces the picker while the value is empty, plus an × to return to unanswered. |
|
||||
| 91 | **A stored photo path is only valid inside the container that wrote it — resolve by FILENAME, never trust the absolute path** | Photo paths are absolute and embed the app-container UUID (`/var/mobile/Containers/Data/Application/<UUID>/Documents/JQC/Photos/<file>.jpg`). iOS assigns a NEW container UUID on every app update, reinstall and restore: `Documents/` survives, every stored path dies. Nothing accounted for that, so `uploadPhoto`'s `FileManager.contents(atPath:)` returned nil and the upload could **never** succeed no matter how often it retried — the path named a container that no longer existed. Any photo still awaiting upload when the app updated was stranded permanently and its inspection submitted with the field blank. Confirmed in the field on inspection #887 (Aug 2026): nine photos, all `upload: pending`, **0 rows failed**, every file present on disk under the current container. `PhotoStore.resolve()` re-resolves by basename (filenames are per-save UUIDs, so a basename is unambiguous) and is now used by every reader, writer and deleter of a stored path. `processPhotoQueue` heals paths BEFORE the pending filter so a row already given up on is revived, and resets `uploadRetryCount` — earlier failures were about a path that no longer applies. **The retry budget added in rule 83 does not help here and never could: retrying an unresolvable path is futile.** Two corollaries: an unresolvable path now fails fast instead of burning five sync cycles, and `cleanupOrphanedPhotos` compares FILENAMES — comparing full paths meant that after an update every reference missed its own file and the sweep would have deleted exactly the photos that were still recoverable. |
|
||||
| 92 | **The notification API is a POLLER, not an inbox — `LocalNotification` is the inbox** | `GET /api/v1/notifications` filters to `is_read = False` and never sends the flag (`app/api/notifications.py`), so the iPad list was all-unread by construction — every row rendered identically, with nothing to read or dismiss — and marking one read made it disappear rather than grey out. The fix is a local store the poll upserts into and never deletes from; a row's absence from a response carries no information. Read state is shared with the web: a tap or Mark All is a **deliberate user action**, so it pushes via `PATCH /notifications/mark-read` (previously dead code) and clears the web badge too. That does not contradict the long-standing rule against marking read on POLL — auto-marking would zero the user's web badge just because the iPad was switched on, and `pollNotifications()` still never does it. Writes are local-first with `readSyncPending`, drained by `pushNotificationReadState()`, so reading works in airplane mode. If a mobile inbox endpoint mirroring `routes/notifications.py::index` is ever added, this model becomes a cache of it rather than the source of truth. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
JQC — "Instructions" on scheduled inspections
|
||||
=============================================
|
||||
NO migration. NO schema change (web or iOS). NO API change.
|
||||
The field stays `notes` end-to-end: WTForms field name, ScheduledInspection.notes,
|
||||
scheduled_inspections.notes column, and the JSON key in
|
||||
GET /api/v1/scheduled-inspections. Only the wording changed.
|
||||
|
||||
--------------------------------------------------------------------
|
||||
WEB — repo: lt_janitorial_quality_control
|
||||
deploy root: /home/jqc/janitorial_qc/
|
||||
--------------------------------------------------------------------
|
||||
Overwrite:
|
||||
app/utils/forms.py (label 'Notes' -> 'Instructions')
|
||||
app/templates/scheduled_inspections/form.html (placeholder + visibility hint, rows 2->3)
|
||||
app/templates/inspections/execute.html (NEW: instructions panel)
|
||||
CLAUDE.md
|
||||
|
||||
Deploy (code only — no alembic step):
|
||||
cd /home/jqc/janitorial_qc
|
||||
git pull
|
||||
sudo systemctl restart janitorial_qc
|
||||
sudo systemctl status janitorial_qc --no-pager
|
||||
|
||||
--------------------------------------------------------------------
|
||||
iOS — repo: jqc_ios_app
|
||||
--------------------------------------------------------------------
|
||||
Overwrite:
|
||||
JanitorialQC/Models/LocalScheduledInspection.swift (computed `instructions`)
|
||||
JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift
|
||||
JanitorialQC/Views/Dashboard/StartInspectionView.swift
|
||||
JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
|
||||
JanitorialQC/Views/Dashboard/MyInspectionsView.swift
|
||||
JanitorialQC/Views/Dashboard/DashboardView.swift
|
||||
JanitorialQC/CLAUDE.md
|
||||
|
||||
No new files, none removed, no .xcodeproj edit
|
||||
(PBXFileSystemSynchronizedRootGroup picks changes up automatically).
|
||||
`instructions` is COMPUTED, not stored -> no SwiftData migration,
|
||||
no need to delete the app from the iPad.
|
||||
|
||||
Build: Product > Clean Build Folder (Shift-Cmd-K), then run.
|
||||
|
||||
--------------------------------------------------------------------
|
||||
VERIFY
|
||||
--------------------------------------------------------------------
|
||||
1. Web > Inspections > Scheduled > New Schedule
|
||||
- field reads "Instructions", hint below it
|
||||
- save some text
|
||||
2. Web, as the assigned inspector: Start that schedule
|
||||
- indigo "Instructions for this inspection" panel between header and form
|
||||
3. iPad, after a sync:
|
||||
- Dashboard SCHEDULED card: 1-line preview with an info icon
|
||||
- tap the row: "Instructions" section at the top of the start screen
|
||||
- Start Inspection: collapsible "Instructions" banner above the form,
|
||||
tap the header to collapse/expand
|
||||
4. Draft-resume regression: back out mid-inspection, reopen from the blue
|
||||
"Inspection in progress" banner -> instructions still shown
|
||||
5. Empty case: a schedule with no instructions shows NOTHING extra anywhere
|
||||
(blank and whitespace-only both normalise to nil)
|
||||
6. Submit regression: banner stays readable through the 2.5s success screen
|
||||
and the app does not crash when the schedule row is deleted
|
||||
+48
-1
@@ -1,5 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<!-- Background sync. BOTH keys are required and BOTH must live in this
|
||||
file, not in build settings.
|
||||
|
||||
INFOPLIST_KEY_* only merges Xcode's recognised key list and it merges
|
||||
STRING values. BGTaskSchedulerPermittedIdentifiers must be an ARRAY, so
|
||||
the old INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers build setting
|
||||
never produced a valid entry — App Store Connect rejected the upload
|
||||
with error 90771 as soon as UIBackgroundModes made the validator look.
|
||||
That build setting has been removed from project.pbxproj; do not put it
|
||||
back. Keep the identifier below in sync with the string passed to
|
||||
BGTaskScheduler.register/BGProcessingTaskRequest in JanitorialQCApp. -->
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.jqc.sync</string>
|
||||
</array>
|
||||
<!-- App Transport Security.
|
||||
|
||||
Both real servers (jqc / jqc1) are HTTPS and need no exception at all.
|
||||
This block exists ONLY so a developer can point ServerOption.primary at
|
||||
a local http backend (http://127.0.0.1:5055) while working on the API.
|
||||
|
||||
It is deliberately scoped to loopback. The previous version of this key
|
||||
was a blanket NSAllowsArbitraryLoads=true, which disables ATS for EVERY
|
||||
host — it turns off certificate and TLS validation for the production
|
||||
servers too, and App Store review requires a justification for it. Do
|
||||
not reintroduce the blanket flag; add a specific domain here instead. -->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>127.0.0.1</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -101,6 +101,8 @@ struct JanitorialQCApp: App {
|
||||
LocalInspection.self,
|
||||
LocalIssue.self,
|
||||
LocalScheduledInspection.self,
|
||||
LocalFollowUpRequest.self,
|
||||
LocalNotification.self,
|
||||
PendingPhoto.self,
|
||||
SyncQueueEntry.self,
|
||||
], isUndoEnabled: false) { result in
|
||||
@@ -128,7 +130,11 @@ struct JanitorialQCApp: App {
|
||||
}
|
||||
|
||||
private func registerBackgroundTasks() {
|
||||
BGTaskScheduler.shared.register(
|
||||
// register() returns false when the identifier is not declared in
|
||||
// BGTaskSchedulerPermittedIdentifiers or the `processing` background
|
||||
// mode is missing. Both are easy to lose in a project settings change
|
||||
// and the failure is otherwise completely silent, so it is logged.
|
||||
let registered = BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: "com.jqc.sync",
|
||||
using: nil
|
||||
) { task in
|
||||
@@ -138,16 +144,44 @@ struct JanitorialQCApp: App {
|
||||
}
|
||||
handleBackgroundSync(task: processingTask)
|
||||
}
|
||||
if !registered {
|
||||
print("[JQC] BGTaskScheduler.register FAILED for com.jqc.sync — "
|
||||
+ "check UIBackgroundModes contains 'processing' and "
|
||||
+ "BGTaskSchedulerPermittedIdentifiers contains com.jqc.sync")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBackgroundSync(task: BGProcessingTask) {
|
||||
// Re-arm first: if anything below throws or the task is killed, a
|
||||
// request is already queued for the next opportunity.
|
||||
scheduleBackgroundSync()
|
||||
let syncTask = Task {
|
||||
|
||||
let syncTask = Task { @MainActor in
|
||||
// A BGTaskScheduler launch does not render ContentView, so the
|
||||
// `.task { restoreSession() }` there never runs and
|
||||
// AuthManager.isAuthenticated is still false. triggerSync() guards
|
||||
// on it and would return having done nothing at all.
|
||||
if !AuthManager.shared.isAuthenticated {
|
||||
await AuthManager.shared.restoreSession()
|
||||
}
|
||||
|
||||
// The SwiftData container is created by the `.modelContainer`
|
||||
// scene modifier, so on a COLD background launch (process was
|
||||
// terminated, no scene connected) there is no context to drain.
|
||||
// The common case — app suspended but still resident — has one.
|
||||
guard SyncManager.shared.modelContext != nil else {
|
||||
print("[JQC] background sync skipped — no model context "
|
||||
+ "(cold launch, no scene)")
|
||||
return
|
||||
}
|
||||
|
||||
await SyncManager.shared.triggerSync()
|
||||
}
|
||||
|
||||
task.expirationHandler = {
|
||||
syncTask.cancel()
|
||||
}
|
||||
|
||||
Task {
|
||||
await syncTask.value
|
||||
task.setTaskCompleted(success: !syncTask.isCancelled)
|
||||
@@ -178,5 +212,12 @@ func scheduleBackgroundSync() {
|
||||
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
|
||||
request.requiresNetworkConnectivity = true
|
||||
request.requiresExternalPower = false
|
||||
try? BGTaskScheduler.shared.submit(request)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
// Was `try?`. Submitting without the `processing` background mode fails
|
||||
// with BGTaskSchedulerError.notPermitted, which is exactly how this
|
||||
// whole path stayed dead unnoticed. Never swallow it again.
|
||||
print("[JQC] BGTaskScheduler.submit failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Models/LocalFollowUpRequest.swift
|
||||
// ---------------------------------
|
||||
// SwiftData model for follow-up requests raised by a director/admin on the web
|
||||
// app against an inspection this inspector already completed.
|
||||
//
|
||||
// Read-only reference data pulled from the server
|
||||
// (GET /api/v1/inspections?follow_up_required=true) and refreshed by
|
||||
// SyncManager.pullFollowUpRequests() — never created or mutated on device.
|
||||
// Surfaced in the "Follow-up Requested" section on the Dashboard and My
|
||||
// Inspections. Tapping "Re-inspect" opens the normal new-inspection flow with
|
||||
// the facility + template preselected and `parentServerId` set, so the
|
||||
// submission lands as a linked re-inspection.
|
||||
//
|
||||
// WHY THIS EXISTS AS ITS OWN CACHE, rather than reading followUpRequired off
|
||||
// LocalInspection: the flag is set on the WEB, after the inspection has already
|
||||
// synced. By then the local copy either shows `status == "synced"` (filtered out
|
||||
// of every My Inspections @Query) or is not on this device at all — a reinstall,
|
||||
// a second iPad, or a follow-up raised weeks later all leave nothing to badge.
|
||||
// The request has to be pulled as its own work item to be actionable.
|
||||
//
|
||||
// Follows LocalScheduledInspection field-for-field: a `.unique` serverId WITHOUT
|
||||
// an inline default (a default on the unique key breaks @Model's PersistentModel
|
||||
// conformance) and a full init(from:)/update(from:) pair.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class LocalFollowUpRequest {
|
||||
|
||||
/// Server ID of the flagged (parent) Inspection — stable unique identity,
|
||||
/// and the value passed as `parentServerId` when the re-inspection starts.
|
||||
@Attribute(.unique) var serverId: Int
|
||||
|
||||
var facilityServerId: Int
|
||||
var facilityName: String
|
||||
var templateServerId: Int
|
||||
var templateName: String
|
||||
|
||||
/// Score the flagged inspection came back with, when it has one. Shown on
|
||||
/// the row: the reason a follow-up was raised is usually the low score.
|
||||
var overallScore: Double?
|
||||
|
||||
/// Raw server date string of the original inspection — sortable (ISO strings
|
||||
/// sort chronologically) and the source for the parsed `inspectedOn`.
|
||||
var inspectionDateString: String
|
||||
|
||||
/// The director's note explaining what the follow-up should address.
|
||||
/// Stored raw; read through `note` for the normalised form.
|
||||
var followUpNote: String?
|
||||
|
||||
/// Display name of the inspector this follow-up was handed to, when it was
|
||||
/// assigned to somebody other than whoever performed the original
|
||||
/// inspection (phase53). nil = it belongs to the original inspector.
|
||||
///
|
||||
/// Purely for display — the server only ever returns follow-ups this user
|
||||
/// owns, so nothing here decides what is shown. Optional so existing
|
||||
/// SwiftData stores migrate lightweight (rule 8).
|
||||
var assignedToName: String?
|
||||
|
||||
/// Set on device the moment a re-inspection of this request is submitted, so
|
||||
/// the FOLLOW-UP REQUESTED lists hide the row immediately — online or
|
||||
/// offline — without waiting for the round trip.
|
||||
///
|
||||
/// Purely a display flag, and the exact counterpart of
|
||||
/// `LocalScheduledInspection.fulfilledLocally` (see that file for the full
|
||||
/// rationale). The lifecycle stays server-driven: the server clears
|
||||
/// `follow_up_required` when the linked re-inspection arrives, the next pull
|
||||
/// stops returning the row, and it is deleted. If the submission never
|
||||
/// lands, the server still reports the flag and the row comes back —
|
||||
/// self-healing.
|
||||
///
|
||||
/// Non-optional with an inline default, so SwiftData migrates lightweight
|
||||
/// (CLAUDE.md rule 12): existing rows read as `false`, no app reinstall.
|
||||
var fulfilledLocally: Bool = false
|
||||
|
||||
/// The flagged inspection's answers, JSON-encoded `[String: String]`, cached
|
||||
/// so the re-inspection can pre-fill from them.
|
||||
///
|
||||
/// WHY CACHED HERE rather than read from the parent at start time: the
|
||||
/// parent `LocalInspection` is usually **not on this device**. A follow-up is
|
||||
/// raised on the web after the inspection already synced, so by then the
|
||||
/// local copy may be long gone (reinstall, a second iPad, a follow-up raised
|
||||
/// weeks later) — the same reason this model exists at all. The prefill in
|
||||
/// `StartInspectionView` matched on a local parent only, found nothing, and
|
||||
/// silently produced a blank form, unlike the web.
|
||||
///
|
||||
/// Free to carry: `GET /api/v1/inspections` already returns `form_data` on
|
||||
/// every row (`_inspection_payload`), so this costs no extra request and the
|
||||
/// prefill works offline — the values are captured at pull time.
|
||||
///
|
||||
/// Non-optional with an inline default so SwiftData migrates lightweight
|
||||
/// (CLAUDE.md rule 12); `"{}"` decodes to an empty dictionary.
|
||||
var parentFormDataJSON: String = "{}"
|
||||
|
||||
/// Last time this row was refreshed from the server pull.
|
||||
var updatedAt: Date
|
||||
|
||||
/// The follow-up note, normalised: nil for nil, empty, or whitespace-only
|
||||
/// text so callers can guard with a single `if let` instead of repeating the
|
||||
/// emptiness check. Computed, not stored — no SwiftData schema change.
|
||||
var note: String? {
|
||||
guard let t = followUpNote?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!t.isEmpty
|
||||
else { return nil }
|
||||
return t
|
||||
}
|
||||
|
||||
/// Parsed inspection date for display. Computed properties are not persisted
|
||||
/// by SwiftData; sort on `inspectionDateString` (not this) in @Query.
|
||||
///
|
||||
/// Parses the leading `yyyy-MM-dd` rather than the whole timestamp on
|
||||
/// purpose. `SyncManager.isoFormatter` is fixed at `yyyy-MM-dd'T'HH:mm:ss`
|
||||
/// and returns nil the moment the server includes fractional seconds —
|
||||
/// which it does whenever the column carries microseconds (SQLite keeps
|
||||
/// them; MySQL truncates by default), so the same field parses on one
|
||||
/// deployment and not another. Only the day is displayed here, so taking the
|
||||
/// date prefix sidesteps the whole variation.
|
||||
var inspectedOn: Date? {
|
||||
guard inspectionDateString.count >= 10 else { return nil }
|
||||
return Self.dateOnlyFormatter.date(from: String(inspectionDateString.prefix(10)))
|
||||
}
|
||||
|
||||
private static let dateOnlyFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
init(from api: APIInspectionSummary) {
|
||||
self.serverId = api.id
|
||||
self.facilityServerId = api.facilityId
|
||||
self.facilityName = api.facilityName
|
||||
self.templateServerId = api.templateId
|
||||
self.templateName = api.templateName
|
||||
self.overallScore = api.overallScore
|
||||
self.inspectionDateString = api.inspectionDate ?? ""
|
||||
self.followUpNote = api.followUpNote
|
||||
self.assignedToName = api.followUpAssignedToName
|
||||
self.fulfilledLocally = false
|
||||
self.parentFormDataJSON = Self.encode(api)
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
|
||||
/// Serialise the parent's answers for storage.
|
||||
///
|
||||
/// Uses the raw `formDataRaw` values rather than the flattened `formValues`
|
||||
/// so the structure survives the round trip exactly as the web's prefill
|
||||
/// copies it — an array field stays an array instead of being joined into
|
||||
/// `"a, b"`, which would be written back as a single bogus string.
|
||||
/// `JSONValue.anyValue` yields only JSON-serialisable types (NSNull for
|
||||
/// null), so `JSONSerialization` accepts the result.
|
||||
///
|
||||
/// Returns `"{}"` on failure so the property is always valid JSON and
|
||||
/// `parentFormData` can decode it without a special case.
|
||||
private static func encode(_ api: APIInspectionSummary) -> String {
|
||||
let raw = api.formDataRaw.mapValues(\.anyValue)
|
||||
guard JSONSerialization.isValidJSONObject(raw),
|
||||
let data = try? JSONSerialization.data(withJSONObject: raw),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return "{}" }
|
||||
return str
|
||||
}
|
||||
|
||||
/// The flagged inspection's answers, decoded for prefill. Typed `[String: Any]`
|
||||
/// to match `LocalInspection.formData`, which is what the value is copied into.
|
||||
var parentFormData: [String: Any] {
|
||||
guard let data = parentFormDataJSON.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return [:] }
|
||||
return dict
|
||||
}
|
||||
|
||||
func update(from api: APIInspectionSummary) {
|
||||
self.facilityServerId = api.facilityId
|
||||
self.facilityName = api.facilityName
|
||||
self.templateServerId = api.templateId
|
||||
self.templateName = api.templateName
|
||||
self.overallScore = api.overallScore
|
||||
self.inspectionDateString = api.inspectionDate ?? ""
|
||||
self.followUpNote = api.followUpNote
|
||||
self.assignedToName = api.followUpAssignedToName
|
||||
self.parentFormDataJSON = Self.encode(api)
|
||||
// The server is authoritative. Being returned by the pull at all means
|
||||
// the follow-up is still outstanding, so any local "just did it" flag is
|
||||
// stale by definition — a re-inspection that never synced, or one the
|
||||
// server rejected.
|
||||
self.fulfilledLocally = false
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,16 @@ final class LocalInspection {
|
||||
/// followUpRequired badge without relying on parentServerId being non-nil.
|
||||
var parentLocalId: String?
|
||||
|
||||
// ── Scheduled inspection link ──────────────────────────────────────────
|
||||
/// Server ID of the ScheduledInspection this inspection was started from,
|
||||
/// set when the inspector taps Start on a scheduled row. Sent as
|
||||
/// `scheduled_inspection_id` on submit so the server can fulfil the
|
||||
/// schedule (deactivate a one-time / roll a recurring one forward) and
|
||||
/// flag the inspection as "Scheduled" in the web list. Nil for ad-hoc work.
|
||||
///
|
||||
/// Declared with an inline default so existing stores migrate lightweight.
|
||||
var scheduledInspectionServerId: Int? = nil
|
||||
|
||||
// ── GPS (captured at submit time via CoreLocation) ─────────────────────
|
||||
/// Device latitude at the moment the inspector tapped Submit. Nil if
|
||||
/// location permission was denied or a fix could not be obtained in time.
|
||||
@@ -89,6 +99,7 @@ final class LocalInspection {
|
||||
self.followUpNote = nil
|
||||
self.parentServerId = nil
|
||||
self.parentLocalId = nil
|
||||
self.scheduledInspectionServerId = nil
|
||||
self.submitLatitude = nil
|
||||
self.submitLongitude = nil
|
||||
self.pendingPhotos = []
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Models/LocalNotification.swift
|
||||
// ------------------------------
|
||||
// SwiftData model backing the in-app notification inbox.
|
||||
//
|
||||
// WHY THIS IS A LOCAL STORE, unlike every other server-backed list in the app.
|
||||
// `GET /api/v1/notifications` is a POLLER, not an inbox: it filters to
|
||||
// `is_read = False` and never sends the flag at all
|
||||
// (app/api/notifications.py :: list_notifications). So the instant a
|
||||
// notification is marked read the server stops returning it — there is no
|
||||
// response the iPad could render as "read", and before this every row in the
|
||||
// list was unread by definition, which is why they all looked identical.
|
||||
// Keeping our own copy is the only way to show read and unread side by side.
|
||||
//
|
||||
// The web already has a real inbox (routes/notifications.py :: index, with an
|
||||
// all / unread / read filter and paging). If a mobile equivalent is ever added,
|
||||
// this model should become a cache of it rather than the source of truth — see
|
||||
// rule 92.
|
||||
//
|
||||
// Scope: notifications are per-user, so this is purged on an identity change
|
||||
// like everything else (rule 88).
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class LocalNotification {
|
||||
|
||||
/// Server notification id — stable identity, and the value
|
||||
/// `PATCH /api/v1/notifications/mark-read` takes.
|
||||
/// No inline default: a `.unique` key must not carry one (rule 63).
|
||||
@Attribute(.unique) var serverId: Int
|
||||
|
||||
var title: String
|
||||
var body: String
|
||||
|
||||
/// e.g. `issue_assigned`, `sla_alert`, `scheduled_inspection`. Nil for rows
|
||||
/// created before the server's phase17 migration added the column.
|
||||
var eventType: String?
|
||||
|
||||
/// Set when the notification refers to an issue — drives "View Issue".
|
||||
var issueId: Int?
|
||||
|
||||
/// Server `created_at`, parsed. Falls back to receipt time when the string
|
||||
/// cannot be parsed so ordering never collapses to a single instant.
|
||||
var createdAt: Date
|
||||
|
||||
/// When THIS device first saw it. Distinct from `createdAt`: a notification
|
||||
/// raised while the iPad was offline arrives late but keeps its real time.
|
||||
var receivedAt: Date
|
||||
|
||||
// ── Read state ────────────────────────────────────────────────────────
|
||||
// Set optimistically on tap / Mark All so the UI responds offline, then
|
||||
// pushed to the server. Read state is shared with the web (rule 92).
|
||||
|
||||
var isRead: Bool = false
|
||||
var readAt: Date?
|
||||
|
||||
/// True while this row's read state has not yet reached the server.
|
||||
/// Drained by `SyncManager.pushNotificationReadState()`.
|
||||
///
|
||||
/// Non-optional with an inline default so SwiftData migrates lightweight
|
||||
/// (rule 8).
|
||||
var readSyncPending: Bool = false
|
||||
|
||||
init(from api: APINotification) {
|
||||
self.serverId = api.id
|
||||
self.title = api.title
|
||||
self.body = api.body
|
||||
self.eventType = api.eventType
|
||||
self.issueId = api.issueId
|
||||
self.createdAt = SyncManager.isoFormatter.date(from: api.createdAt) ?? Date()
|
||||
self.receivedAt = Date()
|
||||
self.isRead = false
|
||||
self.readAt = nil
|
||||
self.readSyncPending = false
|
||||
}
|
||||
|
||||
/// Refresh the mutable text from a later poll.
|
||||
///
|
||||
/// Deliberately does NOT touch `isRead`. The endpoint only ever returns
|
||||
/// UNREAD rows, so being returned again carries no information about read
|
||||
/// state — it usually just means our mark-read has not been pushed yet.
|
||||
/// Clobbering it here would make a notification the inspector just opened
|
||||
/// pop straight back to unread.
|
||||
func update(from api: APINotification) {
|
||||
self.title = api.title
|
||||
self.body = api.body
|
||||
self.eventType = api.eventType
|
||||
self.issueId = api.issueId
|
||||
if let parsed = SyncManager.isoFormatter.date(from: api.createdAt) {
|
||||
self.createdAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark read locally and queue the server push. Idempotent.
|
||||
func markRead() {
|
||||
guard !isRead else { return }
|
||||
isRead = true
|
||||
readAt = Date()
|
||||
readSyncPending = true
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,55 @@ final class LocalScheduledInspection {
|
||||
var isOverdue: Bool
|
||||
var notes: String?
|
||||
|
||||
/// Server ID of the inspection this schedule is a planned follow-up of
|
||||
/// (phase45). Carried onto the `LocalInspection` as `parentServerId` when
|
||||
/// the inspector starts it, so the run lands as a linked re-inspection
|
||||
/// rather than an ordinary scheduled one.
|
||||
///
|
||||
/// Optional, so SwiftData migrates lightweight without a plan (rule 46);
|
||||
/// nil for an ordinary schedule and for every row pulled from a
|
||||
/// pre-phase45 server.
|
||||
var parentInspectionServerId: Int?
|
||||
|
||||
/// Set on device the moment an inspection fulfilling this schedule is
|
||||
/// submitted, so the SCHEDULED lists hide the row immediately — online or
|
||||
/// offline — without waiting for the round trip.
|
||||
///
|
||||
/// Purely a display flag. The schedule lifecycle stays server-driven: the
|
||||
/// next successful pull calls `update(from:)`, which clears this and writes
|
||||
/// the authoritative `next_due_date`. If the submission never lands, the
|
||||
/// server still reports the schedule as due and the row simply comes back —
|
||||
/// self-healing.
|
||||
///
|
||||
/// This exists instead of deleting the row. A recurring schedule is
|
||||
/// returned by the server *again* after it rolls forward, so deleting meant
|
||||
/// delete-then-reinsert against an `@Attribute(.unique)` key on every
|
||||
/// completion, and the reinserted row did not reliably carry the new due
|
||||
/// date. Flagging keeps `update(from:)` — the path that has always worked —
|
||||
/// as the only way a cached row's dates ever change.
|
||||
///
|
||||
/// Non-optional with an inline default, so SwiftData migrates lightweight
|
||||
/// (CLAUDE.md rule 12): existing rows read as `false`, no app reinstall.
|
||||
var fulfilledLocally: Bool = false
|
||||
|
||||
/// Manager-authored instructions for this occurrence, normalised.
|
||||
///
|
||||
/// The wire field, the server model attribute and the DB column are all
|
||||
/// still `notes` — only the user-facing wording changed to "Instructions"
|
||||
/// (web form label + both iPad surfaces). Renaming the stored property would
|
||||
/// break `init(from:)`/`update(from:)` against `APIScheduledInspection.notes`
|
||||
/// for no benefit, so the rename lives here.
|
||||
///
|
||||
/// Returns nil for nil, empty, or whitespace-only text so callers can guard
|
||||
/// with a single `if let` instead of repeating the emptiness check.
|
||||
/// Computed, not stored — no SwiftData schema change.
|
||||
var instructions: String? {
|
||||
guard let t = notes?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!t.isEmpty
|
||||
else { return nil }
|
||||
return t
|
||||
}
|
||||
|
||||
/// Last time this row was refreshed from the server pull.
|
||||
var updatedAt: Date
|
||||
|
||||
@@ -66,6 +115,8 @@ final class LocalScheduledInspection {
|
||||
self.dueDateString = api.nextDueDate ?? ""
|
||||
self.isOverdue = api.isOverdue
|
||||
self.notes = api.notes
|
||||
self.parentInspectionServerId = api.parentInspectionId
|
||||
self.fulfilledLocally = false
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
|
||||
@@ -80,6 +131,11 @@ final class LocalScheduledInspection {
|
||||
self.dueDateString = api.nextDueDate ?? ""
|
||||
self.isOverdue = api.isOverdue
|
||||
self.notes = api.notes
|
||||
self.parentInspectionServerId = api.parentInspectionId
|
||||
// The server is authoritative. Being returned by the pull at all means
|
||||
// this schedule is live again — for a recurring one, on its NEXT
|
||||
// occurrence — so any local "just did it" flag is stale by definition.
|
||||
self.fulfilledLocally = false
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,24 +22,66 @@ final class PendingPhoto {
|
||||
/// Populated after successful upload
|
||||
var serverPath: String?
|
||||
/// "pending" | "uploaded" | "failed"
|
||||
///
|
||||
/// "failed" is TERMINAL: it means every upload attempt was used up, and it
|
||||
/// is what lets processInspectionQueue stop waiting and submit without this
|
||||
/// photo. A *transient* error must therefore leave the row "pending" — see
|
||||
/// `uploadRetryCount`. Marking "failed" on the first error is what turned a
|
||||
/// single dropped connection into a permanently lost evidence photo.
|
||||
var uploadStatus: String
|
||||
/// Consecutive failed upload attempts. The row stays "pending" — and so
|
||||
/// keeps blocking its parent's submission — until this reaches
|
||||
/// `SyncManager.maxPhotoUploadAttempts`.
|
||||
///
|
||||
/// Non-optional with an inline default so SwiftData migrates lightweight
|
||||
/// (CLAUDE.md rule 8): rows in existing stores read as 0.
|
||||
var uploadRetryCount: Int = 0
|
||||
var createdAt: Date
|
||||
|
||||
/// Why the last upload attempt failed, for diagnosis.
|
||||
///
|
||||
/// Nothing recorded this before: a photo could burn all five attempts and
|
||||
/// cost an inspection its evidence with no trace anywhere of the reason —
|
||||
/// the device showed only "failed" and the server logs only SUCCESSFUL
|
||||
/// uploads. Cleared on a successful upload.
|
||||
///
|
||||
/// Optional so existing SwiftData stores migrate lightweight (rule 8).
|
||||
var lastUploadError: String?
|
||||
|
||||
// ── Capture metadata (sent to the server, burned into the photo) ───────
|
||||
// Recorded when the shutter fires, NOT when the upload runs — the app is
|
||||
// offline-first, so a photo taken at 09:14 may not sync until 16:00 and
|
||||
// the stamp must show 09:14. EXIF cannot serve as a fallback here: the
|
||||
// photo is re-encoded via jpegData() on save, which strips every tag.
|
||||
// Optional so existing SwiftData stores migrate without a schema step.
|
||||
var capturedAt: Date?
|
||||
var captureLatitude: Double?
|
||||
var captureLongitude: Double?
|
||||
|
||||
var inspection: LocalInspection?
|
||||
|
||||
init(
|
||||
localFilePath: String,
|
||||
entityType: String,
|
||||
entityLocalId: String,
|
||||
fieldId: String? = nil
|
||||
fieldId: String? = nil,
|
||||
capturedAt: Date? = nil,
|
||||
captureLatitude: Double? = nil,
|
||||
captureLongitude: Double? = nil
|
||||
) {
|
||||
self.localId = UUID().uuidString
|
||||
self.localFilePath = localFilePath
|
||||
self.entityType = entityType
|
||||
self.entityLocalId = entityLocalId
|
||||
self.fieldId = fieldId
|
||||
self.serverPath = nil
|
||||
self.uploadStatus = "pending"
|
||||
self.createdAt = Date()
|
||||
self.localId = UUID().uuidString
|
||||
self.localFilePath = localFilePath
|
||||
self.entityType = entityType
|
||||
self.entityLocalId = entityLocalId
|
||||
self.fieldId = fieldId
|
||||
self.serverPath = nil
|
||||
self.uploadStatus = "pending"
|
||||
self.uploadRetryCount = 0
|
||||
self.createdAt = Date()
|
||||
// Fall back to now when the caller has no recorded capture moment —
|
||||
// still far better than the server's upload-time default.
|
||||
self.capturedAt = capturedAt ?? Date()
|
||||
self.captureLatitude = captureLatitude
|
||||
self.captureLongitude = captureLongitude
|
||||
}
|
||||
}
|
||||
|
||||
+757
-103
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,59 @@ nonisolated enum ServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session scope
|
||||
|
||||
/// The (server, user) pair the local database is currently scoped to.
|
||||
///
|
||||
/// Almost every local id is meaningful only within one such pair. `serverId`
|
||||
/// values differ between jqc and jqc1; facility/template ids and inspector
|
||||
/// facility scope differ per user. So when either half changes, the cached data
|
||||
/// is not merely stale — it is *wrong*, and silently belongs to someone else.
|
||||
///
|
||||
/// Two concrete failures this exists to close:
|
||||
/// • A different inspector signing in on the same iPad inherited the previous
|
||||
/// one's issues. `pullAssignedIssues`' reconciliation only deletes rows with
|
||||
/// `inspectionLocalId == ""`, so device-authored synced issues survived
|
||||
/// indefinitely and were simply visible to whoever logged in next.
|
||||
/// • A server switch cleared `LocalIssue` alone, leaving `LocalInspection`
|
||||
/// rows carrying facility/template ids from the other server, ready to be
|
||||
/// submitted against it.
|
||||
///
|
||||
/// Deliberately in UserDefaults, NOT the Keychain: `KeychainHelper.clearAll()`
|
||||
/// runs on logout, and this marker has to OUTLIVE a logout to be able to notice
|
||||
/// that the next login is a different person. It is not a secret.
|
||||
nonisolated enum SessionScope {
|
||||
|
||||
struct Scope: Equatable, Sendable {
|
||||
let server: String
|
||||
let userId: Int
|
||||
}
|
||||
|
||||
private static let userKey = "com.jqc.sessionScope.userId"
|
||||
private static let serverKey = "com.jqc.sessionScope.server"
|
||||
|
||||
/// The recorded scope, or nil when none has ever been written.
|
||||
static var stored: Scope? {
|
||||
let d = UserDefaults.standard
|
||||
guard let server = d.string(forKey: serverKey),
|
||||
d.object(forKey: userKey) != nil
|
||||
else { return nil }
|
||||
return Scope(server: server, userId: d.integer(forKey: userKey))
|
||||
}
|
||||
|
||||
static func record(userId: Int, server: String = ServerConfig.current) {
|
||||
UserDefaults.standard.set(userId, forKey: userKey)
|
||||
UserDefaults.standard.set(server, forKey: serverKey)
|
||||
}
|
||||
|
||||
/// Forget the scope entirely — used on a server switch, where the next
|
||||
/// login is guaranteed to be against a different data set.
|
||||
static func clear() {
|
||||
UserDefaults.standard.removeObject(forKey: userKey)
|
||||
UserDefaults.standard.removeObject(forKey: serverKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App-wide constants
|
||||
|
||||
// Explicitly not @MainActor — these constants must be readable from
|
||||
@@ -76,4 +129,50 @@ nonisolated enum Constants {
|
||||
}
|
||||
|
||||
static let tokenRefreshBufferMinutes: Double = 5
|
||||
|
||||
// MARK: - Roles
|
||||
//
|
||||
// The server's role strings, and the ONE definition of which of them the
|
||||
// app treats as an inspector. This mirrors `User.INSPECTOR_ROLES` /
|
||||
// `User.is_inspector` in the Flask app (see server rule 87).
|
||||
//
|
||||
// Why this exists: `external_inspector` (displayed as "Customer Inspector"
|
||||
// — an inspector employed by the customer) has exactly the same powers as
|
||||
// our own `inspector`, and the API scopes it identically. The views here
|
||||
// were hand-written as `role == "admin" || role == "director" || role ==
|
||||
// "inspector"`, so every one of them silently locked customer inspectors
|
||||
// out of actions the SERVER was perfectly willing to accept — Update
|
||||
// Status, Handled By, Start Follow-up. The failure is invisible: no error,
|
||||
// the control simply isn't drawn.
|
||||
//
|
||||
// Add a role in ONE place here; never re-write the literals in a view.
|
||||
nonisolated enum Roles {
|
||||
static let admin = "admin"
|
||||
static let director = "director"
|
||||
static let projectManager = "project_manager"
|
||||
static let auditor = "auditor"
|
||||
static let inspector = "inspector"
|
||||
/// "Customer Inspector" — employed by the customer, same powers as
|
||||
/// `inspector`, scoped to their assigned contracts.
|
||||
static let externalInspector = "external_inspector"
|
||||
|
||||
/// Both inspector roles. Test membership of this, never `== inspector`.
|
||||
static let inspectorRoles: Set<String> = [inspector, externalInspector]
|
||||
|
||||
/// May change an issue's status / handler, and start a follow-up.
|
||||
/// Matches what the API actually accepts for these actions; the server
|
||||
/// remains the authority and additionally enforces facility scope.
|
||||
///
|
||||
/// Built from `inspectorRoles` (already a Set) rather than from an
|
||||
/// array literal: `[a, b, c].union(...)` does not compile, because the
|
||||
/// literal is typed as Array before `.union` is looked up, and Array
|
||||
/// has no such member — the annotation on the left does not reach back
|
||||
/// into the receiver.
|
||||
static let issueActors: Set<String> =
|
||||
inspectorRoles.union([admin, director, projectManager])
|
||||
|
||||
static func isInspector(_ role: String) -> Bool {
|
||||
inspectorRoles.contains(role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,8 @@ enum InspectionPDFGenerator {
|
||||
group.addTask {
|
||||
if val.hasPrefix("local://") {
|
||||
let path = String(val.dropFirst("local://".count))
|
||||
guard let raw = UIImage(contentsOfFile: path) else { return (fid, nil) }
|
||||
guard let live = PhotoStore.resolve(path),
|
||||
let raw = UIImage(contentsOfFile: live) else { return (fid, nil) }
|
||||
return (fid, compress(raw))
|
||||
} else if val.hasPrefix("uploads/") {
|
||||
guard let url = URL(string: "\(ServerConfig.current)/static/\(val)")
|
||||
|
||||
@@ -157,7 +157,7 @@ enum IssuePDFGenerator {
|
||||
return results
|
||||
}
|
||||
} else if !localPaths.isEmpty {
|
||||
raw = localPaths.compactMap { UIImage(contentsOfFile: $0) }
|
||||
raw = localPaths.compactMap { PhotoStore.resolve($0).flatMap(UIImage.init(contentsOfFile:)) }
|
||||
}
|
||||
|
||||
return raw.compactMap { compress($0) }
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Utils/PhotoCapture.swift
|
||||
// ------------------------
|
||||
// Capture-time metadata for evidence photos.
|
||||
//
|
||||
// The server burns a timestamp + GPS overlay into every photo uploaded through
|
||||
// POST /api/v1/photos/upload (see photo_stamp.py in the Flask app). It resolves
|
||||
// that metadata from the client form fields first, then the image's own EXIF,
|
||||
// then — last resort — server receipt time.
|
||||
//
|
||||
// EXIF is NOT a usable fallback for this app: savePhotoToDisk() re-encodes each
|
||||
// UIImage via jpegData(compressionQuality:), which strips every EXIF tag. So
|
||||
// these client fields are the ONLY source of true capture time and location.
|
||||
// Without them, a photo taken offline at 09:14 and synced at 16:00 is stamped
|
||||
// 16:00 — the wrong time, on evidence.
|
||||
//
|
||||
// Hence the flow: record the moment + fix AT CAPTURE, carry them through
|
||||
// CapturedPhoto -> PendingPhoto -> the upload request.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - CapturedPhoto
|
||||
|
||||
/// A photo the user just took or picked, together with the metadata recorded
|
||||
/// at that instant. Property names match the tuple this replaced
|
||||
/// (`image`, `path`), so existing call sites keep compiling.
|
||||
struct CapturedPhoto {
|
||||
|
||||
let image: UIImage
|
||||
let path: String
|
||||
let capturedAt: Date
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
|
||||
/// Stamps "now" plus the freshest GPS fix available at the moment of capture.
|
||||
init(image: UIImage, path: String) {
|
||||
let fix = PhotoLocationProvider.shared.lastLocation
|
||||
self.image = image
|
||||
self.path = path
|
||||
self.capturedAt = Date()
|
||||
self.latitude = fix?.coordinate.latitude
|
||||
self.longitude = fix?.coordinate.longitude
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PhotoLocationProvider
|
||||
|
||||
/// Shared, always-warm location source for photo capture.
|
||||
///
|
||||
/// One long-lived CLLocationManager: views call `start()` in `onAppear` so a
|
||||
/// fix already exists the instant the shutter fires. Kept separate from
|
||||
/// ExecuteInspectionView's `InspectionLocationManager` (phase 25 submit GPS),
|
||||
/// which is per-view and would start cold on every photo screen.
|
||||
final class PhotoLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
static let shared = PhotoLocationProvider()
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
|
||||
/// Most recent fix, or nil if unavailable/denied.
|
||||
private(set) var lastLocation: CLLocation?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
manager.distanceFilter = 10
|
||||
}
|
||||
|
||||
/// Request permission if needed and begin updating. Safe to call repeatedly.
|
||||
/// Requires NSLocationWhenInUseUsageDescription in the target's Info settings.
|
||||
func start() {
|
||||
switch manager.authorizationStatus {
|
||||
case .notDetermined:
|
||||
manager.requestWhenInUseAuthorization()
|
||||
// locationManagerDidChangeAuthorization starts updates once granted.
|
||||
case .authorizedWhenInUse, .authorizedAlways:
|
||||
manager.startUpdatingLocation()
|
||||
default:
|
||||
break // denied/restricted — lat/lng stay nil; the timestamp is still stamped
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
manager.stopUpdatingLocation()
|
||||
}
|
||||
|
||||
// CLLocationManagerDelegate
|
||||
|
||||
func locationManager(_ m: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
if let loc = locations.last { lastLocation = loc }
|
||||
}
|
||||
|
||||
func locationManager(_ m: CLLocationManager, didFailWithError error: Error) {
|
||||
// Non-fatal — photos still upload, just without coordinates.
|
||||
print("[JQC] Photo location fix failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
|
||||
if m.authorizationStatus == .authorizedWhenInUse ||
|
||||
m.authorizationStatus == .authorizedAlways {
|
||||
manager.startUpdatingLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wire format
|
||||
|
||||
// Explicitly not @MainActor. SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor makes
|
||||
// every type MainActor-isolated by default, but this formatter is read from
|
||||
// `actor APIClient` while building the photo-upload multipart body — a
|
||||
// nonisolated context. Without this the reference warns ("Main actor-isolated
|
||||
// static property 'iso8601' can not be referenced from a nonisolated context")
|
||||
// and becomes a hard error under the Swift 6 language mode. Same treatment as
|
||||
// `Constants` / `ServerConfig` and `SyncManager.isoFormatter` (rule 35).
|
||||
nonisolated enum PhotoCaptureFormat {
|
||||
|
||||
/// ISO-8601 with an explicit offset — the format the server's
|
||||
/// `_parse_client_datetime()` expects. A naive string with no offset would
|
||||
/// be read as Eastern wall time, so the offset must always be present.
|
||||
///
|
||||
/// Created once and reused: formatter init is expensive, and this runs per
|
||||
/// photo upload. Foundation formatters are thread-safe for formatting, so
|
||||
/// sharing one across isolation domains is safe.
|
||||
static let iso8601: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Utils/PhotoStore.swift
|
||||
// ----------------------
|
||||
// Resolves a stored photo path against the CURRENT app container.
|
||||
//
|
||||
// ── The bug this exists to fix ───────────────────────────────────────────────
|
||||
// Every photo path in the database is ABSOLUTE and embeds the app-container
|
||||
// UUID:
|
||||
//
|
||||
// /var/mobile/Containers/Data/Application/<CONTAINER-UUID>/Documents/JQC/Photos/<file>.jpg
|
||||
//
|
||||
// iOS assigns a NEW container UUID on every app update, reinstall and restore.
|
||||
// The Documents directory survives — the files are all still there — but every
|
||||
// stored path is instantly dead.
|
||||
//
|
||||
// Nothing accounted for that. `uploadPhoto` does
|
||||
// `FileManager.default.contents(atPath:)`, which returns nil, so the upload
|
||||
// throws "Could not read photo" and can NEVER succeed no matter how often it is
|
||||
// retried: the path names a container that no longer exists. Any photo still
|
||||
// awaiting upload when the app updates is therefore stranded permanently, and
|
||||
// its inspection is submitted with the field blank.
|
||||
//
|
||||
// That is what happened to inspection #887 (9 photos, Aug 2026): the diagnostic
|
||||
// reported all nine as `upload: pending` with 0 failed rows and
|
||||
// "Stored path stale (container changed) — file found by name, recoverable".
|
||||
//
|
||||
// ── Why basename lookup is safe ──────────────────────────────────────────────
|
||||
// Filenames are `UUID().uuidString + ".jpg"`, generated per save at every call
|
||||
// site, so a basename identifies a file unambiguously. This is the same
|
||||
// resolution PhotoDiagnosticView already performs to report recoverability —
|
||||
// it just was not wired into the code paths that actually read the files.
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated enum PhotoStore {
|
||||
|
||||
/// Sub-directories of Documents/ the app has ever written photos to.
|
||||
/// `JQCPhotos` is not written by any current code path but is checked so a
|
||||
/// file left by an older build is still found.
|
||||
private static let subdirectories = ["JQC/Photos", "JQC/ResultPhotos", "JQCPhotos"]
|
||||
|
||||
/// Documents/ in the CURRENT container. Recomputed per call — caching it
|
||||
/// across an app update would reintroduce the very staleness this fixes.
|
||||
private static var documentsDirectory: URL? {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
}
|
||||
|
||||
/// Absolute URL for a photo directory in the current container.
|
||||
static func directory(_ subdirectory: String = "JQC/Photos") -> URL? {
|
||||
documentsDirectory?.appendingPathComponent(subdirectory, isDirectory: true)
|
||||
}
|
||||
|
||||
/// The live absolute path for a stored photo path, or nil if the file is
|
||||
/// genuinely gone.
|
||||
///
|
||||
/// Returns `storedPath` unchanged when it still resolves (the common case,
|
||||
/// costing one `fileExists` check). Otherwise re-resolves by filename under
|
||||
/// the current container.
|
||||
static func resolve(_ storedPath: String) -> String? {
|
||||
guard !storedPath.isEmpty else { return nil }
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: storedPath) { return storedPath }
|
||||
|
||||
let name = URL(fileURLWithPath: storedPath).lastPathComponent
|
||||
guard !name.isEmpty else { return nil }
|
||||
for sub in subdirectories {
|
||||
guard let candidate = directory(sub)?.appendingPathComponent(name) else { continue }
|
||||
if fm.fileExists(atPath: candidate.path) { return candidate.path }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Read a photo's bytes, healing a stale container path first.
|
||||
static func contents(at storedPath: String) -> Data? {
|
||||
guard let live = resolve(storedPath) else { return nil }
|
||||
return FileManager.default.contents(atPath: live)
|
||||
}
|
||||
|
||||
/// Delete a photo, whichever container its path was written in.
|
||||
@discardableResult
|
||||
static func remove(at storedPath: String) -> Bool {
|
||||
guard let live = resolve(storedPath) else { return false }
|
||||
return (try? FileManager.default.removeItem(atPath: live)) != nil
|
||||
}
|
||||
|
||||
/// Filename component, which is the only stable part of a stored path.
|
||||
/// Use this — never the full path — to compare a database reference against
|
||||
/// a file on disk (see `SyncManager.cleanupOrphanedPhotos`).
|
||||
static func filename(of storedPath: String) -> String {
|
||||
URL(fileURLWithPath: storedPath).lastPathComponent
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ struct DashboardView: View {
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
@Query(
|
||||
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
||||
@@ -41,6 +42,19 @@ struct DashboardView: View {
|
||||
order: .reverse
|
||||
) private var myInspections: [LocalInspection]
|
||||
|
||||
/// Outstanding follow-up requests, counted into the My Inspections badge so
|
||||
/// the inspector sees there is work waiting from any tab — the same reason
|
||||
/// in-progress inspections are counted there.
|
||||
@Query private var followUpRequests: [LocalFollowUpRequest]
|
||||
|
||||
/// Badge count for My Inspections: in-progress work plus outstanding
|
||||
/// follow-ups. `fulfilledLocally` rows are excluded in Swift, not in the
|
||||
/// @Query predicate (CLAUDE.md rule 3), so the badge drops the instant a
|
||||
/// re-inspection is submitted.
|
||||
private var myInspectionsBadgeCount: Int {
|
||||
myInspections.count + followUpRequests.filter { !$0.fulfilledLocally }.count
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -71,141 +85,26 @@ struct DashboardView: View {
|
||||
selectedTab = tab
|
||||
}
|
||||
|
||||
/// Sidebar order — single source of truth for both the regular-width
|
||||
/// sidebar and the compact-width root list.
|
||||
private let sidebarTabs: [SidebarTab] = [
|
||||
.dashboard, .myInspections, .issues, .facilities,
|
||||
.pendingSync, .history, .notifications, .settings,
|
||||
]
|
||||
|
||||
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() }
|
||||
Group {
|
||||
// On compact width (iPhone) a NavigationSplitView collapses to show
|
||||
// ONLY the sidebar: its `detail:` column is never presented, because
|
||||
// nothing pushes it. The rows here are plain Buttons driving @State
|
||||
// (rule 2 forbids a `selection:` binding), and a state change alone
|
||||
// cannot push the detail column — so every destination was
|
||||
// unreachable on iPhone. Compact width therefore gets a real
|
||||
// NavigationStack whose rows are NavigationLinks.
|
||||
if hSizeClass == .compact {
|
||||
compactBody
|
||||
} else {
|
||||
regularBody
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -223,6 +122,183 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regular width (iPad) — unchanged two-column split view ────────────
|
||||
|
||||
private var regularBody: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(sidebarTabs, id: \.self) { tab in
|
||||
Button {
|
||||
selectTab(tab)
|
||||
// No longer zeroes the badge on tap: opening the inbox
|
||||
// is not reading it. The count now tracks genuinely
|
||||
// unread rows and clears as they are read (rule 92).
|
||||
} label: {
|
||||
sidebarRowLabel(tab, tinted: selectedTab == tab)
|
||||
}
|
||||
.listRowBackground(
|
||||
selectedTab == tab ? Color.blue.opacity(0.1) : Color.clear
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("JQC Inspector")
|
||||
.listStyle(.sidebar)
|
||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||
|
||||
} detail: {
|
||||
switch selectedTab {
|
||||
case .myInspections:
|
||||
NavigationStack(path: $inspectionsPath) { detailRoot(for: .myInspections) }
|
||||
case .issues:
|
||||
NavigationStack(path: $issuesPath) { detailRoot(for: .issues) }
|
||||
case .history:
|
||||
NavigationStack(path: $historyPath) { detailRoot(for: .history) }
|
||||
default:
|
||||
NavigationStack { detailRoot(for: selectedTab) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compact width (iPhone) — push-based stack ─────────────────────────
|
||||
// One NavigationStack whose root is the same destination list. Rows are
|
||||
// NavigationLinks so tapping actually pushes. The per-tab paths used by
|
||||
// the iPad split view are not needed here: this single stack owns the
|
||||
// whole hierarchy, and the nested `.navigationDestination`s declared in
|
||||
// detailRoot(for:) register against it.
|
||||
|
||||
private var compactBody: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(sidebarTabs, id: \.self) { tab in
|
||||
NavigationLink(value: tab) {
|
||||
sidebarRowLabel(tab, tinted: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("JQC Inspector")
|
||||
.navigationDestination(for: SidebarTab.self) { detailRoot(for: $0) }
|
||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared row label ──────────────────────────────────────────────────
|
||||
|
||||
@ViewBuilder
|
||||
private func sidebarRowLabel(_ tab: SidebarTab, tinted: Bool) -> some View {
|
||||
let tint: Color = tinted ? .blue : .primary
|
||||
switch tab {
|
||||
case .dashboard:
|
||||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .myInspections:
|
||||
HStack {
|
||||
Label("My Inspections", systemImage: "checklist")
|
||||
.foregroundStyle(tint)
|
||||
Spacer()
|
||||
if myInspectionsBadgeCount > 0 {
|
||||
Text("\(myInspectionsBadgeCount)")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
case .issues:
|
||||
Label("Issues", systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .facilities:
|
||||
Label("Facilities", systemImage: "building.2")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .pendingSync:
|
||||
HStack {
|
||||
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(tint)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
case .history:
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
.foregroundStyle(tint)
|
||||
|
||||
case .notifications:
|
||||
HStack {
|
||||
Label("Notifications", systemImage: "bell")
|
||||
.foregroundStyle(tint)
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
case .settings:
|
||||
Label("Settings", systemImage: "gear")
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared destination root ───────────────────────────────────────────
|
||||
// The NavigationStack wrapper lives at the call site, so the same content
|
||||
// serves as a split-view detail root (iPad) and a pushed view (iPhone).
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRoot(for tab: SidebarTab) -> some View {
|
||||
switch tab {
|
||||
case .dashboard:
|
||||
DashboardStatsView()
|
||||
|
||||
case .myInspections:
|
||||
MyInspectionsView()
|
||||
.navigationDestination(for: LocalInspection.self) { inspection in
|
||||
if inspection.status == "draft" {
|
||||
ExecuteInspectionView(inspection: inspection)
|
||||
} else {
|
||||
CompletedInspectionView(inspection: inspection)
|
||||
}
|
||||
}
|
||||
|
||||
case .issues:
|
||||
IssuesListView()
|
||||
.navigationDestination(for: LocalIssue.self) { issue in
|
||||
IssueDetailView(issue: issue)
|
||||
}
|
||||
|
||||
case .facilities:
|
||||
FacilitiesListView()
|
||||
|
||||
case .pendingSync:
|
||||
SyncStatusView()
|
||||
|
||||
case .history:
|
||||
InspectionHistoryView()
|
||||
.navigationDestination(for: APIInspectionSummary.self) { inspection in
|
||||
HistoryDetailView(inspection: inspection)
|
||||
}
|
||||
|
||||
case .notifications:
|
||||
NotificationsView()
|
||||
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
private var syncStatusFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
@@ -266,6 +342,15 @@ struct DashboardStatsView: View {
|
||||
) private var draftInspections: [LocalInspection]
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
/// Schedule the inspector tapped in ScheduledInspectionsCard. Held here, not
|
||||
/// in the card: the card self-hides, and submitting the last scheduled
|
||||
/// inspection empties its @Query while the start form is still presented.
|
||||
@State private var scheduledStartTarget: ScheduledStartTarget? = nil
|
||||
|
||||
/// The follow-up request tapped in FollowUpRequestsCard. Held here for the
|
||||
/// same reason as `scheduledStartTarget` — see the covers at the bottom.
|
||||
@State private var followUpStartTarget: FollowUpStartTarget? = nil
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
@@ -275,11 +360,22 @@ struct DashboardStatsView: View {
|
||||
DraftResumeBanner(drafts: draftInspections, context: context)
|
||||
}
|
||||
|
||||
// ── Follow-up Requests ─────────────────────────────────────
|
||||
// Re-inspections a director asked for. Ranked above SCHEDULED:
|
||||
// a follow-up is remedial work on a facility that already failed
|
||||
// once, so it is the more urgent of the two. Self-hides when
|
||||
// there are none. Tap a row to start the linked re-inspection.
|
||||
FollowUpRequestsCard(onStart: { target in
|
||||
followUpStartTarget = target
|
||||
})
|
||||
|
||||
// ── 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()
|
||||
ScheduledInspectionsCard(onStart: { target in
|
||||
scheduledStartTarget = target
|
||||
})
|
||||
|
||||
if let stats = sync.dashboardStats {
|
||||
// ── Today ──────────────────────────────────────────────
|
||||
@@ -407,6 +503,43 @@ struct DashboardStatsView: View {
|
||||
.refreshable {
|
||||
await sync.fetchDashboardStats()
|
||||
}
|
||||
// Start cover for a tapped scheduled inspection. Owned here rather than
|
||||
// by ScheduledInspectionsCard because that card self-hides the instant
|
||||
// its last row is removed — which is exactly when this cover is on
|
||||
// screen (submit deletes the cached schedule row). The ScrollView is
|
||||
// always present, so the form is never torn down mid-submit.
|
||||
.fullScreenCover(item: $scheduledStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
// phase45 — nil for an ordinary schedule; set when this row is a
|
||||
// planned follow-up, which makes the run a linked re-inspection.
|
||||
// Must precede preFillScheduleId: argument order follows the
|
||||
// property declaration order in StartInspectionView.
|
||||
parentServerId: t.parentServerId,
|
||||
preFillScheduleId: t.id,
|
||||
preFillScheduleInstructions: t.instructions
|
||||
)
|
||||
}
|
||||
// Start cover for a tapped follow-up request. Owned here for the same
|
||||
// reason as the scheduled cover above: FollowUpRequestsCard self-hides
|
||||
// the instant its last row is invalidated at submit, which is exactly
|
||||
// when this cover is on screen.
|
||||
//
|
||||
// `parentServerId` is what makes this a re-inspection rather than a
|
||||
// fresh one — the server reads it to clear follow_up_required on the
|
||||
// flagged inspection. `parentLocalId` stays nil: the parent synced long
|
||||
// ago (that is how it got flagged), so serverId is the reliable handle,
|
||||
// and clearParentFollowUpFlag()'s fallback-1 matches on it.
|
||||
.fullScreenCover(item: $followUpStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
parentServerId: t.id,
|
||||
preFillFollowUpNote: t.note,
|
||||
preFillParentFormDataJSON: t.parentFormDataJSON
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
@@ -440,6 +573,9 @@ struct DashboardStatsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
// Tiles are 2-up, so on a phone each is ~170 pt wide and
|
||||
// longer labels ("Open / In Progress") would truncate.
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
Text(value)
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
|
||||
@@ -29,10 +29,27 @@ struct ExecuteInspectionView: View {
|
||||
/// Work is preserved either way — .onDisappear calls saveDraft().
|
||||
var isModallyPresented: Bool = false
|
||||
|
||||
/// What to do when the inspection has been submitted, instead of the
|
||||
/// default `dismiss()`.
|
||||
///
|
||||
/// `StartInspectionView` PUSHES this view onto the NavigationStack inside
|
||||
/// its own `.fullScreenCover`, so a plain `dismiss()` only pops — landing
|
||||
/// the inspector back on the "New Inspection" form they just started from,
|
||||
/// with Cancel as the only way out. It passes its own dismiss here so the
|
||||
/// whole cover closes and they return to the dashboard.
|
||||
///
|
||||
/// Left nil everywhere else, where popping IS correct: the My Inspections
|
||||
/// row pushes onto the list's stack, and the dashboard's Resume banner
|
||||
/// presents this view as the cover root.
|
||||
var onFinished: (() -> Void)? = nil
|
||||
|
||||
@State private var formValues: [String: String] = [:]
|
||||
@State private var showFlagIssue = false
|
||||
@State private var showSubmitAlert = false
|
||||
@State private var showNoGPSAlert = false
|
||||
/// Set when Submit is tapped with no GPS fix; consumed by
|
||||
/// `onChange(of: showSubmitAlert)` once the confirm alert has dismissed.
|
||||
@State private var pendingNoGPSPrompt = false
|
||||
@State private var showValidationAlert = false
|
||||
@State private var missingFields: [String] = []
|
||||
@State private var isSaving = false
|
||||
@@ -40,6 +57,14 @@ struct ExecuteInspectionView: View {
|
||||
@State private var isSubmitting = false
|
||||
@State private var submitResult: SubmitResult?
|
||||
|
||||
// ── Schedule instructions ─────────────────────────────────────────────
|
||||
// Snapshotted into @State in onAppear rather than read from SwiftData on
|
||||
// every body pass: resolveAndFulfillSchedule() DELETES the cached
|
||||
// LocalScheduledInspection row at submit time, while this view is still on
|
||||
// screen showing the success banner. Reading a deleted PersistentModel traps.
|
||||
@State private var scheduleInstructions: String? = nil
|
||||
@State private var instructionsExpanded = true
|
||||
|
||||
// Location manager — created on view init. requestLocation() is called in
|
||||
// onAppear so the permission prompt (and GPS fix acquisition) starts as
|
||||
// soon as the inspector opens the inspection, maximising the chance of
|
||||
@@ -93,7 +118,13 @@ struct ExecuteInspectionView: View {
|
||||
|
||||
switch ftype {
|
||||
case "rating":
|
||||
if let v = Int(val), v > 0 { earned += v; total += field["max"] as? Int ?? 5 }
|
||||
// Denominator is a FLAT 5, never the field's `max`.
|
||||
// `_compute_score_from_form()` in the Flask app (routes/
|
||||
// inspections.py) hardcodes `total += 5`, and LocalInspection
|
||||
// .computeScore() mirrors it — this was the only site reading
|
||||
// `max`, so a template with max != 5 showed one percentage in
|
||||
// the toolbar and submitted a different one.
|
||||
if let v = Int(val), v > 0 { earned += v; total += 5 }
|
||||
case "checkbox":
|
||||
total += 1; if val == "true" { earned += 1 }
|
||||
case "radio":
|
||||
@@ -158,6 +189,7 @@ struct ExecuteInspectionView: View {
|
||||
}
|
||||
.onAppear {
|
||||
formValues = inspection.formData.compactMapValues { "\($0)" }
|
||||
loadScheduleInstructions()
|
||||
// Request Location permission (and start acquiring a fix) the moment
|
||||
// the inspector opens the inspection — gives GPS the entire duration
|
||||
// of the inspection to get a fix, rather than only the few seconds
|
||||
@@ -185,7 +217,16 @@ struct ExecuteInspectionView: View {
|
||||
if locationManager.lastLocation == nil {
|
||||
// No GPS fix yet — warn before proceeding rather than
|
||||
// silently submitting without a location.
|
||||
showNoGPSAlert = true
|
||||
//
|
||||
// Deferred, NOT set here: raising a second alert from
|
||||
// inside the first one's action, with both attached to the
|
||||
// same view, is dropped by SwiftUI — the confirm alert is
|
||||
// still tearing down, so the new presentation is discarded.
|
||||
// The visible effect was that tapping Submit without a fix
|
||||
// did nothing at all: no warning, no submission. Handing it
|
||||
// to onChange(of: showSubmitAlert) below presents it only
|
||||
// once the first alert has actually gone.
|
||||
pendingNoGPSPrompt = true
|
||||
} else {
|
||||
Task { await submitInspection() }
|
||||
}
|
||||
@@ -214,7 +255,26 @@ struct ExecuteInspectionView: View {
|
||||
.onChange(of: showSubmitAlert) { _, showing in
|
||||
// Begin acquiring a GPS fix the moment the confirm dialog appears
|
||||
// so a location is likely ready by the time the inspector taps Submit.
|
||||
if showing { locationManager.requestLocation() }
|
||||
if showing {
|
||||
locationManager.requestLocation()
|
||||
return
|
||||
}
|
||||
// Confirm alert has closed. If Submit was tapped without a fix,
|
||||
// raise the warning now that the presentation slot is free.
|
||||
guard pendingNoGPSPrompt else { return }
|
||||
pendingNoGPSPrompt = false
|
||||
Task {
|
||||
// One runloop hop. `showing == false` means the binding flipped,
|
||||
// not that the dismissal animation has finished, and presenting
|
||||
// into the tail of that animation is unreliable.
|
||||
try? await Task.sleep(for: .milliseconds(350))
|
||||
// Re-check: the fix may have landed while the dialog was up.
|
||||
if locationManager.lastLocation == nil {
|
||||
showNoGPSAlert = true
|
||||
} else {
|
||||
await submitInspection()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Result overlay
|
||||
.overlay(alignment: .top) {
|
||||
@@ -260,6 +320,15 @@ struct ExecuteInspectionView: View {
|
||||
headerCard
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// Instructions from the schedule this inspection fulfils. Sits directly
|
||||
// above the form so the inspector can re-read it mid-inspection without
|
||||
// leaving the screen. Collapsible because long instructions would
|
||||
// otherwise push the first form field below the fold.
|
||||
if let instructions = scheduleInstructions {
|
||||
instructionsBanner(instructions)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
// CHANGED: grid-based form rendering (replaces card-grouped linear list)
|
||||
if formSchema.isEmpty {
|
||||
emptyFormPlaceholder
|
||||
@@ -297,6 +366,60 @@ struct ExecuteInspectionView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
// ── Instructions Banner ───────────────────────────────────────────────
|
||||
|
||||
/// Manager-authored instructions for the schedule this inspection fulfils.
|
||||
/// Wire/DB field is `notes`; "Instructions" is the user-facing wording.
|
||||
private func instructionsBanner(_ text: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
instructionsExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(.blue)
|
||||
Text("Instructions")
|
||||
.font(.callout.bold())
|
||||
.foregroundStyle(.blue)
|
||||
Spacer()
|
||||
Image(systemName: instructionsExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if instructionsExpanded {
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.blue.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
/// Copy the schedule's instructions into `@State` once, at appear.
|
||||
///
|
||||
/// Deliberately a snapshot, not a computed lookup: the cached
|
||||
/// `LocalScheduledInspection` is deleted by `resolveAndFulfillSchedule()`
|
||||
/// the instant Submit is tapped, and this view stays on screen for another
|
||||
/// 2.5 s afterwards. A computed property would re-read a deleted
|
||||
/// `PersistentModel` during that window and trap.
|
||||
private func loadScheduleInstructions() {
|
||||
guard let schedId = inspection.scheduledInspectionServerId else { return }
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
||||
scheduleInstructions = all.first(where: { $0.serverId == schedId })?.instructions
|
||||
}
|
||||
|
||||
// ── Header Card ───────────────────────────────────────────────────────
|
||||
|
||||
private var headerCard: some View {
|
||||
@@ -576,6 +699,22 @@ struct ExecuteInspectionView: View {
|
||||
// regardless of connectivity or sync timing.
|
||||
clearParentFollowUpFlag()
|
||||
|
||||
// ── Drop the cached follow-up request row ─────────────────────────
|
||||
// Same immediacy as above, for the other surface: the FOLLOW-UP
|
||||
// REQUESTED card on the Dashboard and My Inspections reads its own
|
||||
// pulled cache, not LocalInspection, so clearing the flag above is not
|
||||
// enough to make the row disappear.
|
||||
fulfillFollowUpRequest()
|
||||
|
||||
// ── Fulfil the originating scheduled inspection ───────────────────
|
||||
// Two jobs, both mirroring the web app's execute route:
|
||||
// 1. Make sure the submission carries scheduled_inspection_id, even
|
||||
// when the inspector reached this form via "+" instead of the
|
||||
// Scheduled row — the server cannot fulfil an unlinked inspection.
|
||||
// 2. Drop the cached schedule row so the SCHEDULED card clears the
|
||||
// moment Submit is tapped, online or offline.
|
||||
resolveAndFulfillSchedule()
|
||||
|
||||
try? context.save()
|
||||
|
||||
isSubmitting = false
|
||||
@@ -590,9 +729,9 @@ struct ExecuteInspectionView: View {
|
||||
Task { await sync.triggerSync() }
|
||||
}
|
||||
|
||||
// Wait 2.5 seconds so inspector reads the result, then dismiss
|
||||
// Wait 2.5 seconds so inspector reads the result, then leave.
|
||||
try? await Task.sleep(for: .seconds(2.5))
|
||||
dismiss()
|
||||
if let onFinished { onFinished() } else { dismiss() }
|
||||
}
|
||||
|
||||
/// Find the parent LocalInspection and clear its followUpRequired flag.
|
||||
@@ -638,16 +777,132 @@ struct ExecuteInspectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate the cached follow-up request this submission satisfies, so the
|
||||
/// FOLLOW-UP REQUESTED card and section clear the moment Submit is tapped —
|
||||
/// online or offline — rather than waiting for the round trip.
|
||||
///
|
||||
/// Matches on `parentServerId` alone. Unlike the schedule fallback there is
|
||||
/// no facility+template guess here: a request is keyed by the exact
|
||||
/// inspection it was raised against, and that id is set whenever the run was
|
||||
/// launched from a follow-up row or from CompletedInspectionView's banner.
|
||||
/// An ad-hoc inspection of the same facility is genuinely not the follow-up
|
||||
/// the director asked for, and must not clear it.
|
||||
///
|
||||
/// The row is flagged, not deleted, for the same reason as
|
||||
/// `LocalScheduledInspection.fulfilledLocally`: the server is authoritative,
|
||||
/// and `pullFollowUpRequests()` deletes the row once the flag actually
|
||||
/// clears — or brings it back if the submission never landed.
|
||||
private func fulfillFollowUpRequest() {
|
||||
guard let sid = inspection.parentServerId else { return }
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalFollowUpRequest>())) ?? []
|
||||
all.first { $0.serverId == sid }?.fulfilledLocally = true
|
||||
}
|
||||
|
||||
// ── Scheduled inspection fulfilment ───────────────────────────────────
|
||||
|
||||
/// Link this submission to the schedule it satisfies, then drop the cached
|
||||
/// schedule row.
|
||||
///
|
||||
/// **Why the fallback link exists.** On the web, `scheduled_inspections.start`
|
||||
/// is the *only* way to open a scheduled inspection, so the link is always
|
||||
/// present. On the iPad the Scheduled row merely pre-selects facility +
|
||||
/// template — the inspector can reach the identical form through the "+"
|
||||
/// button, and that path leaves `scheduledInspectionServerId` nil. The
|
||||
/// server then stores `scheduled_inspection_id = NULL`, never calls
|
||||
/// `_fulfill_schedule()`, and the schedule stays **Active** on the web.
|
||||
/// Matching facility + template here restores parity of outcome between the
|
||||
/// two entry points.
|
||||
///
|
||||
/// **Why the match is narrow.** Only schedules already due (due date on or
|
||||
/// before today) are eligible, so an ad-hoc inspection today cannot silently
|
||||
/// close out an occurrence planned for next month. Assignment must also fit:
|
||||
/// unassigned schedules, or ones assigned to this inspector. When several
|
||||
/// qualify, the earliest due date wins — that is the occurrence being worked.
|
||||
///
|
||||
/// **Why the row is deleted rather than rolled forward.** `LocalScheduledInspection`
|
||||
/// is a read-only cache and does not carry the phase43 recurrence detail
|
||||
/// (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the
|
||||
/// next due date cannot be computed correctly on device. Deleting invalidates
|
||||
/// the cache instead: `pullScheduledInspections()` re-inserts recurring
|
||||
/// schedules with the server-authoritative `next_due_date` on the next pull,
|
||||
/// and a one-time schedule stays gone because the server has deactivated it.
|
||||
/// The same pull also restores the row if the submission never lands, so a
|
||||
/// failed sync self-heals.
|
||||
private func resolveAndFulfillSchedule() {
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalScheduledInspection>())) ?? []
|
||||
guard !all.isEmpty else { return }
|
||||
|
||||
// Fallback link for inspections not started from a Scheduled row.
|
||||
// Re-inspections are excluded: a follow-up shares its parent's facility
|
||||
// and template, so it would otherwise close out an unrelated planned
|
||||
// occurrence. The web app keeps the two workflows separate the same way.
|
||||
let isReInspection = inspection.parentServerId != nil || inspection.parentLocalId != nil
|
||||
if inspection.scheduledInspectionServerId == nil && !isReInspection {
|
||||
let tid = inspection.templateServerId
|
||||
let fid = inspection.facilityServerId
|
||||
let uid = inspection.inspectorUserId
|
||||
let today = Self.dueDateFormatter.string(from: Date())
|
||||
|
||||
let candidate = all
|
||||
.filter {
|
||||
$0.templateServerId == tid &&
|
||||
$0.facilityServerId == fid &&
|
||||
($0.inspectorId == nil || $0.inspectorId == uid) &&
|
||||
!$0.fulfilledLocally && // already satisfied, awaiting sync
|
||||
!$0.dueDateString.isEmpty &&
|
||||
$0.dueDateString <= today // ISO strings sort chronologically
|
||||
}
|
||||
.sorted { $0.dueDateString < $1.dueDateString }
|
||||
.first
|
||||
|
||||
if let candidate {
|
||||
inspection.scheduledInspectionServerId = candidate.serverId
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the row until the server confirms what happened to it.
|
||||
//
|
||||
// NOT a delete. A recurring schedule comes back from the server on its
|
||||
// next occurrence, so deleting turned every completion into a
|
||||
// delete-then-reinsert against the `@Attribute(.unique)` serverId, and
|
||||
// the reinserted row did not reliably pick up the new due date — a
|
||||
// daily schedule kept showing today's date after being completed.
|
||||
// One-time schedules masked it, because the server stops returning them
|
||||
// and they are never reinserted. Flagging leaves `update(from:)` as the
|
||||
// single path that ever writes a cached schedule's dates.
|
||||
guard let schedId = inspection.scheduledInspectionServerId,
|
||||
let sched = all.first(where: { $0.serverId == schedId })
|
||||
else { return }
|
||||
|
||||
sched.fulfilledLocally = true
|
||||
}
|
||||
|
||||
/// "yyyy-MM-dd", matching `LocalScheduledInspection.dueDateString`.
|
||||
private static let dueDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
// ── Photo Handling ────────────────────────────────────────────────────
|
||||
|
||||
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
|
||||
let fid = fieldId(field)
|
||||
formValues[fid] = "local://\(localPath)"
|
||||
// Stamp capture time + the current fix now; the upload may be hours
|
||||
// later on a slow sync and must not use its own clock.
|
||||
let fix = locationManager.lastLocation ?? PhotoLocationProvider.shared.lastLocation
|
||||
let photo = PendingPhoto(
|
||||
localFilePath: localPath,
|
||||
entityType: "inspection",
|
||||
entityLocalId: inspection.localId,
|
||||
fieldId: fid
|
||||
localFilePath: localPath,
|
||||
entityType: "inspection",
|
||||
entityLocalId: inspection.localId,
|
||||
fieldId: fid,
|
||||
capturedAt: Date(),
|
||||
captureLatitude: fix?.coordinate.latitude,
|
||||
captureLongitude: fix?.coordinate.longitude
|
||||
)
|
||||
inspection.pendingPhotos.append(photo)
|
||||
context.insert(photo)
|
||||
@@ -695,6 +950,25 @@ struct GridFormView: View {
|
||||
static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W
|
||||
static let cardPadding: CGFloat = 16 // card inset on all sides
|
||||
|
||||
// Below this container width the 12-column grid stops being usable: at
|
||||
// 375 pt (iPhone SE/6/7/8) a column is only ~21 pt wide and a row ~15 pt
|
||||
// tall, so a normal 6x2 field renders ~167x35 pt — the label alone eats
|
||||
// most of it. Cells are absolutely positioned and deliberately unclipped
|
||||
// (see body), so the overflow draws on top of the row beneath and the
|
||||
// form becomes an unreadable pile. Under this width we reflow to one
|
||||
// field per line instead. 600 pt keeps a column at >=40 pt.
|
||||
static let minGridWidth: CGFloat = 600
|
||||
|
||||
// Heights for widgets that have no intrinsic size of their own. In the
|
||||
// absolute grid these are driven by rowSpan; in the stacked layout there
|
||||
// is no rowSpan to read, so they would otherwise collapse to nothing.
|
||||
static let stackedMinH: [String: CGFloat] = [
|
||||
"textarea": 96,
|
||||
"signature": 120,
|
||||
"table": 120,
|
||||
"image": 88,
|
||||
]
|
||||
|
||||
// Minimum cell height (points) per field type — ensures 44pt touch targets
|
||||
// on iPad even when the template author assigned a very short rowSpan.
|
||||
static let minCellH: [String: CGFloat] = [
|
||||
@@ -719,54 +993,115 @@ struct GridFormView: View {
|
||||
@State private var containerWidth: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
// ── Card background ────────────────────────────────────────────
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
Group {
|
||||
if containerWidth > 0 && isCompact {
|
||||
// ── Compact: content drives the height ─────────────────────
|
||||
// The card is a .background modifier rather than a ZStack
|
||||
// sibling so it takes its size FROM the stack. As a ZStack
|
||||
// sibling the flexible RoundedRectangle competes with the
|
||||
// VStack for the container's size and the card ends up
|
||||
// shorter than its own content, cutting off the last fields.
|
||||
stackedLayout
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
} else {
|
||||
// ── Regular: absolute 12-column canvas ─────────────────────
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
|
||||
// ── Width probe — zero-size overlay, reports container width ───
|
||||
// Using a background Color.clear with a GeometryReader that sends
|
||||
// its width via PreferenceKey is the idiomatic SwiftUI pattern that
|
||||
// works correctly inside ScrollView on all iOS versions.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 0)
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: WidthPreferenceKey.self,
|
||||
value: geo.size.width
|
||||
)
|
||||
}
|
||||
)
|
||||
// Field overlays — only rendered after width is measured.
|
||||
// containerWidth == 0 means the PreferenceKey has not fired
|
||||
// yet (first layout pass). Skipping the overlay pass on the
|
||||
// zero frame prevents fields from being positioned using a
|
||||
// stale width and overflowing the modal on narrow sheet
|
||||
// presentations (iPad 10th gen).
|
||||
if containerWidth > 0 {
|
||||
let cellW = computedCellW
|
||||
let cellH = cellW * Self.cellAspect
|
||||
|
||||
// ── Field overlays — only rendered after width is measured ─────
|
||||
// containerWidth == 0 means the PreferenceKey has not fired yet
|
||||
// (first layout pass). Skipping the overlay pass on the zero frame
|
||||
// prevents fields from being positioned using a stale width and
|
||||
// overflowing the modal on narrow sheet presentations (iPad 10th gen).
|
||||
if containerWidth > 0 {
|
||||
let cellW = computedCellW
|
||||
let cellH = cellW * Self.cellAspect
|
||||
|
||||
ForEach(schema.indices, id: \.self) { idx in
|
||||
let field = schema[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
gridCell(field: field, cellW: cellW, cellH: cellH)
|
||||
ForEach(schema.indices, id: \.self) { idx in
|
||||
let field = schema[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
gridCell(field: field, cellW: cellW, cellH: cellH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Height is derived from the same arithmetic as the cell
|
||||
// offsets — the ScrollView measures this frame and can never
|
||||
// be wrong.
|
||||
.frame(height: containerWidth > 0
|
||||
? canvasHeight() + 2 * Self.cardPadding
|
||||
: 0)
|
||||
}
|
||||
}
|
||||
// ── Width probe ───────────────────────────────────────────────────
|
||||
// Attached as a background so it reports the resolved container width
|
||||
// without taking part in sizing the content itself.
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear.preference(
|
||||
key: WidthPreferenceKey.self,
|
||||
value: geo.size.width
|
||||
)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(WidthPreferenceKey.self) { width in
|
||||
if width > 0 { containerWidth = width }
|
||||
}
|
||||
// Height is always derived from the same arithmetic as cell offsets —
|
||||
// the ScrollView measures this frame and can never be wrong.
|
||||
// When containerWidth is 0, canvasHeight() still returns the correct
|
||||
// value (it uses computedCellW which returns 0 when containerWidth is 0),
|
||||
// so the card reserves space and avoids a layout jump.
|
||||
.frame(height: containerWidth > 0 ? canvasHeight() + 2 * Self.cardPadding : 0)
|
||||
}
|
||||
|
||||
// ── Compact (narrow) layout ───────────────────────────────────────────
|
||||
// One field per line, full width, natural height. Fields are ordered by
|
||||
// (row, col) because the form editor stores them in drag/creation order,
|
||||
// not visual order — the same sort the PDF and read-only renderers use
|
||||
// (rule 62). Nothing is absolutely positioned here, so nothing can
|
||||
// overlap regardless of how narrow the screen gets.
|
||||
|
||||
private var isCompact: Bool { containerWidth < Self.minGridWidth }
|
||||
|
||||
private var orderedFields: [[String: Any]] {
|
||||
schema
|
||||
.filter { f in
|
||||
let t = f["type"] as? String ?? "text"
|
||||
return !["button_submit", "button_print", "button_email"].contains(t)
|
||||
}
|
||||
.sorted {
|
||||
let r0 = $0["row"] as? Int ?? 0, r1 = $1["row"] as? Int ?? 0
|
||||
if r0 != r1 { return r0 < r1 }
|
||||
return ($0["col"] as? Int ?? 0) < ($1["col"] as? Int ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedLayout: some View {
|
||||
let fields = orderedFields
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(fields.indices, id: \.self) { idx in
|
||||
let field = fields[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
let fid = fieldId(field)
|
||||
|
||||
GridCellContentView(
|
||||
field: field,
|
||||
value: Binding(
|
||||
get: { formValues[fid] ?? "" },
|
||||
set: { formValues[fid] = $0; onFieldChanged?() }
|
||||
),
|
||||
onPhotoSelected: { path in onPhotoSelected?(path, field) }
|
||||
)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
minHeight: Self.stackedMinH[ftype] ?? 0,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Self.cardPadding)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
// ── Derived cell width from current containerWidth ────────────────────
|
||||
@@ -1125,15 +1460,83 @@ struct CellDatePicker: View {
|
||||
|
||||
private var dateBinding: Binding<Date> {
|
||||
Binding(
|
||||
get: { ISO8601DateFormatter().date(from: value) ?? Date() },
|
||||
set: { value = ISO8601DateFormatter().string(from: $0) }
|
||||
get: { FormDateFormat.date(from: value) ?? Date() },
|
||||
set: { value = FormDateFormat.string(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DatePicker("", selection: dateBinding, displayedComponents: .date)
|
||||
.labelsHidden()
|
||||
// An empty value must LOOK empty until the inspector acts.
|
||||
//
|
||||
// The old version bound the DatePicker straight to the value: when it
|
||||
// was empty the picker still displayed TODAY, so the field looked
|
||||
// answered — but the setter only fires on a CHANGE, so selecting the
|
||||
// already-displayed date wrote nothing and missingRequiredFields()
|
||||
// reported the field missing with a date plainly visible on screen.
|
||||
// The inspector had to pick a different day and navigate back. This
|
||||
// explicit step makes unanswered look unanswered and makes today
|
||||
// selectable in one tap.
|
||||
if value.isEmpty {
|
||||
Button {
|
||||
value = FormDateFormat.string(from: Date())
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "calendar").font(.system(size: 11))
|
||||
Text("Set date").font(.system(size: 12))
|
||||
}
|
||||
.foregroundStyle(Color(.placeholderText))
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.overlay(RoundedRectangle(cornerRadius: 5)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
HStack(spacing: 2) {
|
||||
DatePicker("", selection: dateBinding, displayedComponents: .date)
|
||||
.labelsHidden()
|
||||
Button { value = "" } label: { // back to unanswered
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FormDateFormat
|
||||
// Wire format for `date` form fields: "yyyy-MM-dd", matching the web's
|
||||
// <input type="date"> (templates/inspections/execute.html), so a value entered
|
||||
// on the iPad and one entered in a browser are the same string in form_data.
|
||||
//
|
||||
// Both date widgets previously used ISO8601DateFormatter, which round-tripped a
|
||||
// full timestamp ("2026-08-18T14:30:00Z") into a field that the web renders and
|
||||
// the PDF prints verbatim.
|
||||
//
|
||||
// UTC + POSIX locale so the day cannot shift with device timezone or calendar.
|
||||
// nonisolated for the same reason as PhotoCaptureFormat (rule 82).
|
||||
nonisolated enum FormDateFormat {
|
||||
static let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
static func string(from date: Date) -> String { formatter.string(from: date) }
|
||||
|
||||
/// Parses the canonical form, and tolerates a leading `yyyy-MM-dd` inside a
|
||||
/// longer timestamp so values written by earlier builds still display.
|
||||
static func date(from value: String) -> Date? {
|
||||
if let d = formatter.date(from: value) { return d }
|
||||
guard value.count >= 10 else { return nil }
|
||||
return formatter.date(from: String(value.prefix(10)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ struct FlagIssueView: View {
|
||||
@State private var description = ""
|
||||
|
||||
// Each entry: (UIImage for display, local file path for storage)
|
||||
@State private var photos: [(image: UIImage, path: String)] = []
|
||||
// CapturedPhoto records the capture moment + GPS fix at shutter time; the
|
||||
// `image` / `path` members match the tuple this replaced.
|
||||
@State private var photos: [CapturedPhoto] = []
|
||||
|
||||
@State private var showCamera = false
|
||||
@State private var showLibrary = false
|
||||
@@ -190,6 +192,9 @@ struct FlagIssueView: View {
|
||||
}
|
||||
.navigationTitle("Flag Issue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// Warm up GPS so a fix exists the instant a photo is taken; the
|
||||
// coordinates are burned into the photo server-side.
|
||||
.onAppear { PhotoLocationProvider.shared.start() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
@@ -242,14 +247,14 @@ struct FlagIssueView: View {
|
||||
private func appendPhoto(_ img: UIImage) {
|
||||
guard photos.count < maxPhotos else { return }
|
||||
guard let path = savePhotoToDisk(img) else { return }
|
||||
photos.append((image: img, path: path))
|
||||
photos.append(CapturedPhoto(image: img, path: path))
|
||||
}
|
||||
|
||||
private func appendPhotos(_ images: [UIImage]) {
|
||||
for img in images {
|
||||
guard photos.count < maxPhotos else { break }
|
||||
guard let path = savePhotoToDisk(img) else { continue }
|
||||
photos.append((image: img, path: path))
|
||||
photos.append(CapturedPhoto(image: img, path: path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,9 +295,12 @@ struct FlagIssueView: View {
|
||||
// Create one PendingPhoto per photo so they all upload independently
|
||||
for photo in photos {
|
||||
let pending = PendingPhoto(
|
||||
localFilePath: photo.path,
|
||||
entityType: "issue",
|
||||
entityLocalId: issue.localId
|
||||
localFilePath: photo.path,
|
||||
entityType: "issue",
|
||||
entityLocalId: issue.localId,
|
||||
capturedAt: photo.capturedAt,
|
||||
captureLatitude: photo.latitude,
|
||||
captureLongitude: photo.longitude
|
||||
)
|
||||
context.insert(pending)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Views/Dashboard/FollowUpRequestsView.swift
|
||||
// ------------------------------------------
|
||||
// Displays inspections a director/admin flagged as needing a follow-up, pulled
|
||||
// read-only from GET /api/v1/inspections?follow_up_required=true by
|
||||
// SyncManager.pullFollowUpRequests().
|
||||
//
|
||||
// Deliberately built as the twin of ScheduledInspectionsView: a follow-up
|
||||
// request is assigned work the inspector must recognise and act on, exactly
|
||||
// like a scheduled assignment, so it gets the same two surfaces and the same
|
||||
// ownership rules.
|
||||
//
|
||||
// Two consumers share one FollowUpRow:
|
||||
// • FollowUpRequestsCard — VStack card for the Dashboard ScrollView.
|
||||
// Presentational only; it reports taps via `onStart` and DashboardStatsView
|
||||
// owns the .fullScreenCover on its always-present ScrollView.
|
||||
// • MyInspectionsView renders its own "Follow-up Requested" List section
|
||||
// inline, reusing FollowUpRow, with the start cover on the enclosing Group.
|
||||
// Both self-hide when there are none, and present StartInspectionView with the
|
||||
// facility + template preselected AND `parentServerId` set, so the submission
|
||||
// lands as a linked re-inspection — the same path CompletedInspectionView's
|
||||
// "Start Re-inspection" banner has always used.
|
||||
// Both cover owners are views that outlive the rows themselves — submitting the
|
||||
// last follow-up empties the @Query while the cover is still up, so a cover
|
||||
// owned by the self-hiding card would be torn down with it.
|
||||
//
|
||||
// The lifecycle stays server-driven: the re-inspection carries
|
||||
// `parent_inspection_id` and the server clears `follow_up_required` on arrival.
|
||||
// ExecuteInspectionView only invalidates the local cache row;
|
||||
// pullFollowUpRequests() re-reads the authoritative state.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Start target snapshot
|
||||
|
||||
/// Plain-value snapshot of the tapped request, used as the `.fullScreenCover`
|
||||
/// item instead of the `LocalFollowUpRequest` itself.
|
||||
///
|
||||
/// The model object is unsafe to hold across the presentation: the cached row is
|
||||
/// invalidated while the cover is still on screen — by `fulfillFollowUpRequest()`
|
||||
/// the instant Submit is tapped, and deleted by `pullFollowUpRequests()` once the
|
||||
/// server stops returning it. Reading a deleted `PersistentModel` traps, and a
|
||||
/// `@Query` that empties out would also tear the cover down mid-submit. Copying
|
||||
/// the values at tap time removes both hazards. (Same reasoning as
|
||||
/// `ScheduledStartTarget`.)
|
||||
struct FollowUpStartTarget: Identifiable {
|
||||
/// Flagged inspection's `serverId` — the identity for `.fullScreenCover(item:)`
|
||||
/// and the `parentServerId` the re-inspection is linked to.
|
||||
let id: Int
|
||||
let templateServerId: Int
|
||||
let facilityServerId: Int
|
||||
/// The director's note explaining what the follow-up should address.
|
||||
let note: String?
|
||||
/// The flagged inspection's answers, JSON-encoded, carried so the
|
||||
/// re-inspection can pre-fill from them. Snapshotted here for the same
|
||||
/// reason as every other field: the row it came from is invalidated while
|
||||
/// the start form is still on screen.
|
||||
let parentFormDataJSON: String
|
||||
|
||||
init(_ request: LocalFollowUpRequest) {
|
||||
self.id = request.serverId
|
||||
self.templateServerId = request.templateServerId
|
||||
self.facilityServerId = request.facilityServerId
|
||||
self.note = request.note
|
||||
self.parentFormDataJSON = request.parentFormDataJSON
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared row
|
||||
|
||||
struct FollowUpRow: View {
|
||||
let request: LocalFollowUpRequest
|
||||
|
||||
private var inspectedText: String {
|
||||
if let d = request.inspectedOn {
|
||||
return d.formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
return request.inspectionDateString.isEmpty ? "—" : request.inspectionDateString
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.arrow.circlepath")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(request.templateName.isEmpty ? "Inspection" : request.templateName)
|
||||
.font(.callout.bold())
|
||||
Text(request.facilityName.isEmpty ? "Facility" : request.facilityName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Follow-up")
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.foregroundStyle(.orange)
|
||||
.clipShape(Capsule())
|
||||
Text("Inspected \(inspectedText)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
// The score is usually why the follow-up was raised, so it
|
||||
// is the one number worth showing before the tap.
|
||||
if let score = request.overallScore {
|
||||
Text("· \(String(format: "%.1f%%", score))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||
}
|
||||
}
|
||||
|
||||
// Assigned to somebody other than whoever ran the original
|
||||
// inspection (phase53). The endpoint only returns follow-ups
|
||||
// this user owns, so seeing this badge means "you were given
|
||||
// this one" — worth calling out, because it is NOT the usual
|
||||
// case of re-inspecting your own work.
|
||||
if request.assignedToName != nil {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.crop.circle.badge.checkmark")
|
||||
.font(.caption2)
|
||||
Text("Assigned to you")
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
// Note preview — so the inspector can see there is something to
|
||||
// read before committing to the tap. Truncated to one line; the
|
||||
// full text is shown on the start screen. Mirrors the
|
||||
// instructions preview on ScheduledRow.
|
||||
if let note = request.note {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
Text(note)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Label("Re-inspect", systemImage: "arrow.uturn.right.circle.fill")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||
.background(Color.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard card (VStack)
|
||||
|
||||
struct FollowUpRequestsCard: View {
|
||||
@Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward)
|
||||
private var requests: [LocalFollowUpRequest]
|
||||
|
||||
/// Rows still awaiting action. Filtered in Swift rather than in the @Query
|
||||
/// predicate, per CLAUDE.md rule 3. `fulfilledLocally` is set at submit and
|
||||
/// cleared by the next pull, so a completed follow-up leaves the card at
|
||||
/// once and reappears only if the re-inspection never reached the server.
|
||||
private var visible: [LocalFollowUpRequest] {
|
||||
requests.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
/// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT
|
||||
/// (`DashboardStatsView`, on its always-present ScrollView) rather than here,
|
||||
/// for the same reason as `ScheduledInspectionsCard`: this card self-hides,
|
||||
/// and submitting the last follow-up removes the final row while the cover is
|
||||
/// still on screen. Keeping the card purely presentational also keeps the
|
||||
/// empty case a true `EmptyView`, so the dashboard stack adds no spacing.
|
||||
let onStart: (FollowUpStartTarget) -> Void
|
||||
|
||||
var body: some View {
|
||||
if !visible.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("FOLLOW-UP REQUESTED")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.orange)
|
||||
.tracking(1)
|
||||
|
||||
ForEach(visible) { r in
|
||||
Button { onStart(FollowUpStartTarget(r)) } label: {
|
||||
FollowUpRow(request: r)
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,19 +183,45 @@ struct DateFieldView: View {
|
||||
|
||||
private var dateBinding: Binding<Date> {
|
||||
Binding(
|
||||
get: {
|
||||
ISO8601DateFormatter().date(from: value) ?? Date()
|
||||
},
|
||||
set: {
|
||||
value = ISO8601DateFormatter().string(from: $0)
|
||||
}
|
||||
get: { FormDateFormat.date(from: value) ?? Date() },
|
||||
set: { value = FormDateFormat.string(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DatePicker("", selection: dateBinding, displayedComponents: .date)
|
||||
.labelsHidden()
|
||||
// Same two problems as CellDatePicker, same fix — see the comments
|
||||
// there. Empty must look empty, and the stored format is "yyyy-MM-dd"
|
||||
// to match the web's <input type="date">, not an ISO 8601 timestamp.
|
||||
if value.isEmpty {
|
||||
Button {
|
||||
value = FormDateFormat.string(from: Date())
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "calendar")
|
||||
Text("Set date")
|
||||
}
|
||||
.font(.callout)
|
||||
.foregroundStyle(Color(.placeholderText))
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(.systemGray4), lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
DatePicker("", selection: dateBinding, displayedComponents: .date)
|
||||
.labelsHidden()
|
||||
Button { value = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,11 +145,15 @@ struct IssuesListView: View {
|
||||
|
||||
// New Issue — borderedProminent so it stands out clearly
|
||||
// from the filter icon and is easy to find at a glance.
|
||||
// `.titleAndIcon` is required: without it SwiftUI collapses the
|
||||
// Label to icon-only in a toolbar, so this rendered as a bare
|
||||
// "+" despite having a title in code.
|
||||
Button {
|
||||
showNewIssue = true
|
||||
} label: {
|
||||
Label("New Issue", systemImage: "plus")
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
@@ -240,7 +244,8 @@ struct IssueDetailView: View {
|
||||
@State private var statusError: String?
|
||||
@State private var showStatusPicker = false
|
||||
// ── Resolution Photos ─────────────────────────────────────────────────
|
||||
@State private var resultPhotos: [(image: UIImage, path: String)] = []
|
||||
// Resolution photos are evidence too — record capture time + GPS at shutter.
|
||||
@State private var resultPhotos: [CapturedPhoto] = []
|
||||
@State private var showResultCamera = false
|
||||
@State private var showResultLibrary = false
|
||||
@State private var isUploadingResultPhotos = false
|
||||
@@ -285,10 +290,15 @@ struct IssueDetailView: View {
|
||||
|
||||
/// Inspector can update status only if the issue has synced (has a serverId)
|
||||
/// and we are online. Admins/directors can always update when online.
|
||||
///
|
||||
/// Uses `Constants.Roles.issueActors` rather than a hand-written list: the
|
||||
/// old `role == "inspector"` check silently excluded Customer Inspectors
|
||||
/// (`external_inspector`), who the API has always accepted here — the
|
||||
/// Update Status control simply never appeared for them, with no error to
|
||||
/// explain why.
|
||||
private var canUpdateStatus: Bool {
|
||||
guard sync.isOnline, issue.serverId != nil else { return false }
|
||||
let role = AuthManager.shared.currentUserRole
|
||||
return role == "admin" || role == "director" || role == "inspector"
|
||||
return Constants.Roles.issueActors.contains(AuthManager.shared.currentUserRole)
|
||||
}
|
||||
|
||||
private let allStatuses: [(value: String, label: String, color: Color)] = [
|
||||
@@ -303,9 +313,7 @@ struct IssueDetailView: View {
|
||||
/// admin/director/PM); the server enforces facility scope for inspectors.
|
||||
private var canEditHandler: Bool {
|
||||
guard sync.isOnline, issue.serverId != nil else { return false }
|
||||
let role = AuthManager.shared.currentUserRole
|
||||
return role == "admin" || role == "director"
|
||||
|| role == "inspector" || role == "project_manager"
|
||||
return Constants.Roles.issueActors.contains(AuthManager.shared.currentUserRole)
|
||||
}
|
||||
|
||||
private func handlerTypeLabel(_ type: String) -> String {
|
||||
@@ -559,7 +567,7 @@ struct IssueDetailView: View {
|
||||
if !issue.photoLocalPaths.isEmpty {
|
||||
Section("Photos (\(issue.photoLocalPaths.count))") {
|
||||
ForEach(issue.photoLocalPaths, id: \.self) { path in
|
||||
if let img = UIImage(contentsOfFile: path) {
|
||||
if let live = PhotoStore.resolve(path), let img = UIImage(contentsOfFile: live) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
@@ -641,6 +649,8 @@ struct IssueDetailView: View {
|
||||
}
|
||||
.navigationTitle("Issue Detail")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// Warm up GPS so resolution photos taken here carry coordinates.
|
||||
.onAppear { PhotoLocationProvider.shared.start() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
@@ -981,14 +991,14 @@ struct IssueDetailView: View {
|
||||
private func appendResultPhoto(_ img: UIImage) {
|
||||
guard resultPhotos.count < maxResultPhotos,
|
||||
let path = saveResultPhotoToDisk(img) else { return }
|
||||
resultPhotos.append((image: img, path: path))
|
||||
resultPhotos.append(CapturedPhoto(image: img, path: path))
|
||||
}
|
||||
|
||||
private func appendResultPhotos(_ images: [UIImage]) {
|
||||
for img in images {
|
||||
guard resultPhotos.count < maxResultPhotos,
|
||||
let path = saveResultPhotoToDisk(img) else { break }
|
||||
resultPhotos.append((image: img, path: path))
|
||||
resultPhotos.append(CapturedPhoto(image: img, path: path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,7 +1034,12 @@ struct IssueDetailView: View {
|
||||
do {
|
||||
var serverPaths: [String] = []
|
||||
for photo in resultPhotos {
|
||||
let path = try await APIClient.shared.uploadResultPhoto(localPath: photo.path)
|
||||
let path = try await APIClient.shared.uploadResultPhoto(
|
||||
localPath: photo.path,
|
||||
capturedAt: photo.capturedAt,
|
||||
latitude: photo.latitude,
|
||||
longitude: photo.longitude
|
||||
)
|
||||
serverPaths.append(path)
|
||||
}
|
||||
|
||||
@@ -1066,7 +1081,9 @@ struct StandaloneIssueView: View {
|
||||
|
||||
@State private var severity = "medium"
|
||||
@State private var description = ""
|
||||
@State private var photos: [(image: UIImage, path: String)] = []
|
||||
// CapturedPhoto records the capture moment + GPS fix at shutter time; the
|
||||
// `image` / `path` members match the tuple this replaced.
|
||||
@State private var photos: [CapturedPhoto] = []
|
||||
@State private var showCamera = false
|
||||
@State private var showLibrary = false
|
||||
@State private var showBanner = false
|
||||
@@ -1079,11 +1096,19 @@ struct StandaloneIssueView: View {
|
||||
}
|
||||
private var remainingSlots: Int { maxPhotos - photos.count }
|
||||
|
||||
/// Facilities this user may file a new issue against.
|
||||
/// Excludes rows SyncManager retained purely so an unsynced draft could
|
||||
/// still show its facility name — see StartInspectionView for the full
|
||||
/// explanation. Out-of-scope facilities must not be offered for new work.
|
||||
private var availableFacilities: [LocalFacility] {
|
||||
facilities.filter { $0.isActive }
|
||||
}
|
||||
|
||||
/// Unique contracts derived from cached facilities, sorted by name.
|
||||
private var contracts: [(id: Int, name: String)] {
|
||||
var seen = Set<Int>()
|
||||
var result: [(id: Int, name: String)] = []
|
||||
for f in facilities {
|
||||
for f in availableFacilities {
|
||||
if seen.insert(f.projectId).inserted {
|
||||
result.append((id: f.projectId, name: f.projectName))
|
||||
}
|
||||
@@ -1096,7 +1121,7 @@ struct StandaloneIssueView: View {
|
||||
private var filteredFacilities: [LocalFacility] {
|
||||
guard let pid = selectedProjectId else { return [] }
|
||||
var seen = Set<Int>()
|
||||
return facilities
|
||||
return availableFacilities
|
||||
.filter { $0.projectId == pid }
|
||||
.filter { seen.insert($0.serverId).inserted }
|
||||
}
|
||||
@@ -1238,6 +1263,9 @@ struct StandaloneIssueView: View {
|
||||
}
|
||||
.navigationTitle("New Issue")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// Warm up GPS so a fix exists the instant a photo is taken; the
|
||||
// coordinates are burned into the photo server-side.
|
||||
.onAppear { PhotoLocationProvider.shared.start() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
@@ -1284,13 +1312,13 @@ struct StandaloneIssueView: View {
|
||||
|
||||
private func appendPhoto(_ img: UIImage) {
|
||||
guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { return }
|
||||
photos.append((image: img, path: path))
|
||||
photos.append(CapturedPhoto(image: img, path: path))
|
||||
}
|
||||
|
||||
private func appendPhotos(_ images: [UIImage]) {
|
||||
for img in images {
|
||||
guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { break }
|
||||
photos.append((image: img, path: path))
|
||||
photos.append(CapturedPhoto(image: img, path: path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1325,9 +1353,12 @@ struct StandaloneIssueView: View {
|
||||
|
||||
for photo in photos {
|
||||
let pending = PendingPhoto(
|
||||
localFilePath: photo.path,
|
||||
entityType: "issue",
|
||||
entityLocalId: issue.localId
|
||||
localFilePath: photo.path,
|
||||
entityType: "issue",
|
||||
entityLocalId: issue.localId,
|
||||
capturedAt: photo.capturedAt,
|
||||
captureLatitude: photo.latitude,
|
||||
captureLongitude: photo.longitude
|
||||
)
|
||||
context.insert(pending)
|
||||
}
|
||||
|
||||
@@ -19,10 +19,28 @@ struct MyInspectionsView: View {
|
||||
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||
private var scheduledAll: [LocalScheduledInspection]
|
||||
|
||||
/// Rows still awaiting action — see ScheduledInspectionsCard.visible.
|
||||
private var scheduledVisible: [LocalScheduledInspection] {
|
||||
scheduledAll.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
/// Follow-up requests raised on the web — rendered as the top section, above
|
||||
/// Scheduled, and counted in the empty-state decision. Sorted by the flagged
|
||||
/// inspection's date (ISO strings sort chronologically), oldest first: the
|
||||
/// longest-outstanding request is the one to clear next.
|
||||
@Query(sort: \LocalFollowUpRequest.inspectionDateString, order: .forward)
|
||||
private var followUpsAll: [LocalFollowUpRequest]
|
||||
|
||||
/// Rows still awaiting action — see FollowUpRequestsCard.visible.
|
||||
private var followUpsVisible: [LocalFollowUpRequest] {
|
||||
followUpsAll.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var showNewInspection = false
|
||||
@State private var scheduledStartTarget: LocalScheduledInspection?
|
||||
@State private var scheduledStartTarget: ScheduledStartTarget?
|
||||
@State private var followUpStartTarget: FollowUpStartTarget?
|
||||
|
||||
// Deletion confirmation state
|
||||
@State private var pendingDelete: LocalInspection?
|
||||
@@ -30,7 +48,7 @@ struct MyInspectionsView: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if inspections.isEmpty && scheduledAll.isEmpty {
|
||||
if inspections.isEmpty && scheduledVisible.isEmpty && followUpsVisible.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Inspections",
|
||||
systemImage: "checklist",
|
||||
@@ -38,11 +56,25 @@ struct MyInspectionsView: View {
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
// Follow-up requests — self-hides when empty. First section:
|
||||
// remedial work on a facility that already failed once
|
||||
// outranks a routine scheduled visit.
|
||||
if !followUpsVisible.isEmpty {
|
||||
Section("Follow-up Requested") {
|
||||
ForEach(followUpsVisible) { r in
|
||||
Button { followUpStartTarget = FollowUpStartTarget(r) } label: {
|
||||
FollowUpRow(request: r)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled assignments (phase36) — self-hides when empty.
|
||||
if !scheduledAll.isEmpty {
|
||||
if !scheduledVisible.isEmpty {
|
||||
Section("Scheduled") {
|
||||
ForEach(scheduledAll) { s in
|
||||
Button { scheduledStartTarget = s } label: {
|
||||
ForEach(scheduledVisible) { s in
|
||||
Button { scheduledStartTarget = ScheduledStartTarget(s) } label: {
|
||||
ScheduledRow(schedule: s)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -71,15 +103,39 @@ struct MyInspectionsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cover attached to the stable List, not a Section.
|
||||
.fullScreenCover(item: $scheduledStartTarget) { s in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: s.templateServerId,
|
||||
preFillFacilityId: s.facilityServerId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cover attached to the enclosing Group, not the List and never a
|
||||
// Section (rule 64). The List itself is conditional: submitting the last
|
||||
// scheduled inspection can flip this view to ContentUnavailableView while
|
||||
// the cover is still presented, which would tear the form down mid-submit.
|
||||
// The Group is always present.
|
||||
.fullScreenCover(item: $scheduledStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
// phase45 — nil for an ordinary schedule; set when this row is a
|
||||
// planned follow-up, which makes the run a linked re-inspection.
|
||||
// Must precede preFillScheduleId: argument order follows the
|
||||
// property declaration order in StartInspectionView.
|
||||
parentServerId: t.parentServerId,
|
||||
preFillScheduleId: t.id,
|
||||
preFillScheduleInstructions: t.instructions
|
||||
)
|
||||
}
|
||||
// Also on the Group, not the List — see the comment above. Submitting the
|
||||
// last follow-up can flip this view to ContentUnavailableView while the
|
||||
// cover is still presented. `parentServerId` is what links the run back
|
||||
// to the flagged inspection; see the twin cover in DashboardStatsView.
|
||||
.fullScreenCover(item: $followUpStartTarget) { t in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
parentServerId: t.id,
|
||||
preFillFollowUpNote: t.note,
|
||||
preFillParentFormDataJSON: t.parentFormDataJSON
|
||||
)
|
||||
}
|
||||
.navigationTitle("My Inspections")
|
||||
// Confirmation before deletion — destructive action cannot be undone
|
||||
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
||||
@@ -90,9 +146,14 @@ struct MyInspectionsView: View {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
// Labelled, not a bare "+". `.titleAndIcon` is required:
|
||||
// SwiftUI collapses a toolbar Label to icon-only on its own,
|
||||
// which is what made this read as an unlabelled plus sign.
|
||||
Button { showNewInspection = true } label: {
|
||||
Image(systemName: "plus")
|
||||
Label("New Inspection", systemImage: "plus")
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showNewInspection) {
|
||||
@@ -112,13 +173,13 @@ struct MyInspectionsView: View {
|
||||
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)
|
||||
PhotoStore.remove(at: 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)
|
||||
PhotoStore.remove(at: path)
|
||||
}
|
||||
context.delete(issue)
|
||||
}
|
||||
|
||||
@@ -1,90 +1,355 @@
|
||||
// Views/Dashboard/NotificationsView.swift
|
||||
// ---------------------------------------
|
||||
// In-app notification inbox, backed by LocalNotification (see that file for why
|
||||
// the inbox is stored locally rather than re-read from the server each time).
|
||||
//
|
||||
// Every row used to look identical because the poll endpoint only returns
|
||||
// UNREAD notifications — the list was, by construction, all-unread with nothing
|
||||
// to distinguish. Now read state is real: unread rows carry a dot and a bold
|
||||
// title, read rows are muted, and reading is an explicit act (tap a row, or
|
||||
// Mark All Read) rather than a side effect of opening the screen.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MessageUI
|
||||
|
||||
// MARK: - Notifications Inbox
|
||||
// Shows the most recent notifications fetched during polling.
|
||||
// Notifications are already marked read on the server by pollNotifications().
|
||||
// MARK: - Inbox
|
||||
|
||||
struct NotificationsView: View {
|
||||
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
// Sorted newest-first. Filtered in Swift, not in the @Query predicate
|
||||
// (CLAUDE.md rule 3).
|
||||
@Query(sort: \LocalNotification.createdAt, order: .reverse)
|
||||
private var allNotifications: [LocalNotification]
|
||||
|
||||
enum Filter: String, CaseIterable, Identifiable {
|
||||
case all, unread, read
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .all: return "All"
|
||||
case .unread: return "Unread"
|
||||
case .read: return "Read"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var filter: Filter = .all
|
||||
@State private var isMarkingAll = false
|
||||
|
||||
private var unreadCount: Int { allNotifications.filter { !$0.isRead }.count }
|
||||
|
||||
private var visible: [LocalNotification] {
|
||||
switch filter {
|
||||
case .all: return allNotifications
|
||||
case .unread: return allNotifications.filter { !$0.isRead }
|
||||
case .read: return allNotifications.filter { $0.isRead }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if sync.recentNotifications.isEmpty {
|
||||
if !sync.isOnline {
|
||||
ContentUnavailableView(
|
||||
"Offline",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text("Notifications are delivered when you go online.")
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Notifications",
|
||||
systemImage: "bell.slash",
|
||||
description: Text("You\'re all caught up.")
|
||||
)
|
||||
}
|
||||
if allNotifications.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
List(sync.recentNotifications) { notif in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: iconName(for: notif.eventType))
|
||||
.foregroundStyle(iconColor(for: notif.eventType))
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(notif.title)
|
||||
.font(.callout.bold())
|
||||
.lineLimit(2)
|
||||
Text(notif.body)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
if let date = SyncManager.isoFormatter.date(from: notif.createdAt) {
|
||||
Text(date.formatted(.relative(presentation: .named)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
VStack(spacing: 0) {
|
||||
// OUTSIDE the List, so it survives a filter that matches
|
||||
// nothing. As a list row it vanished with the rows —
|
||||
// selecting "Read" with nothing read left no way back.
|
||||
Picker("Show", selection: $filter) {
|
||||
ForEach(Filter.allCases) { f in
|
||||
Text(f.label).tag(f)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if visible.isEmpty {
|
||||
ContentUnavailableView(
|
||||
filter == .unread ? "All Caught Up" : "Nothing Read Yet",
|
||||
systemImage: filter == .unread ? "checkmark.circle" : "envelope.open",
|
||||
description: Text(filter == .unread
|
||||
? "You have no unread notifications."
|
||||
: "Notifications you open will appear here.")
|
||||
)
|
||||
Spacer(minLength: 0)
|
||||
} else {
|
||||
List {
|
||||
ForEach(visible) { notif in
|
||||
NavigationLink(value: notif) {
|
||||
NotificationRow(notification: notif)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if !notif.isRead {
|
||||
Button {
|
||||
Task { await sync.markNotificationRead(notif) }
|
||||
} label: {
|
||||
Label("Read", systemImage: "envelope.open")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
// On the ALWAYS-PRESENT Group, never inside the List — same hazard as
|
||||
// rule 68. Opening a notification marks it read, which removes it from
|
||||
// the "Unread" filter; if that was the last row, the List is replaced by
|
||||
// an empty state and a destination declared inside it would be torn
|
||||
// down, popping the detail view out from under the inspector as they
|
||||
// read it.
|
||||
.navigationDestination(for: LocalNotification.self) { notif in
|
||||
NotificationDetailView(notification: notif)
|
||||
}
|
||||
.navigationTitle(unreadCount > 0 ? "Notifications (\(unreadCount))" : "Notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
sync.markNotificationsViewed()
|
||||
.toolbar {
|
||||
if unreadCount > 0 {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task {
|
||||
isMarkingAll = true
|
||||
await sync.markAllNotificationsRead()
|
||||
isMarkingAll = false
|
||||
}
|
||||
} label: {
|
||||
if isMarkingAll {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Mark All Read", systemImage: "envelope.open")
|
||||
}
|
||||
}
|
||||
.labelStyle(.titleAndIcon) // rule 72
|
||||
.disabled(isMarkingAll)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await sync.pollNotifications()
|
||||
sync.markNotificationsViewed()
|
||||
}
|
||||
// Keep the sidebar badge honest if read state changed elsewhere (a
|
||||
// swipe, the detail view, or a push that landed while this was open).
|
||||
.onAppear { sync.refreshUnreadNotificationCount() }
|
||||
}
|
||||
|
||||
private func iconName(for eventType: String?) -> String {
|
||||
switch eventType {
|
||||
case "inspection_completed": return "checkmark.circle.fill"
|
||||
case "issue_flagged": return "exclamationmark.triangle.fill"
|
||||
case "issue_resolved": return "checkmark.seal.fill"
|
||||
case "sla_alert": return "clock.badge.exclamationmark"
|
||||
case "follow_up_required": return "exclamationmark.arrow.circlepath"
|
||||
default: return "bell.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func iconColor(for eventType: String?) -> Color {
|
||||
switch eventType {
|
||||
case "inspection_completed": return .green
|
||||
case "issue_flagged": return .orange
|
||||
case "issue_resolved": return .green
|
||||
case "sla_alert": return .red
|
||||
case "follow_up_required": return .orange
|
||||
default: return .blue
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
if !sync.isOnline {
|
||||
ContentUnavailableView(
|
||||
"Offline",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text("Notifications are delivered when you go online.")
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Notifications",
|
||||
systemImage: "bell.slash",
|
||||
description: Text("You're all caught up.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
struct NotificationRow: View {
|
||||
let notification: LocalNotification
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
// Unread marker. A filled dot rather than colour alone, so the
|
||||
// distinction survives greyscale and colour-blind vision.
|
||||
Circle()
|
||||
.fill(notification.isRead ? Color.clear : Color.blue)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 6)
|
||||
|
||||
Image(systemName: NotificationStyle.icon(for: notification.eventType))
|
||||
.foregroundStyle(notification.isRead
|
||||
? Color.secondary
|
||||
: NotificationStyle.color(for: notification.eventType))
|
||||
.frame(width: 24)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(notification.title)
|
||||
.font(notification.isRead ? .callout : .callout.bold())
|
||||
.foregroundStyle(notification.isRead ? .secondary : .primary)
|
||||
.lineLimit(2)
|
||||
Text(notification.body)
|
||||
.font(.caption)
|
||||
.foregroundStyle(notification.isRead ? Color(.tertiaryLabel) : .secondary)
|
||||
.lineLimit(2)
|
||||
Text(notification.createdAt.formatted(.relative(presentation: .named)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail
|
||||
|
||||
/// Full text of one notification, plus a route to whatever it refers to.
|
||||
///
|
||||
/// Opening this marks the notification read — the standard inbox contract, and
|
||||
/// the reason a tap is treated as a deliberate read action that also clears the
|
||||
/// user's web badge (rule 92).
|
||||
struct NotificationDetailView: View {
|
||||
|
||||
let notification: LocalNotification
|
||||
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
/// The issue this notification refers to, when it refers to one AND that
|
||||
/// issue is cached on this device. Absent is normal, not an error: the
|
||||
/// issue may belong to another inspector, or simply not be pulled yet.
|
||||
private var linkedIssue: LocalIssue? {
|
||||
guard let issueId = notification.issueId else { return nil }
|
||||
// Fetch-all + filter in Swift (rule 3), `try?` parenthesised (rule 25).
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
return all.first { $0.serverId == issueId }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: NotificationStyle.icon(for: notification.eventType))
|
||||
.foregroundStyle(NotificationStyle.color(for: notification.eventType))
|
||||
Text(NotificationStyle.label(for: notification.eventType))
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(notification.title)
|
||||
.font(.headline)
|
||||
Text(notification.body)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Received") {
|
||||
LabeledContent("Sent",
|
||||
value: notification.createdAt.formatted(date: .long, time: .shortened))
|
||||
if notification.isRead, let readAt = notification.readAt {
|
||||
LabeledContent("Read",
|
||||
value: readAt.formatted(date: .long, time: .shortened))
|
||||
}
|
||||
}
|
||||
|
||||
if let issueId = notification.issueId {
|
||||
Section("Related") {
|
||||
if let issue = linkedIssue {
|
||||
NavigationLink(value: issue) {
|
||||
Label("View Issue #\(issueId)", systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
} else {
|
||||
// Honest dead end rather than a link that goes nowhere.
|
||||
Label("Issue #\(issueId) is not on this device yet.",
|
||||
systemImage: "arrow.down.circle")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("It will appear under Issues after the next sync, "
|
||||
+ "if it is assigned to you.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !notification.isRead {
|
||||
Section {
|
||||
Button {
|
||||
Task { await sync.markNotificationRead(notification) }
|
||||
} label: {
|
||||
Label("Mark as Read", systemImage: "envelope.open")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Notification")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(for: LocalIssue.self) { issue in
|
||||
IssueDetailView(issue: issue)
|
||||
}
|
||||
.task {
|
||||
// Opening IS reading — the standard inbox contract.
|
||||
// Re-fires when returning from the issue detail, which is harmless:
|
||||
// markNotificationRead() no-ops once isRead is true.
|
||||
await sync.markNotificationRead(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event styling
|
||||
|
||||
/// Icon, colour and human label per server `event_type`.
|
||||
///
|
||||
/// Keys mirror the constants in `app/models/notification.py`; an unknown or nil
|
||||
/// type (rows predating the server's phase17 migration) falls back to a
|
||||
/// neutral bell rather than being hidden.
|
||||
nonisolated enum NotificationStyle {
|
||||
|
||||
static func icon(for eventType: String?) -> String {
|
||||
switch eventType {
|
||||
case "issue_assigned": return "person.crop.circle.badge.exclamationmark"
|
||||
case "issue_status": return "arrow.triangle.2.circlepath"
|
||||
case "issue_comment": return "text.bubble"
|
||||
case "issue_flagged": return "exclamationmark.triangle.fill"
|
||||
case "issue_follow_update": return "bell.badge"
|
||||
case "inspection_completed": return "checkmark.circle.fill"
|
||||
case "sla_alert": return "clock.badge.exclamationmark"
|
||||
case "score_alert": return "chart.line.downtrend.xyaxis"
|
||||
case "scheduled_inspection": return "calendar.badge.clock"
|
||||
case "followup_requested": return "exclamationmark.arrow.circlepath"
|
||||
case "admin_broadcast": return "megaphone"
|
||||
default: return "bell.fill"
|
||||
}
|
||||
}
|
||||
|
||||
static func color(for eventType: String?) -> Color {
|
||||
switch eventType {
|
||||
case "issue_assigned": return .blue
|
||||
case "issue_status": return .blue
|
||||
case "issue_comment": return .teal
|
||||
case "issue_flagged": return .orange
|
||||
case "issue_follow_update": return .blue
|
||||
case "inspection_completed": return .green
|
||||
case "sla_alert": return .red
|
||||
case "score_alert": return .red
|
||||
case "scheduled_inspection": return .indigo
|
||||
case "followup_requested": return .orange
|
||||
case "admin_broadcast": return .purple
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
static func label(for eventType: String?) -> String {
|
||||
switch eventType {
|
||||
case "issue_assigned": return "ISSUE ASSIGNED"
|
||||
case "issue_status": return "ISSUE STATUS"
|
||||
case "issue_comment": return "NEW COMMENT"
|
||||
case "issue_flagged": return "ISSUE FLAGGED"
|
||||
case "issue_follow_update": return "FOLLOWED ISSUE"
|
||||
case "inspection_completed": return "INSPECTION COMPLETED"
|
||||
case "sla_alert": return "SLA ALERT"
|
||||
case "score_alert": return "SCORE ALERT"
|
||||
case "scheduled_inspection": return "SCHEDULED INSPECTION"
|
||||
case "followup_requested": return "FOLLOW-UP REQUESTED"
|
||||
case "admin_broadcast": return "ANNOUNCEMENT"
|
||||
default: return "NOTIFICATION"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
// Views/Dashboard/PhotoDiagnosticView.swift
|
||||
// ------------------------------------------
|
||||
// READ-ONLY diagnostic for the lost-photo defect (August 2026).
|
||||
//
|
||||
// Reports, per record, which photo fields never received a server path and
|
||||
// whether the underlying JPEG is still recoverable on this device. It exists to
|
||||
// size the problem BEFORE any fix ships, because the fix sequence is
|
||||
// destructive if run in the wrong order: correcting cleanupOrphanedPhotos()
|
||||
// deletes exactly the files this report is looking for.
|
||||
//
|
||||
// THIS VIEW MUST STAY READ-ONLY. It never calls context.save(), context.delete(),
|
||||
// FileManager write/remove, or any APIClient method. It only fetches, reads
|
||||
// files' existence, and formats text. Anything that repairs data belongs in a
|
||||
// separate, explicitly-named screen — a diagnostic the user cannot trust to be
|
||||
// safe is a diagnostic they will not run.
|
||||
//
|
||||
// ── What it looks for ────────────────────────────────────────────────────────
|
||||
// A photo is "lost" when a form field still holds the local sentinel
|
||||
// ("local://<path>") instead of a server path ("uploads/..."). APIClient
|
||||
// .submitInspection() rewrites that sentinel to "" in the request body only —
|
||||
// inspection.formData is left intact — so the device still knows which field
|
||||
// the photo belonged to. That is what makes recovery possible, and what this
|
||||
// report enumerates.
|
||||
//
|
||||
// ── File resolution ──────────────────────────────────────────────────────────
|
||||
// Stored paths are ABSOLUTE and include the app container UUID, which iOS
|
||||
// changes on update / reinstall / restore. So a stored path that no longer
|
||||
// exists does NOT mean the file is gone: the same filename usually still exists
|
||||
// under the current container. Filenames are UUIDs generated per save, so a
|
||||
// basename match is unambiguous and safe to rely on.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
// MARK: - Model
|
||||
|
||||
/// Where the JPEG actually is, independent of what the database claims.
|
||||
enum PhotoFileState {
|
||||
/// The stored absolute path resolves — nothing has moved.
|
||||
case foundAtStoredPath
|
||||
/// The stored path is stale (container UUID changed) but a file with the
|
||||
/// same unique filename exists now. Recoverable; carries the live path.
|
||||
case foundByFilename(String)
|
||||
/// No file with that name anywhere under the photo directories.
|
||||
case missing
|
||||
|
||||
var isRecoverable: Bool {
|
||||
if case .missing = self { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// How much damage has already been done for one missing photo.
|
||||
enum PhotoLossSeverity: Int {
|
||||
/// Submitted to the server with the field blank — the loss is live, and
|
||||
/// only a PATCH can repair it.
|
||||
case lostOnServer = 0
|
||||
/// Completed but still in the outbox: it will be submitted blank on the
|
||||
/// next sync unless the fix lands first.
|
||||
case willBeLostOnNextSync = 1
|
||||
/// Still a draft — nothing lost yet.
|
||||
case draftNotYetSubmitted = 2
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .lostOnServer: return "LOST ON SERVER"
|
||||
case .willBeLostOnNextSync: return "WILL BE LOST"
|
||||
case .draftNotYetSubmitted: return "DRAFT (safe)"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .lostOnServer: return .red
|
||||
case .willBeLostOnNextSync: return .orange
|
||||
case .draftNotYetSubmitted: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoDiagnosticEntry: Identifiable {
|
||||
let id = UUID()
|
||||
|
||||
let kind: String // "Inspection" | "Issue"
|
||||
let localId: String
|
||||
let serverId: Int? // the row to PATCH, when already submitted
|
||||
let title: String // template / issue description
|
||||
let facilityName: String
|
||||
let date: Date
|
||||
|
||||
let fieldId: String? // nil for issue photos (no form field)
|
||||
let storedPath: String
|
||||
let fileState: PhotoFileState
|
||||
/// PendingPhoto.uploadStatus, or nil when no row survives for this photo.
|
||||
let uploadStatus: String?
|
||||
let severity: PhotoLossSeverity
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
struct PhotoDiagnosticView: View {
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var entries: [PhotoDiagnosticEntry] = []
|
||||
@State private var failedPhotoCount = 0
|
||||
@State private var totalPhotoRows = 0
|
||||
@State private var scanned = false
|
||||
@State private var copied = false
|
||||
|
||||
private var lostOnServer: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.severity == .lostOnServer }
|
||||
}
|
||||
private var willBeLost: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.severity == .willBeLostOnNextSync }
|
||||
}
|
||||
private var drafts: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.severity == .draftNotYetSubmitted }
|
||||
}
|
||||
private var recoverable: [PhotoDiagnosticEntry] {
|
||||
entries.filter { $0.fileState.isRecoverable }
|
||||
}
|
||||
private var unrecoverable: [PhotoDiagnosticEntry] {
|
||||
entries.filter { !$0.fileState.isRecoverable }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Label(
|
||||
"This screen only reads. It does not upload, delete, "
|
||||
+ "repair, or modify anything.",
|
||||
systemImage: "lock.shield"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !scanned {
|
||||
Section {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Scanning…").padding(.leading, 8)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
summarySection
|
||||
if !entries.isEmpty {
|
||||
entrySection("Lost on Server", lostOnServer,
|
||||
footer: "Submitted with the photo field blank. "
|
||||
+ "Repairing these needs a re-upload plus a PATCH.")
|
||||
entrySection("Will Be Lost on Next Sync", willBeLost,
|
||||
footer: "Still in the outbox. These are submitted "
|
||||
+ "blank unless the fix lands first.")
|
||||
entrySection("Drafts", drafts,
|
||||
footer: "Not submitted yet — nothing lost.")
|
||||
}
|
||||
copySection
|
||||
}
|
||||
}
|
||||
.navigationTitle("Photo Diagnostic")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { runScan() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
scanned = false
|
||||
runScan()
|
||||
} label: {
|
||||
Label("Rescan", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sections ──────────────────────────────────────────────────────────
|
||||
|
||||
private var summarySection: some View {
|
||||
Section("Summary") {
|
||||
row("Photos affected", "\(entries.count)",
|
||||
tint: entries.isEmpty ? .green : .red)
|
||||
row("Already lost on server", "\(lostOnServer.count)",
|
||||
tint: lostOnServer.isEmpty ? .secondary : .red)
|
||||
row("Will be lost on next sync", "\(willBeLost.count)",
|
||||
tint: willBeLost.isEmpty ? .secondary : .orange)
|
||||
row("Still recoverable (file on device)", "\(recoverable.count)",
|
||||
tint: recoverable.isEmpty ? .secondary : .green)
|
||||
row("File gone — unrecoverable", "\(unrecoverable.count)",
|
||||
tint: unrecoverable.isEmpty ? .secondary : .red)
|
||||
|
||||
Divider()
|
||||
row("Photo upload rows total", "\(totalPhotoRows)", tint: .secondary)
|
||||
row("Rows marked failed", "\(failedPhotoCount)",
|
||||
tint: failedPhotoCount == 0 ? .secondary : .orange)
|
||||
|
||||
if entries.isEmpty {
|
||||
Label("No missing photos found on this device.",
|
||||
systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func entrySection(_ title: String,
|
||||
_ list: [PhotoDiagnosticEntry],
|
||||
footer: String) -> some View {
|
||||
if !list.isEmpty {
|
||||
Section {
|
||||
ForEach(list) { e in entryRow(e) }
|
||||
} header: {
|
||||
Text("\(title) (\(list.count))")
|
||||
} footer: {
|
||||
Text(footer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func entryRow(_ e: PhotoDiagnosticEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(e.severity.label)
|
||||
.font(.caption2.bold())
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(e.severity.color.opacity(0.15))
|
||||
.foregroundStyle(e.severity.color)
|
||||
.clipShape(Capsule())
|
||||
Spacer()
|
||||
if let sid = e.serverId {
|
||||
Text("server #\(sid)")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("not on server")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("\(e.kind): \(e.title)").font(.callout.bold())
|
||||
Text(e.facilityName).font(.caption).foregroundStyle(.secondary)
|
||||
Text(e.date.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
|
||||
if let fid = e.fieldId {
|
||||
Text("Field ID: \(fid)").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Upload status: \(e.uploadStatus ?? "no record")")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
|
||||
switch e.fileState {
|
||||
case .foundAtStoredPath:
|
||||
Label("File present at stored path — recoverable",
|
||||
systemImage: "checkmark.circle")
|
||||
.font(.caption2).foregroundStyle(.green)
|
||||
case .foundByFilename:
|
||||
Label("Stored path stale (container changed) — file found by "
|
||||
+ "name, recoverable", systemImage: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2).foregroundStyle(.orange)
|
||||
case .missing:
|
||||
Label("File not on this device — unrecoverable",
|
||||
systemImage: "exclamationmark.triangle")
|
||||
.font(.caption2).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var copySection: some View {
|
||||
Section {
|
||||
Button {
|
||||
UIPasteboard.general.string = textReport()
|
||||
copied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
|
||||
} label: {
|
||||
Label(copied ? "Copied" : "Copy Full Report",
|
||||
systemImage: copied ? "checkmark" : "doc.on.doc")
|
||||
}
|
||||
} footer: {
|
||||
Text("Copies a plain-text version, including full file paths, for "
|
||||
+ "sharing or for driving a recovery pass.")
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ label: String, _ value: String, tint: Color) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
Spacer()
|
||||
Text(value).bold().foregroundStyle(tint)
|
||||
}
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
// ── Scan (read-only) ──────────────────────────────────────────────────
|
||||
|
||||
private func runScan() {
|
||||
// Fetch-all + filter in Swift — no #Predicate (CLAUDE.md rule 3),
|
||||
// and every `try?` parenthesised before `??` (rule 25).
|
||||
let inspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
let issues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
let photos = (try? context.fetch(FetchDescriptor<PendingPhoto>())) ?? []
|
||||
let facilities = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
|
||||
let templates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
|
||||
totalPhotoRows = photos.count
|
||||
failedPhotoCount = photos.filter { $0.uploadStatus == "failed" }.count
|
||||
|
||||
var facilityName: [Int: String] = [:]
|
||||
for f in facilities { facilityName[f.serverId] = f.name }
|
||||
var templateName: [Int: String] = [:]
|
||||
for t in templates { templateName[t.serverId] = t.name }
|
||||
|
||||
// basename -> live path, for re-resolving stale container paths.
|
||||
let diskIndex = buildDiskIndex()
|
||||
|
||||
// Photo rows keyed by the file they point at, so a form field can be
|
||||
// matched to its upload record.
|
||||
var photoByPath: [String: PendingPhoto] = [:]
|
||||
for p in photos { photoByPath[p.localFilePath] = p }
|
||||
|
||||
var found: [PhotoDiagnosticEntry] = []
|
||||
|
||||
// ── Inspections: form fields still holding the local:// sentinel ──
|
||||
for insp in inspections {
|
||||
for (fieldId, value) in insp.formData {
|
||||
guard let s = value as? String, s.hasPrefix("local://") else { continue }
|
||||
let path = String(s.dropFirst("local://".count))
|
||||
|
||||
let severity: PhotoLossSeverity
|
||||
if insp.status == "draft" {
|
||||
severity = .draftNotYetSubmitted
|
||||
} else if insp.serverId != nil || insp.syncStatus == "synced" {
|
||||
severity = .lostOnServer
|
||||
} else {
|
||||
severity = .willBeLostOnNextSync
|
||||
}
|
||||
|
||||
found.append(PhotoDiagnosticEntry(
|
||||
kind: "Inspection",
|
||||
localId: insp.localId,
|
||||
serverId: insp.serverId,
|
||||
title: templateName[insp.templateServerId]
|
||||
?? "Template #\(insp.templateServerId)",
|
||||
facilityName: facilityName[insp.facilityServerId]
|
||||
?? "Facility #\(insp.facilityServerId)",
|
||||
date: insp.inspectionDate,
|
||||
fieldId: fieldId,
|
||||
storedPath: path,
|
||||
fileState: resolve(path, diskIndex),
|
||||
uploadStatus: photoByPath[path]?.uploadStatus,
|
||||
severity: severity
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issues: local photos that never produced a server path ────────
|
||||
// Same defect, different surface. processIssueQueue clears
|
||||
// photoLocalPaths only after a successful submit, so a synced issue
|
||||
// still holding local paths with no server paths lost its evidence.
|
||||
for issue in issues {
|
||||
guard !issue.photoLocalPaths.isEmpty else { continue }
|
||||
let missingServerSide = issue.photoServerPaths.count < issue.photoLocalPaths.count
|
||||
guard missingServerSide else { continue }
|
||||
|
||||
let severity: PhotoLossSeverity
|
||||
if issue.syncStatus == "synced" || issue.serverId != nil {
|
||||
severity = .lostOnServer
|
||||
} else if issue.syncStatus == "failed" {
|
||||
severity = .willBeLostOnNextSync
|
||||
} else {
|
||||
severity = .willBeLostOnNextSync
|
||||
}
|
||||
|
||||
for path in issue.photoLocalPaths {
|
||||
// A path already mirrored server-side is fine — skip it.
|
||||
if let p = photoByPath[path], p.uploadStatus == "uploaded" { continue }
|
||||
|
||||
found.append(PhotoDiagnosticEntry(
|
||||
kind: "Issue",
|
||||
localId: issue.localId,
|
||||
serverId: issue.serverId,
|
||||
title: issue.issueDescription.isEmpty
|
||||
? "(no description)"
|
||||
: String(issue.issueDescription.prefix(60)),
|
||||
facilityName: facilityName[issue.facilityServerId]
|
||||
?? "Facility #\(issue.facilityServerId)",
|
||||
date: issue.createdAt,
|
||||
fieldId: nil,
|
||||
storedPath: path,
|
||||
fileState: resolve(path, diskIndex),
|
||||
uploadStatus: photoByPath[path]?.uploadStatus,
|
||||
severity: severity
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
entries = found.sorted {
|
||||
if $0.severity.rawValue != $1.severity.rawValue {
|
||||
return $0.severity.rawValue < $1.severity.rawValue
|
||||
}
|
||||
return $0.date > $1.date
|
||||
}
|
||||
scanned = true
|
||||
}
|
||||
|
||||
/// Map of filename -> current absolute path for every file under the photo
|
||||
/// directories. Filenames are per-save UUIDs, so collisions are not a
|
||||
/// practical concern and a basename match identifies a file uniquely.
|
||||
private func buildDiskIndex() -> [String: String] {
|
||||
let fm = FileManager.default
|
||||
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
else { return [:] }
|
||||
|
||||
var index: [String: String] = [:]
|
||||
// Both directories the app writes to today. Enumerating rather than
|
||||
// assuming, so a file left by an older build is still found.
|
||||
for sub in ["JQC/Photos", "JQC/ResultPhotos", "JQCPhotos"] {
|
||||
let dir = docs.appendingPathComponent(sub, isDirectory: true)
|
||||
guard let files = try? fm.contentsOfDirectory(
|
||||
at: dir, includingPropertiesForKeys: nil
|
||||
) else { continue }
|
||||
for f in files { index[f.lastPathComponent] = f.path }
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
private func resolve(_ storedPath: String,
|
||||
_ diskIndex: [String: String]) -> PhotoFileState {
|
||||
if FileManager.default.fileExists(atPath: storedPath) {
|
||||
return .foundAtStoredPath
|
||||
}
|
||||
let name = URL(fileURLWithPath: storedPath).lastPathComponent
|
||||
if let live = diskIndex[name] {
|
||||
return .foundByFilename(live)
|
||||
}
|
||||
return .missing
|
||||
}
|
||||
|
||||
// ── Text report ───────────────────────────────────────────────────────
|
||||
|
||||
private func textReport() -> String {
|
||||
var out = """
|
||||
JQC PHOTO DIAGNOSTIC (read-only)
|
||||
Generated: \(Date().formatted(date: .abbreviated, time: .standard))
|
||||
Server: \(ServerConfig.current)
|
||||
|
||||
SUMMARY
|
||||
Photos affected .................. \(entries.count)
|
||||
Already lost on server ......... \(lostOnServer.count)
|
||||
Will be lost on next sync ...... \(willBeLost.count)
|
||||
Drafts (safe) .................. \(drafts.count)
|
||||
Recoverable (file present) ..... \(recoverable.count)
|
||||
Unrecoverable (file gone) ...... \(unrecoverable.count)
|
||||
Photo rows total ............... \(totalPhotoRows)
|
||||
Rows marked failed ............. \(failedPhotoCount)
|
||||
|
||||
DETAIL
|
||||
|
||||
"""
|
||||
for e in entries {
|
||||
let state: String
|
||||
switch e.fileState {
|
||||
case .foundAtStoredPath: state = "file OK at stored path"
|
||||
case .foundByFilename(let p): state = "file found by name -> \(p)"
|
||||
case .missing: state = "FILE MISSING"
|
||||
}
|
||||
out += """
|
||||
[\(e.severity.label)] \(e.kind) \(e.serverId.map { "server #\($0)" } ?? "(unsent)")
|
||||
title : \(e.title)
|
||||
facility : \(e.facilityName)
|
||||
date : \(e.date.formatted(date: .abbreviated, time: .shortened))
|
||||
localId : \(e.localId)
|
||||
fieldId : \(e.fieldId ?? "-")
|
||||
storedPath : \(e.storedPath)
|
||||
upload : \(e.uploadStatus ?? "no record")
|
||||
fileState : \(state)
|
||||
|
||||
"""
|
||||
}
|
||||
if entries.isEmpty { out += "(none)\n" }
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,60 @@
|
||||
// SyncManager.pullScheduledInspections().
|
||||
//
|
||||
// Two consumers share one ScheduledRow:
|
||||
// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView
|
||||
// • ScheduledInspectionsCard — VStack card for the Dashboard ScrollView.
|
||||
// Presentational only; it reports taps via `onStart` and DashboardStatsView
|
||||
// owns the .fullScreenCover on its always-present ScrollView.
|
||||
// • MyInspectionsView renders its own "Scheduled" List section inline,
|
||||
// reusing ScheduledRow, with the start cover attached to the List.
|
||||
// reusing ScheduledRow, with the start cover on the enclosing Group.
|
||||
// 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.
|
||||
// Both cover owners are views that outlive the schedule rows themselves —
|
||||
// submitting the last scheduled inspection empties the @Query while the cover is
|
||||
// still up, so a cover owned by the self-hiding card would be torn down with it.
|
||||
//
|
||||
// The schedule lifecycle stays server-driven: the submission carries
|
||||
// `scheduled_inspection_id` and the server deactivates (one-time) or rolls
|
||||
// forward (recurring). ExecuteInspectionView only invalidates the local cache
|
||||
// row; pullScheduledInspections() re-reads the authoritative state.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Start target snapshot
|
||||
|
||||
/// Plain-value snapshot of the tapped schedule, used as the `.fullScreenCover`
|
||||
/// item instead of the `LocalScheduledInspection` itself.
|
||||
///
|
||||
/// The model object is unsafe to hold across the presentation: the cached row is
|
||||
/// deleted while the cover is still on screen — by `resolveAndFulfillSchedule()`
|
||||
/// the instant Submit is tapped, and by `pullScheduledInspections()` once the
|
||||
/// server stops returning it. Reading a deleted `PersistentModel` traps, and a
|
||||
/// `@Query` that empties out would also tear the cover down mid-submit. Copying
|
||||
/// the values at tap time removes both hazards.
|
||||
struct ScheduledStartTarget: Identifiable {
|
||||
/// Schedule `serverId` — also the identity for `.fullScreenCover(item:)`.
|
||||
let id: Int
|
||||
let templateServerId: Int
|
||||
let facilityServerId: Int
|
||||
/// Manager-authored instructions for this occurrence. Server field is still
|
||||
/// `notes` (API key `notes`, column `scheduled_inspections.notes`); only the
|
||||
/// user-facing wording is "Instructions".
|
||||
let instructions: String?
|
||||
/// Inspection this schedule is a planned follow-up of (phase45), or nil for
|
||||
/// an ordinary schedule. Passed to `StartInspectionView` as `parentServerId`
|
||||
/// so the run lands as a linked re-inspection — the whole point of
|
||||
/// "Schedule Follow-up".
|
||||
let parentServerId: Int?
|
||||
|
||||
init(_ schedule: LocalScheduledInspection) {
|
||||
self.id = schedule.serverId
|
||||
self.templateServerId = schedule.templateServerId
|
||||
self.facilityServerId = schedule.facilityServerId
|
||||
self.instructions = schedule.instructions
|
||||
self.parentServerId = schedule.parentInspectionServerId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared row
|
||||
|
||||
struct ScheduledRow: View {
|
||||
@@ -60,6 +103,24 @@ struct ScheduledRow: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
// Instructions preview — so the inspector can see there is
|
||||
// something to read before committing to the tap. Truncated to
|
||||
// one line; the full text is shown on the start screen and
|
||||
// again above the form itself.
|
||||
if let instructions = schedule.instructions {
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
Text(instructions)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
@@ -81,18 +142,33 @@ struct ScheduledInspectionsCard: View {
|
||||
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||
private var scheduled: [LocalScheduledInspection]
|
||||
|
||||
@State private var startTarget: LocalScheduledInspection? = nil
|
||||
/// Rows still awaiting action. Filtered in Swift rather than in the @Query
|
||||
/// predicate, per CLAUDE.md rule 3. `fulfilledLocally` is set at submit and
|
||||
/// cleared by the next pull, so a completed schedule leaves the card at
|
||||
/// once and reappears only when the server says it is due again.
|
||||
private var visible: [LocalScheduledInspection] {
|
||||
scheduled.filter { !$0.fulfilledLocally }
|
||||
}
|
||||
|
||||
/// Tap handler. The `.fullScreenCover` deliberately lives in the PARENT
|
||||
/// (`DashboardStatsView`, on its always-present ScrollView) rather than here:
|
||||
/// this card self-hides, and submitting the last scheduled inspection removes
|
||||
/// the final row while the cover is still on screen. A cover owned by a view
|
||||
/// that disappears is torn down with it, yanking the form away from the
|
||||
/// inspector mid-submit. Keeping the card purely presentational also keeps
|
||||
/// the empty case a true `EmptyView`, so the dashboard stack adds no spacing.
|
||||
let onStart: (ScheduledStartTarget) -> Void
|
||||
|
||||
var body: some View {
|
||||
if !scheduled.isEmpty {
|
||||
if !visible.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("SCHEDULED")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(1)
|
||||
|
||||
ForEach(scheduled) { s in
|
||||
Button { startTarget = s } label: {
|
||||
ForEach(visible) { s in
|
||||
Button { onStart(ScheduledStartTarget(s)) } label: {
|
||||
ScheduledRow(schedule: s)
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
@@ -101,13 +177,6 @@ struct ScheduledInspectionsCard: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
// Cover attached to the stable VStack root (mirrors DraftResumeBanner).
|
||||
.fullScreenCover(item: $startTarget) { s in
|
||||
StartInspectionView(
|
||||
preFillTemplateId: s.templateServerId,
|
||||
preFillFacilityId: s.facilityServerId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,25 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Read-only investigation aid for the lost-photo defect. Placed
|
||||
// above Cache deliberately: "Clear Reference Cache" sits next to it
|
||||
// and the diagnostic must be run BEFORE anything that touches
|
||||
// stored data, while the evidence is still intact.
|
||||
Section("Diagnostics") {
|
||||
NavigationLink {
|
||||
PhotoDiagnosticView()
|
||||
} label: {
|
||||
// Not a photo.badge.* symbol — those are not universally
|
||||
// available on iOS 17 (CLAUDE.md rule 41).
|
||||
Label("Photo Diagnostic", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
Text("Reports inspection and issue photos that never reached the "
|
||||
+ "server, and whether the original file is still on this "
|
||||
+ "device. Read-only — changes nothing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Cache") {
|
||||
Button {
|
||||
showClearCacheAlert = true
|
||||
@@ -110,13 +129,17 @@ struct SettingsView: View {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
// Do NOT clear server-pulled data on a plain logout —
|
||||
// the user is logging out of the same server, so cached
|
||||
// facilities, issues, and templates are still valid on
|
||||
// their next login. Clearing here leaves the issues list
|
||||
// empty until a full sync succeeds, which breaks offline use.
|
||||
// Server-pulled data is only cleared when switching servers
|
||||
// (see the Switch & Log Out alert below).
|
||||
// Do NOT purge on a plain logout — the same inspector
|
||||
// signing back into the same server must still find
|
||||
// their facilities, templates and issues there, or the
|
||||
// app is unusable offline until a full sync succeeds.
|
||||
//
|
||||
// What was missing is not a purge here: it is the check
|
||||
// that the next sign-in is the SAME person.
|
||||
// AuthManager.reconcileSessionScope() now does that at
|
||||
// login and purges only on an identity change, so a
|
||||
// different inspector no longer inherits this one's
|
||||
// issues (rule 88).
|
||||
sync.resetNotificationPoller()
|
||||
await auth.logout()
|
||||
}
|
||||
@@ -188,7 +211,14 @@ struct SettingsView: View {
|
||||
settingsServer = chosen
|
||||
pendingServer = nil
|
||||
Task {
|
||||
clearServerPulledData()
|
||||
// Purge EVERYTHING, not just issues. The old
|
||||
// clearServerPulledData() deleted LocalIssue alone,
|
||||
// leaving LocalInspection rows carrying facility and
|
||||
// template ids that name different rows on the server
|
||||
// being switched to — ready to be submitted against it.
|
||||
// Nothing local survives a server change (rule 88).
|
||||
sync.purgeSessionScopedData(keepingUserId: nil, sameServer: false)
|
||||
SessionScope.clear()
|
||||
sync.resetNotificationPoller()
|
||||
await auth.logout()
|
||||
}
|
||||
@@ -199,7 +229,7 @@ struct SettingsView: View {
|
||||
}
|
||||
} message: {
|
||||
if let chosen = pendingServer {
|
||||
Text("Switching to \(chosen.displayName) will log you out. All cached server data will be cleared. You will need to log in again.")
|
||||
Text("Switching to \(chosen.displayName) will log you out and erase all local data for this server — including any inspections or issues that have not synced yet. You will need to log in again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,18 +253,4 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete every LocalIssue that has ever been assigned a serverId.
|
||||
/// This covers two categories:
|
||||
/// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced")
|
||||
/// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil)
|
||||
/// — their serverIds are meaningless on a different server, so they must go too.
|
||||
/// The only records preserved are truly pending device-created issues
|
||||
/// (serverId == nil, syncStatus == "pending") that have never reached any server.
|
||||
private func clearServerPulledData() {
|
||||
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||
allIssues
|
||||
.filter { $0.serverId != nil }
|
||||
.forEach { context.delete($0) }
|
||||
try? context.save()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,60 @@ struct StartInspectionView: View {
|
||||
var parentServerId: Int? = nil
|
||||
var parentLocalId: String? = nil
|
||||
|
||||
/// The director's note explaining what the follow-up should address, set when
|
||||
/// the inspector taps a row in the FOLLOW-UP REQUESTED card. Passed as a plain
|
||||
/// String rather than read from SwiftData here, for the same reason
|
||||
/// FollowUpStartTarget exists — the cached request row is invalidated at
|
||||
/// submit while this flow is still on screen. Nil for a re-inspection the
|
||||
/// inspector started themselves from CompletedInspectionView.
|
||||
var preFillFollowUpNote: String? = nil
|
||||
|
||||
/// The flagged inspection's answers, JSON-encoded, used to pre-fill this
|
||||
/// re-inspection when the parent `LocalInspection` is not on this device.
|
||||
///
|
||||
/// The local-parent lookup in `startInspection()` covers the
|
||||
/// CompletedInspectionView path, where the inspector is re-inspecting
|
||||
/// something they just finished on this iPad. It does **not** cover a
|
||||
/// follow-up raised on the web: that parent synced long ago and is often
|
||||
/// absent locally, so the lookup found nothing and the form came up blank —
|
||||
/// where the web pre-fills it. Cached at pull time on
|
||||
/// `LocalFollowUpRequest`, so this works offline too. Nil for every other
|
||||
/// start path.
|
||||
var preFillParentFormDataJSON: String? = nil
|
||||
|
||||
// ── Scheduled inspection launch ───────────────────────────────────────
|
||||
/// Server ID of the ScheduledInspection this run fulfils, passed when the
|
||||
/// inspector taps Start on a scheduled row. Carried onto the LocalInspection
|
||||
/// so submitInspection() can send it; without it the server cannot fulfil
|
||||
/// the schedule and the "Scheduled" banner never clears.
|
||||
var preFillScheduleId: Int? = nil
|
||||
|
||||
/// Instructions the manager attached to this schedule ("Instructions" in the
|
||||
/// UI; still `notes` on the wire and in the DB). Passed as a plain String
|
||||
/// rather than read from SwiftData here, for the same reason
|
||||
/// ScheduledStartTarget exists — the cached schedule row is deleted at
|
||||
/// submit while this flow is still on screen.
|
||||
var preFillScheduleInstructions: String? = nil
|
||||
|
||||
// ── Derived lists ─────────────────────────────────────────────────────
|
||||
|
||||
/// Facilities this inspector may actually start work at.
|
||||
///
|
||||
/// The cache can hold a facility that is no longer in scope — SyncManager
|
||||
/// keeps such a row (marked inactive) when an unsynced draft still needs
|
||||
/// its name, rather than deleting it and showing "Unknown Facility". It
|
||||
/// must not be offered for NEW work, and neither must its contract, so
|
||||
/// every derived list below starts here rather than from `facilities`.
|
||||
private var availableFacilities: [LocalFacility] {
|
||||
facilities.filter { $0.isActive }
|
||||
}
|
||||
|
||||
/// Unique contracts (projectId, projectName) sorted by name.
|
||||
/// Facilities with projectId == 0 are grouped under "No Contract".
|
||||
private var contracts: [(id: Int, name: String)] {
|
||||
var seen = Set<Int>()
|
||||
var result: [(id: Int, name: String)] = []
|
||||
for f in facilities {
|
||||
for f in availableFacilities {
|
||||
if seen.insert(f.projectId).inserted {
|
||||
result.append((id: f.projectId, name: f.projectName))
|
||||
}
|
||||
@@ -58,7 +104,7 @@ struct StartInspectionView: View {
|
||||
private var filteredFacilities: [LocalFacility] {
|
||||
guard let pid = selectedProjectId else { return [] }
|
||||
var seen = Set<Int>()
|
||||
return facilities
|
||||
return availableFacilities
|
||||
.filter { $0.projectId == pid }
|
||||
.filter { seen.insert($0.serverId).inserted }
|
||||
}
|
||||
@@ -78,6 +124,25 @@ struct StartInspectionView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// ── Instructions from the schedule ─────────────────────────
|
||||
// Shown first: this is the reason the inspector was sent here,
|
||||
// and it may change what they carry in with them. Repeated
|
||||
// above the form itself in ExecuteInspectionView.
|
||||
if let instructions = preFillScheduleInstructions,
|
||||
!instructions.isEmpty {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Instructions", systemImage: "info.circle.fill")
|
||||
.font(.callout.bold())
|
||||
.foregroundStyle(.blue)
|
||||
Text(instructions)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Re-inspection notice ───────────────────────────────────
|
||||
if parentServerId != nil {
|
||||
Section {
|
||||
@@ -90,6 +155,16 @@ struct StartInspectionView: View {
|
||||
Text("This will be linked to inspection #\(parentServerId!).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// What the director actually asked for. Shown
|
||||
// here rather than in its own section so it
|
||||
// reads as part of the request, and repeated in
|
||||
// full because the card truncates it to a line.
|
||||
if let note = preFillFollowUpNote, !note.isEmpty {
|
||||
Text(note)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
@@ -223,7 +298,11 @@ struct StartInspectionView: View {
|
||||
}
|
||||
.navigationDestination(isPresented: $navigateToExecution) {
|
||||
if let inspection = createdInspection {
|
||||
ExecuteInspectionView(inspection: inspection)
|
||||
// onFinished closes THIS cover rather than just popping back
|
||||
// to the form the inspector already finished with — see the
|
||||
// property's doc comment on ExecuteInspectionView.
|
||||
ExecuteInspectionView(inspection: inspection,
|
||||
onFinished: { dismiss() })
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -266,23 +345,38 @@ struct StartInspectionView: View {
|
||||
// Link to parent if this is a re-inspection
|
||||
inspection.parentServerId = parentServerId
|
||||
inspection.parentLocalId = parentLocalId
|
||||
// Link to the schedule if launched from a scheduled row
|
||||
inspection.scheduledInspectionServerId = preFillScheduleId
|
||||
|
||||
// ── Pre-fill from parent (mirrors web app behaviour) ───────────────
|
||||
// Copy non-scoring field values from the parent inspection so the
|
||||
// inspector doesn't re-enter static data. Scoring fields (rating,
|
||||
// pass_fail) and media fields (image, signature) are always left blank
|
||||
// so every scoreable item must be re-evaluated fresh.
|
||||
if let parentId = parentServerId {
|
||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
if let parent = allInspections.first(where: { $0.serverId == parentId }),
|
||||
!parent.formData.isEmpty {
|
||||
if parentServerId != nil {
|
||||
// Prefer the local parent (the CompletedInspectionView path, where
|
||||
// the inspector just finished it on this iPad); otherwise fall back
|
||||
// to the snapshot cached on the follow-up request. A follow-up
|
||||
// raised on the web has usually synced and been dropped locally, so
|
||||
// without the fallback this whole block silently no-opped and the
|
||||
// form came up blank — the bug this fixes.
|
||||
let parentData = resolvedParentFormData()
|
||||
|
||||
// Fetch the template schema to identify field types.
|
||||
// Split into two statements — avoids Xcode 26 #Predicate
|
||||
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
let tid = templateId
|
||||
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
||||
// Fetch the template schema to identify field types.
|
||||
// Split into two statements — avoids Xcode 26 #Predicate
|
||||
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||
let tid = templateId
|
||||
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
|
||||
|
||||
// The schema is what identifies which fields must NOT be carried
|
||||
// over, so without it there is no safe prefill: an empty exclude set
|
||||
// would copy *everything*, including the parent's `image` paths and
|
||||
// its ratings — attaching the previous inspection's photos as this
|
||||
// one's evidence and pre-answering the scoreable items. Copy nothing
|
||||
// instead. (Unreachable in practice: the template was chosen from
|
||||
// the local picker, so it is cached — this is a guard, not a case.)
|
||||
if !parentData.isEmpty, !schema.isEmpty {
|
||||
|
||||
// Build the set of field IDs that must NOT be carried over
|
||||
let excludeTypes: Set<String> = ["rating", "pass_fail", "image", "signature"]
|
||||
@@ -295,7 +389,6 @@ struct StartInspectionView: View {
|
||||
}
|
||||
|
||||
// Copy all parent values except excluded fields
|
||||
let parentData = parent.formData
|
||||
var prefilled: [String: Any] = [:]
|
||||
for (key, value) in parentData {
|
||||
if !excludeIds.contains(key) {
|
||||
@@ -314,4 +407,34 @@ struct StartInspectionView: View {
|
||||
createdInspection = inspection
|
||||
navigateToExecution = true
|
||||
}
|
||||
|
||||
/// The parent inspection's answers to pre-fill from, or `[:]` when there are
|
||||
/// none to carry.
|
||||
///
|
||||
/// Two sources, in order:
|
||||
/// 1. The local `LocalInspection` with a matching `serverId` — the
|
||||
/// re-inspection-from-history path, where the parent is on this device
|
||||
/// and is the freshest copy.
|
||||
/// 2. `preFillParentFormDataJSON`, snapshotted from the server at pull
|
||||
/// time — the follow-up-request path, where the parent has synced and
|
||||
/// is typically no longer local.
|
||||
///
|
||||
/// Source 1 is checked first but only wins when it actually holds values, so
|
||||
/// a stray empty local shell can't shadow a good server snapshot.
|
||||
private func resolvedParentFormData() -> [String: Any] {
|
||||
if let parentId = parentServerId {
|
||||
// Fetch-all then filter in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||
if let parent = allInspections.first(where: { $0.serverId == parentId }) {
|
||||
let localData = parent.formData
|
||||
if !localData.isEmpty { return localData }
|
||||
}
|
||||
}
|
||||
|
||||
guard let json = preFillParentFormDataJSON,
|
||||
let data = json.data(using: .utf8),
|
||||
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return [:] }
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,20 @@ struct SyncStatusView: View {
|
||||
sort: \LocalIssue.createdAt
|
||||
) private var pendingIssues: [LocalIssue]
|
||||
|
||||
/// Unfiltered — `uploadStatus` is matched in Swift rather than in a
|
||||
/// #Predicate, per CLAUDE.md rules 3/48.
|
||||
@Query private var allPendingPhotos: [PendingPhoto]
|
||||
|
||||
private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } }
|
||||
private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } }
|
||||
private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty }
|
||||
/// Photos that exhausted `SyncManager.maxPhotoUploadAttempts`. These are the
|
||||
/// reason an inspection can be submitted with a blank photo field, and
|
||||
/// nothing else in the app ever moves one off "failed" — so they belong in
|
||||
/// the retry action too.
|
||||
private var failedPhotos: [PendingPhoto] { allPendingPhotos.filter { $0.uploadStatus == "failed" } }
|
||||
private var hasFailedItems: Bool {
|
||||
!failedInspections.isEmpty || !failedIssues.isEmpty || !failedPhotos.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -60,7 +71,7 @@ struct SyncStatusView: View {
|
||||
Button {
|
||||
retryAllFailed()
|
||||
} label: {
|
||||
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))",
|
||||
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count + failedPhotos.count))",
|
||||
systemImage: "exclamationmark.arrow.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
@@ -113,6 +124,14 @@ struct SyncStatusView: View {
|
||||
issue.syncRetryCount = 0
|
||||
issue.syncErrorMessage = nil
|
||||
}
|
||||
// Photos too. A PendingPhoto only reaches "failed" after every upload
|
||||
// attempt was used, and that is exactly the state that lets an
|
||||
// inspection be submitted with its photo field blank — so without this
|
||||
// the retry button could never actually recover a lost photo.
|
||||
for photo in failedPhotos {
|
||||
photo.uploadStatus = "pending"
|
||||
photo.uploadRetryCount = 0
|
||||
}
|
||||
try? context.save()
|
||||
Task { await sync.triggerSync() }
|
||||
}
|
||||
|
||||
@@ -162,6 +162,14 @@ struct InspectionHistoryView: View {
|
||||
filterToDate = nil
|
||||
showFilterSheet = false
|
||||
Task { await load(reset: true) }
|
||||
} onCancel: {
|
||||
// Discard the draft edits, keep whatever is currently applied,
|
||||
// and do NOT reload — backing out must change nothing.
|
||||
draftFromEnabled = filterFromDate != nil
|
||||
draftToEnabled = filterToDate != nil
|
||||
if let f = filterFromDate { draftFromDate = f }
|
||||
if let t = filterToDate { draftToDate = t }
|
||||
showFilterSheet = false
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -215,6 +223,10 @@ struct DateFilterSheet: View {
|
||||
@Binding var toDate: Date
|
||||
let onApply: () -> Void
|
||||
let onClear: () -> Void
|
||||
/// Dismiss without touching the active filter. Distinct from `onClear`:
|
||||
/// Cancel used to call that, so backing out of the sheet silently wiped
|
||||
/// whatever date range was already applied and reloaded the list.
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -243,7 +255,7 @@ struct DateFilterSheet: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { onClear() }
|
||||
Button("Cancel") { onCancel() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Apply", action: onApply)
|
||||
@@ -350,11 +362,35 @@ struct HistoryDetailView: View {
|
||||
let inspection: APIInspectionSummary
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@State private var showReInspect = false
|
||||
@State private var showMailCompose = false
|
||||
@State private var isGeneratingPDF = false
|
||||
@State private var generatedPDFData: Data? = nil
|
||||
|
||||
// ── Schedule Follow-up (phase45) ──────────────────────────────────────
|
||||
@State private var showScheduleSheet = false
|
||||
/// Defaults to tomorrow: the point of this action is to plan the follow-up
|
||||
/// for another day. Today is still selectable — the server allows it.
|
||||
@State private var followUpDate = Calendar.current.date(
|
||||
byAdding: .day, value: 1, to: Date()
|
||||
) ?? Date()
|
||||
@State private var followUpNotes = ""
|
||||
@State private var isSchedulingFollowUp = false
|
||||
@State private var scheduleError: String? = nil
|
||||
@State private var scheduleConfirmation: String? = nil
|
||||
|
||||
/// Auditors are read-only everywhere else and the API rejects them (403),
|
||||
/// so the two action buttons are hidden rather than shown failing.
|
||||
///
|
||||
/// `issueActors` is the same set minus auditor, and — unlike the literal
|
||||
/// list this replaced — it includes Customer Inspectors, who perform
|
||||
/// inspections exactly as our own do.
|
||||
private var canStartFollowUp: Bool {
|
||||
Constants.Roles.issueActors.contains(auth.currentUserRole)
|
||||
}
|
||||
|
||||
// Local SwiftData copy — used only for follow-up sync-back.
|
||||
// Form data and schema come from the server response directly so
|
||||
// History works even after app reinstall or on a different device.
|
||||
@@ -463,6 +499,34 @@ struct HistoryDetailView: View {
|
||||
.navigationTitle(inspection.templateName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
// ── Re-inspect now ────────────────────────────────────────────
|
||||
// The immediate half of the follow-up pair. Opens the same linked
|
||||
// re-inspection flow the follow-up banner has always used, but
|
||||
// without waiting to be asked for one.
|
||||
if canStartFollowUp {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showReInspect = true
|
||||
} label: {
|
||||
Label("Re-inspect Now", systemImage: "arrow.uturn.right.circle")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schedule follow-up ────────────────────────────────────
|
||||
// The deferred half. Needs the network: it creates a schedule
|
||||
// server-side rather than a local record, so unlike starting an
|
||||
// inspection it cannot be queued offline.
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
scheduleError = nil
|
||||
showScheduleSheet = true
|
||||
} label: {
|
||||
Label("Schedule Follow-up", systemImage: "calendar.badge.plus")
|
||||
}
|
||||
.disabled(!sync.isOnline)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await prepareAndShowMail() }
|
||||
@@ -476,16 +540,34 @@ struct HistoryDetailView: View {
|
||||
.disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScheduleSheet) { scheduleFollowUpSheet }
|
||||
// Confirmation of a successful schedule. An alert rather than an inline
|
||||
// banner because the sheet has already dismissed by this point.
|
||||
.alert("Follow-up Scheduled",
|
||||
isPresented: Binding(get: { scheduleConfirmation != nil },
|
||||
set: { if !$0 { scheduleConfirmation = nil } })) {
|
||||
Button("OK") { scheduleConfirmation = nil }
|
||||
} message: {
|
||||
Text(scheduleConfirmation ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
loadLocalData()
|
||||
syncFollowUpToLocalCopy()
|
||||
}
|
||||
.sheet(isPresented: $showReInspect) {
|
||||
// Full-screen, not a sheet: every inspection-start flow is full-screen
|
||||
// (rule 66), and this one is now reachable from the toolbar on any
|
||||
// completed inspection rather than only the follow-up banner.
|
||||
.fullScreenCover(isPresented: $showReInspect) {
|
||||
StartInspectionView(
|
||||
preFillTemplateId: inspection.templateId,
|
||||
preFillFacilityId: inspection.facilityId,
|
||||
parentServerId: inspection.id,
|
||||
parentLocalId: inspection.mobileLocalId
|
||||
parentLocalId: inspection.mobileLocalId,
|
||||
// History is served from the API, so this inspection is often
|
||||
// not on this device at all and the local-parent lookup finds
|
||||
// nothing — the form would open blank (rule 79). The answers are
|
||||
// already in this very response, so pass them straight through.
|
||||
preFillParentFormDataJSON: parentFormDataJSON
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showMailCompose) {
|
||||
@@ -501,6 +583,138 @@ struct HistoryDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// This inspection's answers, JSON-encoded for `StartInspectionView`'s
|
||||
/// parent prefill. Raw values, not the flattened `formValues`, so an array
|
||||
/// field survives as an array (rule 79).
|
||||
private var parentFormDataJSON: String {
|
||||
let raw = inspection.formDataRaw.mapValues(\.anyValue)
|
||||
guard JSONSerialization.isValidJSONObject(raw),
|
||||
let data = try? JSONSerialization.data(withJSONObject: raw),
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return "{}" }
|
||||
return str
|
||||
}
|
||||
|
||||
// ── Schedule Follow-up sheet (phase45) ────────────────────────────────
|
||||
|
||||
/// Date + note picker for planning a follow-up re-inspection.
|
||||
///
|
||||
/// Only the date and an optional note are collected: the server derives
|
||||
/// facility, template and assignee from the parent inspection, so there is
|
||||
/// nothing else for the inspector to get wrong.
|
||||
private var scheduleFollowUpSheet: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text(inspection.templateName)
|
||||
.font(.callout.bold())
|
||||
Text(inspection.facilityName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} header: {
|
||||
Text("Follow-up of Inspection #\(inspection.id)")
|
||||
}
|
||||
|
||||
Section {
|
||||
DatePicker(
|
||||
"Due Date",
|
||||
selection: $followUpDate,
|
||||
in: Date()..., // the server rejects a past date
|
||||
displayedComponents: .date
|
||||
)
|
||||
.datePickerStyle(.graphical)
|
||||
} header: {
|
||||
Text("When")
|
||||
} footer: {
|
||||
Text("The follow-up appears in Scheduled on this date, "
|
||||
+ "assigned to the inspector who did the original.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(
|
||||
"What should the follow-up address?",
|
||||
text: $followUpNotes,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(3...6)
|
||||
} header: {
|
||||
Text("Instructions (optional)")
|
||||
}
|
||||
|
||||
if let err = scheduleError {
|
||||
Section {
|
||||
Label(err, systemImage: "exclamationmark.triangle")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Schedule Follow-up")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { showScheduleSheet = false }
|
||||
.disabled(isSchedulingFollowUp)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button {
|
||||
Task { await submitScheduledFollowUp() }
|
||||
} label: {
|
||||
if isSchedulingFollowUp {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Schedule")
|
||||
}
|
||||
}
|
||||
.disabled(isSchedulingFollowUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the follow-up schedule on the server, then refresh so it appears
|
||||
/// in the Scheduled lists without waiting for the next timed sync.
|
||||
///
|
||||
/// Online-only by nature: this writes a server-side plan, not a local
|
||||
/// record, so there is nothing meaningful to queue offline — the button is
|
||||
/// disabled when offline and this reports any failure inline rather than
|
||||
/// dismissing as if it had worked.
|
||||
private func submitScheduledFollowUp() async {
|
||||
isSchedulingFollowUp = true
|
||||
scheduleError = nil
|
||||
|
||||
let due = Self.dueDateFormatter.string(from: followUpDate)
|
||||
do {
|
||||
_ = try await APIClient.shared.createScheduledFollowUp(
|
||||
parentInspectionId: inspection.id,
|
||||
dueDate: due,
|
||||
notes: followUpNotes
|
||||
)
|
||||
// Pull the new schedule straight into the Scheduled section.
|
||||
await sync.pullScheduledInspections(context: context)
|
||||
|
||||
isSchedulingFollowUp = false
|
||||
showScheduleSheet = false
|
||||
followUpNotes = ""
|
||||
scheduleConfirmation =
|
||||
"A follow-up re-inspection of \(inspection.facilityName) is scheduled for "
|
||||
+ followUpDate.formatted(date: .abbreviated, time: .omitted) + "."
|
||||
} catch {
|
||||
isSchedulingFollowUp = false
|
||||
scheduleError = (error as? APIError)?.localizedDescription
|
||||
?? "Could not schedule the follow-up. Check your connection and try again."
|
||||
}
|
||||
}
|
||||
|
||||
/// `yyyy-MM-dd` for the API's `due_date`. Fixed POSIX locale so a non-
|
||||
/// Gregorian device calendar cannot emit a date the server can't parse.
|
||||
private static let dueDateFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Generates the PDF (fetching any server photos over the network),
|
||||
/// then presents the mail compose sheet with it attached.
|
||||
/// Photo fetches happen here, off the synchronous PDF drawing pass.
|
||||
@@ -668,6 +882,8 @@ struct ReadOnlyGridFormView: View {
|
||||
let schema: [[String: Any]]
|
||||
let formValues: [String: String]
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
|
||||
// ── Field visibility filtering ────────────────────────────────────────
|
||||
|
||||
// A row group: all visible fields that share the same original `row`.
|
||||
@@ -767,9 +983,13 @@ struct ReadOnlyGridFormView: View {
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
VStack(alignment: .leading, spacing: hSizeClass == .compact ? 10 : 3) {
|
||||
ForEach(visibleRowGroups.indices, id: \.self) { idx in
|
||||
rowView(visibleRowGroups[idx])
|
||||
if hSizeClass == .compact {
|
||||
stackedRowView(visibleRowGroups[idx])
|
||||
} else {
|
||||
rowView(visibleRowGroups[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
@@ -782,6 +1002,11 @@ struct ReadOnlyGridFormView: View {
|
||||
// Render one row as a GeometryReader-based HStack so each field
|
||||
// occupies exactly (colSpan/12) of the available width, and leading
|
||||
// space before col > 1 is filled with a transparent spacer.
|
||||
//
|
||||
// On a narrow screen the same 12-column division that works on iPad
|
||||
// leaves each field a few dozen points wide inside a fixed 36 pt row, so
|
||||
// labels and values collide. Below `minGridWidth` each field gets its own
|
||||
// full-width line at its natural height instead.
|
||||
@ViewBuilder
|
||||
private func rowView(_ group: RowGroup) -> some View {
|
||||
GeometryReader { geo in
|
||||
@@ -808,6 +1033,24 @@ struct ReadOnlyGridFormView: View {
|
||||
.frame(height: rowHeight(group))
|
||||
}
|
||||
|
||||
/// Stacked equivalent of `rowView` for narrow screens: no proportional
|
||||
/// widths, no fixed row height, so nothing can overlap.
|
||||
@ViewBuilder
|
||||
private func stackedRowView(_ group: RowGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(group.fields.indices, id: \.self) { i in
|
||||
let f = group.fields[i]
|
||||
let fid = f["id"] as? String ?? (f["id"] as? Int).map(String.init) ?? ""
|
||||
let value = formValues[fid] ?? ""
|
||||
let ftype = f["type"] as? String ?? "text"
|
||||
let label = f["label"] as? String ?? ""
|
||||
|
||||
ReadOnlyCellView(field: f, value: value, fieldType: ftype, label: label)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row height: fixed 36pt for most fields; taller for section headers.
|
||||
private func rowHeight(_ group: RowGroup) -> CGFloat {
|
||||
let hasSection = group.fields.contains { ($0["type"] as? String) == "section" }
|
||||
@@ -936,7 +1179,7 @@ struct PhotoThumbnailView: View {
|
||||
Group {
|
||||
if value.hasPrefix("local://") {
|
||||
let path = String(value.dropFirst("local://".count))
|
||||
if let img = UIImage(contentsOfFile: path) {
|
||||
if let live = PhotoStore.resolve(path), let img = UIImage(contentsOfFile: live) {
|
||||
thumbnailButton {
|
||||
Image(uiImage: img)
|
||||
.resizable().scaledToFill()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
JQC iOS — FIX for App Store Connect error 90771
|
||||
===============================================
|
||||
Missing Info.plist value: BGTaskSchedulerPermittedIdentifiers
|
||||
|
||||
This supersedes the Info.plist from the previous background-sync drop.
|
||||
The Swift files from that drop are unchanged and still correct.
|
||||
|
||||
OVERWRITE:
|
||||
JanitorialQC/Info.plist <- now has BOTH keys, as arrays
|
||||
JanitorialQC.xcodeproj/project.pbxproj <- removes 2 inert lines
|
||||
JanitorialQC/CLAUDE.md
|
||||
|
||||
WHAT WAS WRONG
|
||||
INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync
|
||||
was set in build settings (Debug + Release). INFOPLIST_KEY_* only merges
|
||||
Xcode's recognised keys, and only as STRINGS.
|
||||
BGTaskSchedulerPermittedIdentifiers must be an ARRAY, so nothing valid ever
|
||||
reached the built Info.plist. It went unnoticed until UIBackgroundModes was
|
||||
added, which is what makes the App Store validator check for it.
|
||||
|
||||
pbxproj CHANGE IS EXACTLY 2 LINE DELETIONS (verified by diff):
|
||||
line 419 (Debug) INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
||||
line 463 (Release) INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync;
|
||||
Nothing else touched. If you prefer not to take a pbxproj file, delete those
|
||||
two lines by hand, or in Xcode: target > Build Settings > search "BGTask" >
|
||||
select the row > Delete.
|
||||
|
||||
DO NOT put that build setting back. A build setting overwrites the plist
|
||||
file's value at merge time and the array becomes a string again.
|
||||
|
||||
BUILD + VERIFY BEFORE UPLOADING
|
||||
1. Product > Clean Build Folder (Shift-Cmd-K)
|
||||
2. Product > Archive
|
||||
3. In Organizer: right-click the archive > Show in Finder >
|
||||
Show Package Contents > Products/Applications/JanitorialQC.app
|
||||
plutil -p JanitorialQC.app/Info.plist | grep -A3 -E "BGTask|UIBackground"
|
||||
EXPECT:
|
||||
"BGTaskSchedulerPermittedIdentifiers" => [ 0 => "com.jqc.sync" ]
|
||||
"UIBackgroundModes" => [ 0 => "processing" ]
|
||||
Both must be arrays. If either shows a bare string, stop - the build
|
||||
setting is back.
|
||||
4. Then upload. Error 90771 should be gone.
|
||||
|
||||
5. On device, confirm the console shows NO
|
||||
"[JQC] BGTaskScheduler.register FAILED" line at launch.
|
||||
Reference in New Issue
Block a user