Aug 19 - Fixed photo-loss issue
This commit is contained in:
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user