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
@@ -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 {