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)))
}
}
@@ -183,19 +183,45 @@ struct DateFieldView: 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()
// Same two problems as CellDatePicker, same fix see the comments
// there. Empty must look empty, and the stored format is "yyyy-MM-dd"
// to match the web's <input type="date">, not an ISO 8601 timestamp.
if value.isEmpty {
Button {
value = FormDateFormat.string(from: Date())
} label: {
HStack(spacing: 6) {
Image(systemName: "calendar")
Text("Set date")
}
.font(.callout)
.foregroundStyle(Color(.placeholderText))
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color(.systemGray4), lineWidth: 1))
}
.buttonStyle(.plain)
} else {
HStack(spacing: 6) {
DatePicker("", selection: dateBinding, displayedComponents: .date)
.labelsHidden()
Button { value = "" } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
+20 -23
View File
@@ -129,13 +129,17 @@ struct SettingsView: View {
Section {
Button(role: .destructive) {
Task {
// Do NOT clear server-pulled data on a plain logout
// the user is logging out of the same server, so cached
// facilities, issues, and templates are still valid on
// their next login. Clearing here leaves the issues list
// empty until a full sync succeeds, which breaks offline use.
// Server-pulled data is only cleared when switching servers
// (see the Switch & Log Out alert below).
// Do NOT purge on a plain logout the same inspector
// signing back into the same server must still find
// their facilities, templates and issues there, or the
// app is unusable offline until a full sync succeeds.
//
// What was missing is not a purge here: it is the check
// that the next sign-in is the SAME person.
// AuthManager.reconcileSessionScope() now does that at
// login and purges only on an identity change, so a
// different inspector no longer inherits this one's
// issues (rule 88).
sync.resetNotificationPoller()
await auth.logout()
}
@@ -207,7 +211,14 @@ struct SettingsView: View {
settingsServer = chosen
pendingServer = nil
Task {
clearServerPulledData()
// Purge EVERYTHING, not just issues. The old
// clearServerPulledData() deleted LocalIssue alone,
// leaving LocalInspection rows carrying facility and
// template ids that name different rows on the server
// being switched to ready to be submitted against it.
// Nothing local survives a server change (rule 88).
sync.purgeSessionScopedData(keepingUserId: nil, sameServer: false)
SessionScope.clear()
sync.resetNotificationPoller()
await auth.logout()
}
@@ -218,7 +229,7 @@ struct SettingsView: View {
}
} message: {
if let chosen = pendingServer {
Text("Switching to \(chosen.displayName) will log you out. All cached server data will be cleared. You will need to log in again.")
Text("Switching to \(chosen.displayName) will log you out and erase all local data for this server — including any inspections or issues that have not synced yet. You will need to log in again.")
}
}
}
@@ -242,18 +253,4 @@ struct SettingsView: View {
}
}
/// Delete every LocalIssue that has ever been assigned a serverId.
/// This covers two categories:
/// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced")
/// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil)
/// their serverIds are meaningless on a different server, so they must go too.
/// The only records preserved are truly pending device-created issues
/// (serverId == nil, syncStatus == "pending") that have never reached any server.
private func clearServerPulledData() {
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
allIssues
.filter { $0.serverId != nil }
.forEach { context.delete($0) }
try? context.save()
}
}
@@ -298,7 +298,11 @@ struct StartInspectionView: View {
}
.navigationDestination(isPresented: $navigateToExecution) {
if let inspection = createdInspection {
ExecuteInspectionView(inspection: inspection)
// onFinished closes THIS cover rather than just popping back
// to the form the inspector already finished with see the
// property's doc comment on ExecuteInspectionView.
ExecuteInspectionView(inspection: inspection,
onFinished: { dismiss() })
}
}
.onAppear {
@@ -19,9 +19,20 @@ struct SyncStatusView: View {
sort: \LocalIssue.createdAt
) private var pendingIssues: [LocalIssue]
/// Unfiltered `uploadStatus` is matched in Swift rather than in a
/// #Predicate, per CLAUDE.md rules 3/48.
@Query private var allPendingPhotos: [PendingPhoto]
private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } }
private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } }
private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty }
/// Photos that exhausted `SyncManager.maxPhotoUploadAttempts`. These are the
/// reason an inspection can be submitted with a blank photo field, and
/// nothing else in the app ever moves one off "failed" so they belong in
/// the retry action too.
private var failedPhotos: [PendingPhoto] { allPendingPhotos.filter { $0.uploadStatus == "failed" } }
private var hasFailedItems: Bool {
!failedInspections.isEmpty || !failedIssues.isEmpty || !failedPhotos.isEmpty
}
var body: some View {
List {
@@ -60,7 +71,7 @@ struct SyncStatusView: View {
Button {
retryAllFailed()
} label: {
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))",
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count + failedPhotos.count))",
systemImage: "exclamationmark.arrow.circlepath")
.foregroundStyle(.orange)
}
@@ -113,6 +124,14 @@ struct SyncStatusView: View {
issue.syncRetryCount = 0
issue.syncErrorMessage = nil
}
// Photos too. A PendingPhoto only reaches "failed" after every upload
// attempt was used, and that is exactly the state that lets an
// inspection be submitted with its photo field blank so without this
// the retry button could never actually recover a lost photo.
for photo in failedPhotos {
photo.uploadStatus = "pending"
photo.uploadRetryCount = 0
}
try? context.save()
Task { await sync.triggerSync() }
}