Jul 30 - Update iPad - inspector can re-inspect and create follow-up

This commit is contained in:
Nguyen Ngo
2026-07-30 16:16:15 -04:00
parent b7990cc9ba
commit dac7e6c597
16 changed files with 961 additions and 19 deletions
@@ -42,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
@@ -181,8 +194,8 @@ struct DashboardView: View {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(tint)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
if myInspectionsBadgeCount > 0 {
Text("\(myInspectionsBadgeCount)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
@@ -332,6 +345,10 @@ struct DashboardStatsView: View {
/// 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) {
@@ -341,6 +358,15 @@ 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 +
@@ -484,10 +510,34 @@ struct DashboardStatsView: View {
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
@@ -648,6 +648,13 @@ 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
@@ -719,6 +726,28 @@ 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
@@ -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)
}
}
}
}
}
@@ -24,10 +24,23 @@ struct MyInspectionsView: View {
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: ScheduledStartTarget?
@State private var followUpStartTarget: FollowUpStartTarget?
// Deletion confirmation state
@State private var pendingDelete: LocalInspection?
@@ -35,7 +48,7 @@ struct MyInspectionsView: View {
var body: some View {
Group {
if inspections.isEmpty && scheduledVisible.isEmpty {
if inspections.isEmpty && scheduledVisible.isEmpty && followUpsVisible.isEmpty {
ContentUnavailableView(
"No Inspections",
systemImage: "checklist",
@@ -43,6 +56,20 @@ 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 !scheduledVisible.isEmpty {
Section("Scheduled") {
@@ -87,10 +114,28 @@ struct MyInspectionsView: View {
StartInspectionView(
preFillTemplateId: t.templateServerId,
preFillFacilityId: t.facilityServerId,
// phase45 nil for an ordinary schedule; set when this row is a
// planned follow-up, which makes the run a linked re-inspection.
// Must precede preFillScheduleId: argument order follows the
// property declaration order in StartInspectionView.
parentServerId: t.parentServerId,
preFillScheduleId: t.id,
preFillScheduleInstructions: t.instructions
)
}
// Also on the Group, not the List see the comment above. Submitting the
// last follow-up can flip this view to ContentUnavailableView while the
// cover is still presented. `parentServerId` is what links the run back
// to the flagged inspection; see the twin cover in DashboardStatsView.
.fullScreenCover(item: $followUpStartTarget) { t in
StartInspectionView(
preFillTemplateId: t.templateServerId,
preFillFacilityId: t.facilityServerId,
parentServerId: t.id,
preFillFollowUpNote: t.note,
preFillParentFormDataJSON: t.parentFormDataJSON
)
}
.navigationTitle("My Inspections")
// Confirmation before deletion destructive action cannot be undone
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
@@ -44,12 +44,18 @@ struct ScheduledStartTarget: Identifiable {
/// `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
}
}
@@ -37,6 +37,27 @@ 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
@@ -123,6 +144,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)
@@ -307,17 +338,30 @@ struct StartInspectionView: View {
// inspector doesn't re-enter static data. Scoring fields (rating,
// pass_fail) and media fields (image, signature) are always left blank
// so every scoreable item must be re-evaluated fresh.
if let parentId = parentServerId {
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
if let parent = allInspections.first(where: { $0.serverId == parentId }),
!parent.formData.isEmpty {
if parentServerId != nil {
// Prefer the local parent (the CompletedInspectionView path, where
// the inspector just finished it on this iPad); otherwise fall back
// to the snapshot cached on the follow-up request. A follow-up
// raised on the web has usually synced and been dropped locally, so
// without the fallback this whole block silently no-opped and the
// form came up blank the bug this fixes.
let parentData = resolvedParentFormData()
// Fetch the template schema to identify field types.
// Split into two statements avoids Xcode 26 #Predicate
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
let tid = templateId
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
// Fetch the template schema to identify field types.
// Split into two statements avoids Xcode 26 #Predicate
// ambiguity under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
let allTemplates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
let tid = templateId
let schema = allTemplates.first(where: { $0.serverId == tid })?.formSchema ?? []
// The schema is what identifies which fields must NOT be carried
// over, so without it there is no safe prefill: an empty exclude set
// would copy *everything*, including the parent's `image` paths and
// its ratings attaching the previous inspection's photos as this
// one's evidence and pre-answering the scoreable items. Copy nothing
// instead. (Unreachable in practice: the template was chosen from
// the local picker, so it is cached this is a guard, not a case.)
if !parentData.isEmpty, !schema.isEmpty {
// Build the set of field IDs that must NOT be carried over
let excludeTypes: Set<String> = ["rating", "pass_fail", "image", "signature"]
@@ -330,7 +374,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) {
@@ -349,4 +392,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.