Aug 19 - Fixed photo-loss issue

This commit is contained in:
Nguyen Ngo
2026-08-19 10:31:20 -04:00
parent 7cbc514c39
commit e31cd7e1ff
13 changed files with 856 additions and 149 deletions
@@ -29,10 +29,27 @@ struct ExecuteInspectionView: View {
/// Work is preserved either way .onDisappear calls saveDraft().
var isModallyPresented: Bool = false
/// What to do when the inspection has been submitted, instead of the
/// default `dismiss()`.
///
/// `StartInspectionView` PUSHES this view onto the NavigationStack inside
/// its own `.fullScreenCover`, so a plain `dismiss()` only pops landing
/// the inspector back on the "New Inspection" form they just started from,
/// with Cancel as the only way out. It passes its own dismiss here so the
/// whole cover closes and they return to the dashboard.
///
/// Left nil everywhere else, where popping IS correct: the My Inspections
/// row pushes onto the list's stack, and the dashboard's Resume banner
/// presents this view as the cover root.
var onFinished: (() -> Void)? = nil
@State private var formValues: [String: String] = [:]
@State private var showFlagIssue = false
@State private var showSubmitAlert = false
@State private var showNoGPSAlert = false
/// Set when Submit is tapped with no GPS fix; consumed by
/// `onChange(of: showSubmitAlert)` once the confirm alert has dismissed.
@State private var pendingNoGPSPrompt = false
@State private var showValidationAlert = false
@State private var missingFields: [String] = []
@State private var isSaving = false
@@ -101,7 +118,13 @@ struct ExecuteInspectionView: View {
switch ftype {
case "rating":
if let v = Int(val), v > 0 { earned += v; total += field["max"] as? Int ?? 5 }
// Denominator is a FLAT 5, never the field's `max`.
// `_compute_score_from_form()` in the Flask app (routes/
// inspections.py) hardcodes `total += 5`, and LocalInspection
// .computeScore() mirrors it this was the only site reading
// `max`, so a template with max != 5 showed one percentage in
// the toolbar and submitted a different one.
if let v = Int(val), v > 0 { earned += v; total += 5 }
case "checkbox":
total += 1; if val == "true" { earned += 1 }
case "radio":
@@ -194,7 +217,16 @@ struct ExecuteInspectionView: View {
if locationManager.lastLocation == nil {
// No GPS fix yet warn before proceeding rather than
// silently submitting without a location.
showNoGPSAlert = true
//
// Deferred, NOT set here: raising a second alert from
// inside the first one's action, with both attached to the
// same view, is dropped by SwiftUI the confirm alert is
// still tearing down, so the new presentation is discarded.
// The visible effect was that tapping Submit without a fix
// did nothing at all: no warning, no submission. Handing it
// to onChange(of: showSubmitAlert) below presents it only
// once the first alert has actually gone.
pendingNoGPSPrompt = true
} else {
Task { await submitInspection() }
}
@@ -223,7 +255,26 @@ struct ExecuteInspectionView: View {
.onChange(of: showSubmitAlert) { _, showing in
// Begin acquiring a GPS fix the moment the confirm dialog appears
// so a location is likely ready by the time the inspector taps Submit.
if showing { locationManager.requestLocation() }
if showing {
locationManager.requestLocation()
return
}
// Confirm alert has closed. If Submit was tapped without a fix,
// raise the warning now that the presentation slot is free.
guard pendingNoGPSPrompt else { return }
pendingNoGPSPrompt = false
Task {
// One runloop hop. `showing == false` means the binding flipped,
// not that the dismissal animation has finished, and presenting
// into the tail of that animation is unreliable.
try? await Task.sleep(for: .milliseconds(350))
// Re-check: the fix may have landed while the dialog was up.
if locationManager.lastLocation == nil {
showNoGPSAlert = true
} else {
await submitInspection()
}
}
}
// Result overlay
.overlay(alignment: .top) {
@@ -678,9 +729,9 @@ struct ExecuteInspectionView: View {
Task { await sync.triggerSync() }
}
// Wait 2.5 seconds so inspector reads the result, then dismiss
// Wait 2.5 seconds so inspector reads the result, then leave.
try? await Task.sleep(for: .seconds(2.5))
dismiss()
if let onFinished { onFinished() } else { dismiss() }
}
/// Find the parent LocalInspection and clear its followUpRequired flag.
@@ -1409,15 +1460,83 @@ struct CellDatePicker: View {
private var dateBinding: Binding<Date> {
Binding(
get: { ISO8601DateFormatter().date(from: value) ?? Date() },
set: { value = ISO8601DateFormatter().string(from: $0) }
get: { FormDateFormat.date(from: value) ?? Date() },
set: { value = FormDateFormat.string(from: $0) }
)
}
var body: some View {
DatePicker("", selection: dateBinding, displayedComponents: .date)
.labelsHidden()
// An empty value must LOOK empty until the inspector acts.
//
// The old version bound the DatePicker straight to the value: when it
// was empty the picker still displayed TODAY, so the field looked
// answered but the setter only fires on a CHANGE, so selecting the
// already-displayed date wrote nothing and missingRequiredFields()
// reported the field missing with a date plainly visible on screen.
// The inspector had to pick a different day and navigate back. This
// explicit step makes unanswered look unanswered and makes today
// selectable in one tap.
if value.isEmpty {
Button {
value = FormDateFormat.string(from: Date())
} label: {
HStack(spacing: 4) {
Image(systemName: "calendar").font(.system(size: 11))
Text("Set date").font(.system(size: 12))
}
.foregroundStyle(Color(.placeholderText))
.padding(.horizontal, 6).padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5)
.stroke(Color(.systemGray4), lineWidth: 1))
}
.buttonStyle(.plain)
} else {
HStack(spacing: 2) {
DatePicker("", selection: dateBinding, displayedComponents: .date)
.labelsHidden()
Button { value = "" } label: { // back to unanswered
Image(systemName: "xmark.circle.fill")
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
// MARK: - FormDateFormat
// Wire format for `date` form fields: "yyyy-MM-dd", matching the web's
// <input type="date"> (templates/inspections/execute.html), so a value entered
// on the iPad and one entered in a browser are the same string in form_data.
//
// Both date widgets previously used ISO8601DateFormatter, which round-tripped a
// full timestamp ("2026-08-18T14:30:00Z") into a field that the web renders and
// the PDF prints verbatim.
//
// UTC + POSIX locale so the day cannot shift with device timezone or calendar.
// nonisolated for the same reason as PhotoCaptureFormat (rule 82).
nonisolated enum FormDateFormat {
static let formatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd"
return f
}()
static func string(from date: Date) -> String { formatter.string(from: date) }
/// Parses the canonical form, and tolerates a leading `yyyy-MM-dd` inside a
/// longer timestamp so values written by earlier builds still display.
static func date(from value: String) -> Date? {
if let d = formatter.date(from: value) { return d }
guard value.count >= 10 else { return nil }
return formatter.date(from: String(value.prefix(10)))
}
}