Jul 27 - Update for scheduled task instruction
This commit is contained in:
@@ -432,7 +432,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6;
|
||||
MARKETING_VERSION = 1.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -476,7 +476,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.6;
|
||||
MARKETING_VERSION = 1.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
+10
-1
@@ -389,6 +389,12 @@ Without it — the original bug — the schedule was never fulfilled: the banner
|
||||
|
||||
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.
|
||||
|
||||
Deleting that row at submit time exposed a second problem: `ScheduledInspectionsCard` self-hid the moment its `@Query` emptied, tearing down the `.fullScreenCover` it owned — with the inspector's form inside it. Cover ownership therefore moved to the parents (`DashboardStatsView`'s `ScrollView`, `MyInspectionsView`'s `Group`) and the cover item became the value type `ScheduledStartTarget`. See rule 68.
|
||||
|
||||
**Instructions (July 2026).** The manager-authored text on a schedule is labelled **"Instructions"** everywhere the user sees it, but remains `notes` on the wire, in `APIScheduledInspection`, and in `LocalScheduledInspection`. `LocalScheduledInspection.instructions` is a *computed* accessor over `notes` that trims and nils-out blank text — no stored property, so no schema change and no migration. Three surfaces: a one-line preview on `ScheduledRow`, a full Section at the top of `StartInspectionView` (passed in as `preFillScheduleInstructions` via `ScheduledStartTarget.instructions`), and a collapsible banner above the form in `ExecuteInspectionView` (looked up from SwiftData, so it also works on the draft-resume path where no parameter is threaded). See rule 70.
|
||||
|
||||
### Inspection-start presentation (July 2026)
|
||||
|
||||
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → `.fullScreenCover` → `ExecuteInspectionView(isModallyPresented: true)` with a leading `Close`; scheduled / new (`+`) / re-inspection → `.fullScreenCover` → `StartInspectionView` (its own Cancel). Pushed presentations (My Inspections row → `ExecuteInspectionView`) keep `isModallyPresented = false` and rely on the nav back button.
|
||||
@@ -682,10 +688,13 @@ 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 **deleted**, and `pullScheduledInspections()` re-inserts it with the authoritative `next_due_date` — 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -38,6 +38,24 @@ final class LocalScheduledInspection {
|
||||
var isOverdue: Bool
|
||||
var notes: String?
|
||||
|
||||
/// 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
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
JQC iOS — scheduled inspection fulfilment fix
|
||||
=============================================
|
||||
Repo: jqc_ios_app Branch target: main
|
||||
All paths are relative to the repo root (the folder containing JanitorialQC.xcodeproj).
|
||||
|
||||
DROP-IN (overwrite):
|
||||
JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
|
||||
JanitorialQC/Views/Dashboard/ScheduledInspectionsView.swift
|
||||
JanitorialQC/Views/Dashboard/MyInspectionsView.swift
|
||||
JanitorialQC/Views/Dashboard/DashboardView.swift
|
||||
JanitorialQC/CLAUDE.md
|
||||
|
||||
NO new files. NO files removed. NO Xcode project edits
|
||||
(the target uses PBXFileSystemSynchronizedRootGroup — new/changed
|
||||
files under JanitorialQC/ are picked up automatically).
|
||||
|
||||
NO server changes. NO SwiftData schema change, so no migration
|
||||
and no need to delete the app from the iPad.
|
||||
|
||||
BUILD
|
||||
1. git pull / copy files in
|
||||
2. Xcode → Product → Clean Build Folder (Shift-Cmd-K)
|
||||
3. Build & run on the iPad
|
||||
|
||||
VERIFY
|
||||
A. Scheduled row path
|
||||
Dashboard → SCHEDULED → tap row → Start → complete → Submit
|
||||
→ SCHEDULED card clears immediately
|
||||
→ web: Inspections → Scheduled → one-time schedule shows "Inactive"
|
||||
B. "+" path (this is the case that was broken)
|
||||
Dashboard/My Inspections → "+" → pick the SAME facility + template
|
||||
as a schedule due today or earlier → complete → Submit
|
||||
→ same result as A
|
||||
C. Offline
|
||||
Airplane mode → run A → SCHEDULED card clears at once
|
||||
→ re-enable network → schedule goes Inactive on the web
|
||||
D. Regression
|
||||
Re-inspection (History → Re-inspect) must NOT deactivate any schedule
|
||||
Ad-hoc inspection for a facility whose schedule is due NEXT month
|
||||
must NOT deactivate it
|
||||
|
||||
SERVER-SIDE CHECK
|
||||
sudo journalctl -u janitorial_qc --since "10 min ago" | grep "API INSPECTIONS"
|
||||
expect: API INSPECTIONS | schedule fulfilled | schedule=N | inspection=M | next=deactivated
|
||||
@@ -266,6 +266,11 @@ 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
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
@@ -279,7 +284,9 @@ struct DashboardStatsView: View {
|
||||
// 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 +414,19 @@ 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,
|
||||
preFillScheduleId: t.id,
|
||||
preFillScheduleInstructions: t.instructions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,15 @@ struct ExecuteInspectionView: View {
|
||||
// regardless of connectivity or sync timing.
|
||||
clearParentFollowUpFlag()
|
||||
|
||||
// ── 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 +719,84 @@ struct ExecuteInspectionView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.dueDateString.isEmpty &&
|
||||
$0.dueDateString <= today // ISO strings sort chronologically
|
||||
}
|
||||
.sorted { $0.dueDateString < $1.dueDateString }
|
||||
.first
|
||||
|
||||
if let candidate {
|
||||
inspection.scheduledInspectionServerId = candidate.serverId
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the cached row for whichever schedule this submission fulfils.
|
||||
guard let schedId = inspection.scheduledInspectionServerId,
|
||||
let sched = all.first(where: { $0.serverId == schedId })
|
||||
else { return }
|
||||
|
||||
context.delete(sched)
|
||||
}
|
||||
|
||||
/// "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]) {
|
||||
|
||||
@@ -22,7 +22,7 @@ struct MyInspectionsView: View {
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var showNewInspection = false
|
||||
@State private var scheduledStartTarget: LocalScheduledInspection?
|
||||
@State private var scheduledStartTarget: ScheduledStartTarget?
|
||||
|
||||
// Deletion confirmation state
|
||||
@State private var pendingDelete: LocalInspection?
|
||||
@@ -42,7 +42,7 @@ struct MyInspectionsView: View {
|
||||
if !scheduledAll.isEmpty {
|
||||
Section("Scheduled") {
|
||||
ForEach(scheduledAll) { s in
|
||||
Button { scheduledStartTarget = s } label: {
|
||||
Button { scheduledStartTarget = ScheduledStartTarget(s) } label: {
|
||||
ScheduledRow(schedule: s)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -71,16 +71,21 @@ 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,
|
||||
preFillScheduleId: s.serverId
|
||||
preFillTemplateId: t.templateServerId,
|
||||
preFillFacilityId: t.facilityServerId,
|
||||
preFillScheduleId: t.id,
|
||||
preFillScheduleInstructions: t.instructions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("My Inspections")
|
||||
// Confirmation before deletion — destructive action cannot be undone
|
||||
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
||||
|
||||
@@ -5,17 +5,54 @@
|
||||
// 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?
|
||||
|
||||
init(_ schedule: LocalScheduledInspection) {
|
||||
self.id = schedule.serverId
|
||||
self.templateServerId = schedule.templateServerId
|
||||
self.facilityServerId = schedule.facilityServerId
|
||||
self.instructions = schedule.instructions
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared row
|
||||
|
||||
struct ScheduledRow: View {
|
||||
@@ -60,6 +97,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,7 +136,14 @@ struct ScheduledInspectionsCard: View {
|
||||
@Query(sort: \LocalScheduledInspection.dueDateString, order: .forward)
|
||||
private var scheduled: [LocalScheduledInspection]
|
||||
|
||||
@State private var startTarget: LocalScheduledInspection? = nil
|
||||
/// 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 {
|
||||
@@ -92,7 +154,7 @@ struct ScheduledInspectionsCard: View {
|
||||
.tracking(1)
|
||||
|
||||
ForEach(scheduled) { s in
|
||||
Button { startTarget = s } label: {
|
||||
Button { onStart(ScheduledStartTarget(s)) } label: {
|
||||
ScheduledRow(schedule: s)
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
@@ -101,14 +163,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,
|
||||
preFillScheduleId: s.serverId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ struct StartInspectionView: View {
|
||||
/// 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 ─────────────────────────────────────────────────────
|
||||
|
||||
/// Unique contracts (projectId, projectName) sorted by name.
|
||||
@@ -85,6 +92,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 {
|
||||
|
||||
Reference in New Issue
Block a user