06/11 Fix some errors: submission datetime incorrect; inspection & issue not linked, etc
This commit is contained in:
@@ -191,8 +191,18 @@ actor APIClient {
|
|||||||
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
|
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
|
||||||
if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId }
|
if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId }
|
||||||
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
|
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
|
||||||
|
if let lat = inspection.submitLatitude { body["submit_latitude"] = lat }
|
||||||
|
if let lng = inspection.submitLongitude { body["submit_longitude"] = lng }
|
||||||
|
|
||||||
|
// IMPORTANT: timeZone must be explicitly set to UTC.
|
||||||
|
// ISO8601DateFormatter() default timeZone is the DEVICE local timezone,
|
||||||
|
// which produces offset strings like "2026-06-11T10:30:00-04:00".
|
||||||
|
// The server's _parse_datetime() only recognises the Z suffix as UTC;
|
||||||
|
// offset-format strings fail all strptime patterns and return None,
|
||||||
|
// causing the server to fall back to now_eastern() — the sync time —
|
||||||
|
// instead of the actual inspection/completion time.
|
||||||
let fmt = ISO8601DateFormatter()
|
let fmt = ISO8601DateFormatter()
|
||||||
|
fmt.timeZone = TimeZone(identifier: "UTC")!
|
||||||
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
|
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
|
||||||
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
|
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
|
||||||
|
|
||||||
@@ -203,15 +213,19 @@ actor APIClient {
|
|||||||
|
|
||||||
// ── Submit Issue ──────────────────────────────────────────────────────
|
// ── Submit Issue ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
func submitIssue(_ issue: LocalIssue) async throws -> Int {
|
func submitIssue(_ issue: LocalIssue, inspectionServerId: Int?) async throws -> Int {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"facility_id": issue.facilityServerId,
|
"facility_id": issue.facilityServerId,
|
||||||
"severity": issue.severity,
|
"severity": issue.severity,
|
||||||
"description": issue.issueDescription,
|
"description": issue.issueDescription,
|
||||||
"mobile_local_id": issue.localId,
|
"mobile_local_id": issue.localId,
|
||||||
]
|
]
|
||||||
if let id = issue.inspection?.serverId { body["inspection_id"] = id }
|
// Use the explicitly passed serverId rather than issue.inspection?.serverId.
|
||||||
if let areaId = issue.areaServerId { body["area_id"] = areaId }
|
// The ORM relationship object is a separate fetch instance from the one
|
||||||
|
// processInspectionQueue updated, so its serverId is nil even after the
|
||||||
|
// inspection synced in the same triggerSync() pass.
|
||||||
|
if let id = inspectionServerId { body["inspection_id"] = id }
|
||||||
|
if let areaId = issue.areaServerId { body["area_id"] = areaId }
|
||||||
// photo_path = primary photo. Additional photos are sent via a
|
// photo_path = primary photo. Additional photos are sent via a
|
||||||
// separate PATCH call in processIssueQueue after the issue is created,
|
// separate PATCH call in processIssueQueue after the issue is created,
|
||||||
// because the server create endpoint only stores a single photo_path.
|
// because the server create endpoint only stores a single photo_path.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
@@ -6,6 +6,52 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
// ── AppearanceManager ─────────────────────────────────────────────────────────
|
||||||
|
// Persists the user's preferred colour scheme to UserDefaults and exposes it
|
||||||
|
// as a @Published property so the root view can apply .preferredColorScheme.
|
||||||
|
// "system" (nil) means the app follows iOS system appearance — the default.
|
||||||
|
|
||||||
|
enum AppearanceMode: String, CaseIterable {
|
||||||
|
case system = "system"
|
||||||
|
case light = "light"
|
||||||
|
case dark = "dark"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return "System"
|
||||||
|
case .light: return "Light"
|
||||||
|
case .dark: return "Dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SwiftUI ColorScheme value to pass to .preferredColorScheme().
|
||||||
|
/// nil = follow the OS (system default).
|
||||||
|
var colorScheme: ColorScheme? {
|
||||||
|
switch self {
|
||||||
|
case .system: return nil
|
||||||
|
case .light: return .light
|
||||||
|
case .dark: return .dark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppearanceManager: ObservableObject {
|
||||||
|
static let shared = AppearanceManager()
|
||||||
|
private static let defaultsKey = "jqc.appearanceMode"
|
||||||
|
|
||||||
|
@Published var mode: AppearanceMode {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(mode.rawValue, forKey: Self.defaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
let saved = UserDefaults.standard.string(forKey: Self.defaultsKey) ?? ""
|
||||||
|
mode = AppearanceMode(rawValue: saved) ?? .system
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── AppDelegate — runtime orientation lock ────────────────────────────────────
|
// ── AppDelegate — runtime orientation lock ────────────────────────────────────
|
||||||
// Info.plist must declare all 4 orientations so iPad multitasking is supported
|
// Info.plist must declare all 4 orientations so iPad multitasking is supported
|
||||||
@@ -28,8 +74,9 @@ struct JanitorialQCApp: App {
|
|||||||
|
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
|
|
||||||
@StateObject private var auth = AuthManager.shared
|
@StateObject private var auth = AuthManager.shared
|
||||||
@StateObject private var sync = SyncManager.shared
|
@StateObject private var sync = SyncManager.shared
|
||||||
|
@StateObject private var appearance = AppearanceManager.shared
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
registerBackgroundTasks()
|
registerBackgroundTasks()
|
||||||
@@ -44,6 +91,8 @@ struct JanitorialQCApp: App {
|
|||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(auth)
|
.environmentObject(auth)
|
||||||
.environmentObject(sync)
|
.environmentObject(sync)
|
||||||
|
.environmentObject(appearance)
|
||||||
|
.preferredColorScheme(appearance.mode.colorScheme)
|
||||||
}
|
}
|
||||||
.modelContainer(for: [
|
.modelContainer(for: [
|
||||||
LocalFacility.self,
|
LocalFacility.self,
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ final class LocalInspection {
|
|||||||
/// followUpRequired badge without relying on parentServerId being non-nil.
|
/// followUpRequired badge without relying on parentServerId being non-nil.
|
||||||
var parentLocalId: String?
|
var parentLocalId: String?
|
||||||
|
|
||||||
|
// ── GPS (captured at submit time via CoreLocation) ─────────────────────
|
||||||
|
/// Device latitude at the moment the inspector tapped Submit. Nil if
|
||||||
|
/// location permission was denied or a fix could not be obtained in time.
|
||||||
|
var submitLatitude: Double?
|
||||||
|
/// Device longitude at the moment the inspector tapped Submit.
|
||||||
|
var submitLongitude: Double?
|
||||||
|
|
||||||
// ── Relationships ──────────────────────────────────────────────────────
|
// ── Relationships ──────────────────────────────────────────────────────
|
||||||
@Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto]
|
@Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto]
|
||||||
@Relationship(deleteRule: .cascade) var localIssues: [LocalIssue]
|
@Relationship(deleteRule: .cascade) var localIssues: [LocalIssue]
|
||||||
@@ -82,6 +89,8 @@ final class LocalInspection {
|
|||||||
self.followUpNote = nil
|
self.followUpNote = nil
|
||||||
self.parentServerId = nil
|
self.parentServerId = nil
|
||||||
self.parentLocalId = nil
|
self.parentLocalId = nil
|
||||||
|
self.submitLatitude = nil
|
||||||
|
self.submitLongitude = nil
|
||||||
self.pendingPhotos = []
|
self.pendingPhotos = []
|
||||||
self.localIssues = []
|
self.localIssues = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,25 +310,56 @@ class SyncManager: ObservableObject {
|
|||||||
.filter { $0.syncStatus == "pending" }
|
.filter { $0.syncStatus == "pending" }
|
||||||
.sorted { $0.createdAt < $1.createdAt }
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
|
||||||
// Pre-fetch all inspections to check parent sync status.
|
// Pre-fetch all inspections to check parent sync status AND to resolve
|
||||||
|
// the parent's serverId. The allInspections array is the same set of
|
||||||
|
// objects that processInspectionQueue updated (serverId written in-memory
|
||||||
|
// this same triggerSync pass), so looking up serverId here is reliable.
|
||||||
|
// issue.inspection?.serverId is NOT reliable — it navigates a @Relationship
|
||||||
|
// that SwiftData may have loaded as a separate object instance before
|
||||||
|
// processInspectionQueue wrote the serverId back, leaving it nil even
|
||||||
|
// when the parent inspection already synced successfully this same pass.
|
||||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||||
|
|
||||||
for issue in pending {
|
for issue in pending {
|
||||||
// Guard: if the parent inspection permanently failed to sync,
|
let parentLocalId = issue.inspectionLocalId
|
||||||
// submitting this issue without an inspection_id would create an
|
let parent = allInspections.first(where: { $0.localId == parentLocalId })
|
||||||
// orphaned server record. Mark it failed immediately instead.
|
|
||||||
let parentId = issue.inspectionLocalId
|
// ── Parent inspection status guards ───────────────────────────
|
||||||
let parent = allInspections.first(where: { $0.localId == parentId })
|
if let parent {
|
||||||
if parent?.syncStatus == "failed" {
|
switch parent.syncStatus {
|
||||||
issue.syncStatus = "failed"
|
case "failed":
|
||||||
issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted."
|
// Parent permanently failed — this issue can never be linked.
|
||||||
try? context.save()
|
// Mark it failed immediately rather than creating an orphaned
|
||||||
syncError = "Issue \(issue.localId.prefix(8))\u{2026} blocked: parent inspection did not sync."
|
// server record with no inspection_id.
|
||||||
continue
|
issue.syncStatus = "failed"
|
||||||
|
issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted."
|
||||||
|
try? context.save()
|
||||||
|
syncError = "Issue \(issue.localId.prefix(8))\u{2026} blocked: parent inspection did not sync."
|
||||||
|
continue
|
||||||
|
|
||||||
|
case "synced":
|
||||||
|
// Parent has a serverId — proceed and link correctly.
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Parent is still "pending" (draft or awaiting submission).
|
||||||
|
// Submitting now would create a server issue with no
|
||||||
|
// inspection_id — the issue and inspection appear unlinked
|
||||||
|
// on the web. Defer until the next triggerSync() pass, by
|
||||||
|
// which point processInspectionQueue will have synced the
|
||||||
|
// parent and assigned it a serverId.
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// parent == nil means inspectionLocalId == "" (standalone issue) — submit without inspection_id.
|
||||||
|
|
||||||
|
// Resolve the parent's server ID. Safe to force-unwrap serverId
|
||||||
|
// here — the switch above guarantees parent.syncStatus == "synced"
|
||||||
|
// when parent is non-nil, so serverId is always set at this point.
|
||||||
|
let inspectionServerId = parent?.serverId
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let issueId = try await APIClient.shared.submitIssue(issue)
|
let issueId = try await APIClient.shared.submitIssue(issue, inspectionServerId: inspectionServerId)
|
||||||
issue.serverId = issueId
|
issue.serverId = issueId
|
||||||
issue.syncStatus = "synced"
|
issue.syncStatus = "synced"
|
||||||
// Photos are now represented by photoServerPaths on the server.
|
// Photos are now represented by photoServerPaths on the server.
|
||||||
|
|||||||
@@ -1823,6 +1823,7 @@ struct TemplatesListView: View {
|
|||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject private var auth: AuthManager
|
@EnvironmentObject private var auth: AuthManager
|
||||||
@EnvironmentObject private var sync: SyncManager
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
@EnvironmentObject private var appearance: AppearanceManager
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
@State private var showClearCacheAlert = false
|
@State private var showClearCacheAlert = false
|
||||||
@@ -1838,6 +1839,15 @@ struct SettingsView: View {
|
|||||||
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Appearance") {
|
||||||
|
Picker("Theme", selection: $appearance.mode) {
|
||||||
|
ForEach(AppearanceMode.allCases, id: \.self) { mode in
|
||||||
|
Text(mode.displayName).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
Section("Sync") {
|
Section("Sync") {
|
||||||
Button {
|
Button {
|
||||||
Task { await sync.triggerSync() }
|
Task { await sync.triggerSync() }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
struct ExecuteInspectionView: View {
|
struct ExecuteInspectionView: View {
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ struct ExecuteInspectionView: View {
|
|||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var submitResult: SubmitResult?
|
@State private var submitResult: SubmitResult?
|
||||||
|
|
||||||
|
// Location manager — created lazily when submit alert fires so the
|
||||||
|
// permission prompt only appears at the point of actual submission.
|
||||||
|
@State private var locationManager = InspectionLocationManager()
|
||||||
|
|
||||||
// Auto-save interval
|
// Auto-save interval
|
||||||
private let autoSaveInterval: TimeInterval = 30
|
private let autoSaveInterval: TimeInterval = 30
|
||||||
|
|
||||||
@@ -98,6 +103,11 @@ struct ExecuteInspectionView: View {
|
|||||||
? "Once submitted the inspection cannot be edited. It will be sent to the server now."
|
? "Once submitted the inspection cannot be edited. It will be sent to the server now."
|
||||||
: "Once submitted the inspection cannot be edited. It will sync automatically when you're back online.")
|
: "Once submitted the inspection cannot be edited. It will sync automatically when you're back online.")
|
||||||
}
|
}
|
||||||
|
.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() }
|
||||||
|
}
|
||||||
// Result overlay
|
// Result overlay
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if let result = submitResult {
|
if let result = submitResult {
|
||||||
@@ -378,6 +388,16 @@ struct ExecuteInspectionView: View {
|
|||||||
inspection.completedAt = Date()
|
inspection.completedAt = Date()
|
||||||
inspection.syncStatus = "pending"
|
inspection.syncStatus = "pending"
|
||||||
|
|
||||||
|
// ── GPS ───────────────────────────────────────────────────────────
|
||||||
|
// Use whatever fix the location manager has at this moment.
|
||||||
|
// lastLocation is nil if permission was denied or no fix arrived yet;
|
||||||
|
// the fields stay nil and the server silently omits them — same as web
|
||||||
|
// submissions where the user declined the browser location prompt.
|
||||||
|
if let loc = locationManager.lastLocation {
|
||||||
|
inspection.submitLatitude = loc.coordinate.latitude
|
||||||
|
inspection.submitLongitude = loc.coordinate.longitude
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clear follow-up flag on parent immediately ────────────────────
|
// ── Clear follow-up flag on parent immediately ────────────────────
|
||||||
// Do this at submit time rather than relying solely on SyncManager,
|
// Do this at submit time rather than relying solely on SyncManager,
|
||||||
// so the badge disappears the moment the inspector taps Submit —
|
// so the badge disappears the moment the inspector taps Submit —
|
||||||
@@ -1138,3 +1158,70 @@ struct ConnectivityBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - InspectionLocationManager
|
||||||
|
// Thin CLLocationManager wrapper used only by ExecuteInspectionView.
|
||||||
|
// Requests a single best-accuracy fix when the Submit confirmation dialog
|
||||||
|
// appears. The fix is stored in lastLocation and read synchronously at the
|
||||||
|
// moment the inspector confirms submission.
|
||||||
|
//
|
||||||
|
// Design constraints:
|
||||||
|
// - @Observable is unavailable before iOS 17 WWDC beta; use plain class +
|
||||||
|
// manual @State on the call site (already done above).
|
||||||
|
// - CLLocationManager delegate callbacks arrive on the main thread when the
|
||||||
|
// manager is created on the main thread (which @State guarantees here).
|
||||||
|
// - requestWhenInUseAuthorization() is a no-op if permission was already
|
||||||
|
// granted or permanently denied; it only shows the system prompt once.
|
||||||
|
// - NSLocationWhenInUseUsageDescription must be present in the app's
|
||||||
|
// Info.plist / build settings (add via Xcode target → Info tab).
|
||||||
|
|
||||||
|
final class InspectionLocationManager: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
|
private let manager = CLLocationManager()
|
||||||
|
|
||||||
|
/// Most recent location fix, or nil if unavailable.
|
||||||
|
private(set) var lastLocation: CLLocation?
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
manager.delegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||||
|
manager.distanceFilter = kCLDistanceFilterNone
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request permission (if needed) and start a single location update.
|
||||||
|
/// Safe to call multiple times — CLLocationManager ignores duplicate requests.
|
||||||
|
func requestLocation() {
|
||||||
|
switch manager.authorizationStatus {
|
||||||
|
case .notDetermined:
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
// Delegate callback didChangeAuthorization will call requestLocation()
|
||||||
|
// again once the user responds.
|
||||||
|
case .authorizedWhenInUse, .authorizedAlways:
|
||||||
|
manager.requestLocation()
|
||||||
|
default:
|
||||||
|
// Denied / restricted — lastLocation stays nil; GPS fields stay nil.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLLocationManagerDelegate
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
|
// Keep the most accurate fix received.
|
||||||
|
lastLocation = locations.last
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
|
// Non-fatal — GPS fields will be nil; submission still proceeds.
|
||||||
|
print("[JQC] Location fix failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
|
// If the user just granted permission, start the fix immediately.
|
||||||
|
if manager.authorizationStatus == .authorizedWhenInUse ||
|
||||||
|
manager.authorizationStatus == .authorizedAlways {
|
||||||
|
manager.requestLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user