Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/JQC_iOS_App
This commit is contained in:
@@ -416,7 +416,6 @@
|
||||
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.";
|
||||
@@ -432,7 +431,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6;
|
||||
MARKETING_VERSION = 1.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -460,7 +459,6 @@
|
||||
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.";
|
||||
@@ -476,7 +474,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6;
|
||||
MARKETING_VERSION = 1.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -238,6 +238,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 }
|
||||
@@ -424,6 +429,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
|
||||
|
||||
@@ -658,6 +658,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 +677,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 +702,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 {
|
||||
|
||||
+55
-6
@@ -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) |
|
||||
@@ -193,9 +193,10 @@ 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` |
|
||||
| `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()` |
|
||||
| `PendingPhoto` | Photo awaiting upload | `localId`, `localFilePath`, `serverPath`, `uploadStatus`, `entityType` (`"issue"` or `"inspection"`), `fieldId` |
|
||||
| `SyncQueueEntry` | Outbox entry (informational) | `entityType`, `localId`, `syncStatus`, `payloadJSON` |
|
||||
|
||||
@@ -260,7 +261,9 @@ guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated e
|
||||
|
||||
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.
|
||||
|
||||
### Server-pulled issue identification
|
||||
|
||||
@@ -318,6 +321,8 @@ All server URLs built as: `ServerConfig.current + endpoint` — **`Constants.bas
|
||||
| `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
|
||||
@@ -367,6 +372,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`.
|
||||
|
||||
---
|
||||
@@ -389,7 +396,21 @@ 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)
|
||||
|
||||
@@ -459,6 +480,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()`.
|
||||
@@ -684,10 +718,25 @@ 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,7 @@ struct JanitorialQCApp: App {
|
||||
LocalInspection.self,
|
||||
LocalIssue.self,
|
||||
LocalScheduledInspection.self,
|
||||
LocalFollowUpRequest.self,
|
||||
PendingPhoto.self,
|
||||
SyncQueueEntry.self,
|
||||
], isUndoEnabled: false) { result in
|
||||
@@ -128,7 +129,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 +143,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 +211,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,181 @@
|
||||
// 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?
|
||||
|
||||
/// 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.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.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 = []
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import SwiftData
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import UserNotifications
|
||||
import UIKit // beginBackgroundTask — see beginSyncBackgroundTask()
|
||||
|
||||
@MainActor
|
||||
class SyncManager: ObservableObject {
|
||||
@@ -110,6 +111,36 @@ class SyncManager: ObservableObject {
|
||||
pollTask = nil
|
||||
}
|
||||
|
||||
// ── Background task assertion ─────────────────────────────────────────
|
||||
// Keeps the process alive across a suspend so an in-flight sync can finish.
|
||||
// Distinct from the BGProcessingTask in JanitorialQCApp: that one asks iOS
|
||||
// to WAKE us later, this one asks it not to suspend us right now.
|
||||
|
||||
private var syncBackgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
||||
|
||||
private func beginSyncBackgroundTask() {
|
||||
// triggerSync() is re-entrancy guarded, but assert defensively anyway:
|
||||
// beginning a second assertion would leak the first identifier.
|
||||
guard syncBackgroundTaskId == .invalid else { return }
|
||||
syncBackgroundTaskId = UIApplication.shared.beginBackgroundTask(
|
||||
withName: "JQC.syncDrain"
|
||||
) {
|
||||
// Called on the main thread when the grace period runs out. It MUST
|
||||
// end the assertion or iOS terminates the app. assumeIsolated is
|
||||
// required because the handler is a nonisolated closure under
|
||||
// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
|
||||
MainActor.assumeIsolated {
|
||||
SyncManager.shared.endSyncBackgroundTask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func endSyncBackgroundTask() {
|
||||
guard syncBackgroundTaskId != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(syncBackgroundTaskId)
|
||||
syncBackgroundTaskId = .invalid
|
||||
}
|
||||
|
||||
/// Called on logout so the next login starts a clean fetch.
|
||||
func resetNotificationPoller() {
|
||||
lastNotificationFetch = nil
|
||||
@@ -232,12 +263,22 @@ class SyncManager: ObservableObject {
|
||||
syncError = nil
|
||||
defer { isSyncing = false }
|
||||
|
||||
// Ask iOS to keep the process alive long enough to finish the drain.
|
||||
// The case this covers: an inspector taps Submit and immediately locks
|
||||
// the iPad or swipes to another app. Without an assertion the process
|
||||
// suspends mid-upload and the work waits for the next launch.
|
||||
// Roughly 30 s of grace; the expiration handler ends it cleanly so iOS
|
||||
// never force-kills us. Harmless in the foreground — it simply ends.
|
||||
beginSyncBackgroundTask()
|
||||
defer { endSyncBackgroundTask() }
|
||||
|
||||
await processPhotoQueue(context: context)
|
||||
await processInspectionQueue(context: context)
|
||||
await processIssueQueue(context: context)
|
||||
await pullReferenceData()
|
||||
await pullAssignedIssues(context: context)
|
||||
await pullScheduledInspections(context: context)
|
||||
await pullFollowUpRequests(context: context)
|
||||
|
||||
// Poll notifications immediately on every sync rather than waiting
|
||||
// for the 60-second timer — ensures the inspector sees assignments
|
||||
@@ -925,6 +966,65 @@ class SyncManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Follow-up Requests ────────────────────────────────────────────────
|
||||
// Read-only pull of inspections a director flagged for follow-up, for the
|
||||
// Dashboard and My Inspections "Follow-up Requested" section. Upsert by
|
||||
// serverId, then delete rows the server no longer returns (the follow-up was
|
||||
// fulfilled by a linked re-inspection, or the director cleared the flag).
|
||||
// Best-effort — never blocks the pipeline.
|
||||
|
||||
func pullFollowUpRequests(context: ModelContext) async {
|
||||
guard isOnline, AuthManager.shared.isAuthenticated else { return }
|
||||
do {
|
||||
let apiRows = try await APIClient.shared.fetchFollowUpRequests()
|
||||
|
||||
// Fetch-all + filter/map in Swift — no #Predicate (CLAUDE.md rule 3).
|
||||
let allLocal = (try? context.fetch(FetchDescriptor<LocalFollowUpRequest>())) ?? []
|
||||
var byServerId: [Int: LocalFollowUpRequest] = [:]
|
||||
for row in allLocal { byServerId[row.serverId] = row }
|
||||
|
||||
for api in apiRows {
|
||||
if let existing = byServerId[api.id] {
|
||||
existing.update(from: api)
|
||||
} else {
|
||||
context.insert(LocalFollowUpRequest(from: api))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete rows the server no longer returns.
|
||||
let returnedIds = Set(apiRows.map { $0.id })
|
||||
for row in allLocal where !returnedIds.contains(row.serverId) {
|
||||
context.delete(row)
|
||||
}
|
||||
|
||||
// Keep the local copy of the flagged inspection in step, so the
|
||||
// follow-up badge in My Inspections / history detail agrees with the
|
||||
// card without waiting for the inspector to open that detail view
|
||||
// (which was previously the only thing that wrote these fields).
|
||||
var noteByServerId: [Int: String] = [:]
|
||||
for api in apiRows {
|
||||
if let note = api.followUpNote { noteByServerId[api.id] = note }
|
||||
}
|
||||
for local in (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? [] {
|
||||
guard let sid = local.serverId else { continue }
|
||||
if returnedIds.contains(sid) {
|
||||
local.followUpRequired = true
|
||||
local.followUpNote = noteByServerId[sid]
|
||||
} else if local.followUpRequired {
|
||||
local.followUpRequired = false
|
||||
local.followUpNote = nil
|
||||
}
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
|
||||
} catch APIError.notAuthenticated {
|
||||
// Let AuthManager handle session expiry
|
||||
} catch {
|
||||
// Non-fatal — stale follow-up 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.
|
||||
|
||||
@@ -107,11 +107,22 @@ final class PhotoLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
// MARK: - Wire format
|
||||
|
||||
enum PhotoCaptureFormat {
|
||||
// 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]
|
||||
|
||||
@@ -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,181 @@ 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)
|
||||
if tab == .notifications { sync.markNotificationsViewed() }
|
||||
} 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 +340,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 +358,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 +501,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 +571,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))
|
||||
|
||||
@@ -40,6 +40,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
|
||||
@@ -158,6 +166,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
|
||||
@@ -260,6 +269,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 +315,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 +648,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
|
||||
@@ -638,6 +726,116 @@ 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]) {
|
||||
@@ -701,6 +899,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] = [
|
||||
@@ -725,54 +942,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 ────────────────────
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,41 @@ 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.
|
||||
@@ -89,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 {
|
||||
@@ -101,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)
|
||||
@@ -277,23 +341,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"]
|
||||
@@ -306,7 +385,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) {
|
||||
@@ -325,4 +403,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,11 +350,32 @@ 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.
|
||||
private var canStartFollowUp: Bool {
|
||||
["admin", "director", "inspector", "project_manager"]
|
||||
.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 +484,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 +525,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 +568,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 +867,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 +968,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 +987,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 +1018,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" }
|
||||
|
||||
@@ -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