Jul 27 - Update for scheduled task instruction

This commit is contained in:
Nguyen Ngo
2026-07-27 12:57:13 -04:00
parent 5999871f77
commit ad91a92ff1
10 changed files with 424 additions and 28 deletions
+2 -2
View File
@@ -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
View File
@@ -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. |
---
+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
@@ -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
+44
View File
@@ -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
StartInspectionView(
preFillTemplateId: s.templateServerId,
preFillFacilityId: s.facilityServerId,
preFillScheduleId: s.serverId
)
}
}
}
// Cover attached to the enclosing Group, not the List and never a
// Section (rule 64). The List itself is conditional: submitting the last
// scheduled inspection can flip this view to ContentUnavailableView while
// the cover is still presented, which would tear the form down mid-submit.
// The Group is always present.
.fullScreenCover(item: $scheduledStartTarget) { t in
StartInspectionView(
preFillTemplateId: t.templateServerId,
preFillFacilityId: t.facilityServerId,
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 {