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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user