This commit is contained in:
2026-08-17 16:08:05 -04:00
21 changed files with 1978 additions and 232 deletions
+2 -4
View File
@@ -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;
+55
View File
@@ -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
+22
View File
@@ -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
View File
@@ -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 7677. 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 6364 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 6364 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. |
---
+61
View File
@@ -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
View File
@@ -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>
+43 -3
View File
@@ -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()
}
}
+11
View File
@@ -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()
}
}
+100
View File
@@ -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.
+12 -1
View File
@@ -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]
+268 -134
View File
@@ -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)
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 {
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() }
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,32 +942,31 @@ struct GridFormView: View {
@State private var containerWidth: CGFloat = 0
var body: some View {
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) {
// Card background
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).
// 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
@@ -764,15 +980,77 @@ struct GridFormView: View {
}
}
}
// 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,14 +103,38 @@ struct MyInspectionsView: View {
}
}
}
// Cover attached to the stable List, not a Section.
.fullScreenCover(item: $scheduledStartTarget) { s in
}
}
// 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: s.templateServerId,
preFillFacilityId: s.facilityServerId
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
@@ -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,16 +341,22 @@ 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
@@ -295,6 +365,15 @@ struct StartInspectionView: View {
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"]
var excludeIds = Set<String>()
@@ -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,11 +968,15 @@ 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
if hSizeClass == .compact {
stackedRowView(visibleRowGroups[idx])
} else {
rowView(visibleRowGroups[idx])
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground))
@@ -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" }
+45
View File
@@ -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.