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