This commit is contained in:
2026-08-17 16:08:05 -04:00
21 changed files with 1978 additions and 232 deletions
@@ -37,6 +37,41 @@ 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
/// so submitInspection() can send it; without it the server cannot fulfil
/// 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
/// Facilities this inspector may actually start work at.
@@ -89,6 +124,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 {
@@ -101,6 +155,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)
@@ -277,23 +341,38 @@ struct StartInspectionView: View {
// Link to parent if this is a re-inspection
inspection.parentServerId = parentServerId
inspection.parentLocalId = parentLocalId
// Link to the schedule if launched from a scheduled row
inspection.scheduledInspectionServerId = preFillScheduleId
// Pre-fill from parent (mirrors web app behaviour)
// Copy non-scoring field values from the parent inspection so the
// 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"]
@@ -306,7 +385,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) {
@@ -325,4 +403,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
}
}