diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift
index 8122214..5cacc73 100644
--- a/JanitorialQC/API/APIClient.swift
+++ b/JanitorialQC/API/APIClient.swift
@@ -191,8 +191,18 @@ actor APIClient {
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
if let parentId = inspection.parentServerId { body["parent_inspection_id"] = parentId }
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()
+ fmt.timeZone = TimeZone(identifier: "UTC")!
body["inspection_date"] = fmt.string(from: inspection.inspectionDate)
if let c = inspection.completedAt { body["completed_at"] = fmt.string(from: c) }
@@ -203,15 +213,19 @@ actor APIClient {
// ── Submit Issue ──────────────────────────────────────────────────────
- func submitIssue(_ issue: LocalIssue) async throws -> Int {
+ func submitIssue(_ issue: LocalIssue, inspectionServerId: Int?) async throws -> Int {
var body: [String: Any] = [
"facility_id": issue.facilityServerId,
"severity": issue.severity,
"description": issue.issueDescription,
"mobile_local_id": issue.localId,
]
- if let id = issue.inspection?.serverId { body["inspection_id"] = id }
- if let areaId = issue.areaServerId { body["area_id"] = areaId }
+ // Use the explicitly passed serverId rather than issue.inspection?.serverId.
+ // 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
// separate PATCH call in processIssueQueue after the issue is created,
// because the server create endpoint only stores a single photo_path.
diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/JanitorialQC/Info.plist
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift
index 531d6b1..16a3fdc 100644
--- a/JanitorialQC/JanitorialQCApp.swift
+++ b/JanitorialQC/JanitorialQCApp.swift
@@ -6,6 +6,52 @@ import SwiftUI
import SwiftData
import BackgroundTasks
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 ────────────────────────────────────
// Info.plist must declare all 4 orientations so iPad multitasking is supported
@@ -28,8 +74,9 @@ struct JanitorialQCApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
- @StateObject private var auth = AuthManager.shared
- @StateObject private var sync = SyncManager.shared
+ @StateObject private var auth = AuthManager.shared
+ @StateObject private var sync = SyncManager.shared
+ @StateObject private var appearance = AppearanceManager.shared
init() {
registerBackgroundTasks()
@@ -44,6 +91,8 @@ struct JanitorialQCApp: App {
ContentView()
.environmentObject(auth)
.environmentObject(sync)
+ .environmentObject(appearance)
+ .preferredColorScheme(appearance.mode.colorScheme)
}
.modelContainer(for: [
LocalFacility.self,
diff --git a/JanitorialQC/Models/LocalInspection.swift b/JanitorialQC/Models/LocalInspection.swift
index 9774baa..0e06fb3 100644
--- a/JanitorialQC/Models/LocalInspection.swift
+++ b/JanitorialQC/Models/LocalInspection.swift
@@ -51,6 +51,13 @@ final class LocalInspection {
/// followUpRequired badge without relying on parentServerId being non-nil.
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 ──────────────────────────────────────────────────────
@Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto]
@Relationship(deleteRule: .cascade) var localIssues: [LocalIssue]
@@ -82,6 +89,8 @@ final class LocalInspection {
self.followUpNote = nil
self.parentServerId = nil
self.parentLocalId = nil
+ self.submitLatitude = nil
+ self.submitLongitude = nil
self.pendingPhotos = []
self.localIssues = []
}
diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift
index a681f3f..b0fbc2f 100644
--- a/JanitorialQC/Sync/SyncManager.swift
+++ b/JanitorialQC/Sync/SyncManager.swift
@@ -310,25 +310,56 @@ class SyncManager: ObservableObject {
.filter { $0.syncStatus == "pending" }
.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())) ?? []
for issue in pending {
- // Guard: if the parent inspection permanently failed to sync,
- // submitting this issue without an inspection_id would create an
- // orphaned server record. Mark it failed immediately instead.
- let parentId = issue.inspectionLocalId
- let parent = allInspections.first(where: { $0.localId == parentId })
- if parent?.syncStatus == "failed" {
- 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
+ let parentLocalId = issue.inspectionLocalId
+ let parent = allInspections.first(where: { $0.localId == parentLocalId })
+
+ // ── Parent inspection status guards ───────────────────────────
+ if let parent {
+ switch parent.syncStatus {
+ case "failed":
+ // Parent permanently failed — this issue can never be linked.
+ // Mark it failed immediately rather than creating an orphaned
+ // server record with no inspection_id.
+ 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 {
- let issueId = try await APIClient.shared.submitIssue(issue)
+ let issueId = try await APIClient.shared.submitIssue(issue, inspectionServerId: inspectionServerId)
issue.serverId = issueId
issue.syncStatus = "synced"
// Photos are now represented by photoServerPaths on the server.
diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift
index 6d5612c..fcace01 100644
--- a/JanitorialQC/Views/Dashboard/DashboardView.swift
+++ b/JanitorialQC/Views/Dashboard/DashboardView.swift
@@ -1823,6 +1823,7 @@ struct TemplatesListView: View {
struct SettingsView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
+ @EnvironmentObject private var appearance: AppearanceManager
@Environment(\.modelContext) private var context
@State private var showClearCacheAlert = false
@@ -1837,6 +1838,15 @@ struct SettingsView: View {
LabeledContent("Username", value: auth.currentUsername)
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") {
Button {
diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
index 95e40d8..e7ff1ee 100644
--- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
+++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift
@@ -12,6 +12,7 @@
import SwiftUI
import SwiftData
+import CoreLocation
struct ExecuteInspectionView: View {
@@ -28,6 +29,10 @@ struct ExecuteInspectionView: View {
@State private var isSubmitting = false
@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
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 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
.overlay(alignment: .top) {
if let result = submitResult {
@@ -378,6 +388,16 @@ struct ExecuteInspectionView: View {
inspection.completedAt = Date()
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 ────────────────────
// Do this at submit time rather than relying solely on SyncManager,
// 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()
+ }
+ }
+}