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() + } + } +}