// Utils/PhotoCapture.swift // ------------------------ // Capture-time metadata for evidence photos. // // The server burns a timestamp + GPS overlay into every photo uploaded through // POST /api/v1/photos/upload (see photo_stamp.py in the Flask app). It resolves // that metadata from the client form fields first, then the image's own EXIF, // then — last resort — server receipt time. // // EXIF is NOT a usable fallback for this app: savePhotoToDisk() re-encodes each // UIImage via jpegData(compressionQuality:), which strips every EXIF tag. So // these client fields are the ONLY source of true capture time and location. // Without them, a photo taken offline at 09:14 and synced at 16:00 is stamped // 16:00 — the wrong time, on evidence. // // Hence the flow: record the moment + fix AT CAPTURE, carry them through // CapturedPhoto -> PendingPhoto -> the upload request. import Foundation import UIKit import CoreLocation // MARK: - CapturedPhoto /// A photo the user just took or picked, together with the metadata recorded /// at that instant. Property names match the tuple this replaced /// (`image`, `path`), so existing call sites keep compiling. struct CapturedPhoto { let image: UIImage let path: String let capturedAt: Date let latitude: Double? let longitude: Double? /// Stamps "now" plus the freshest GPS fix available at the moment of capture. init(image: UIImage, path: String) { let fix = PhotoLocationProvider.shared.lastLocation self.image = image self.path = path self.capturedAt = Date() self.latitude = fix?.coordinate.latitude self.longitude = fix?.coordinate.longitude } } // MARK: - PhotoLocationProvider /// Shared, always-warm location source for photo capture. /// /// One long-lived CLLocationManager: views call `start()` in `onAppear` so a /// fix already exists the instant the shutter fires. Kept separate from /// ExecuteInspectionView's `InspectionLocationManager` (phase 25 submit GPS), /// which is per-view and would start cold on every photo screen. final class PhotoLocationProvider: NSObject, CLLocationManagerDelegate { static let shared = PhotoLocationProvider() private let manager = CLLocationManager() /// Most recent fix, or nil if unavailable/denied. private(set) var lastLocation: CLLocation? private override init() { super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters manager.distanceFilter = 10 } /// Request permission if needed and begin updating. Safe to call repeatedly. /// Requires NSLocationWhenInUseUsageDescription in the target's Info settings. func start() { switch manager.authorizationStatus { case .notDetermined: manager.requestWhenInUseAuthorization() // locationManagerDidChangeAuthorization starts updates once granted. case .authorizedWhenInUse, .authorizedAlways: manager.startUpdatingLocation() default: break // denied/restricted — lat/lng stay nil; the timestamp is still stamped } } func stop() { manager.stopUpdatingLocation() } // CLLocationManagerDelegate func locationManager(_ m: CLLocationManager, didUpdateLocations locations: [CLLocation]) { if let loc = locations.last { lastLocation = loc } } func locationManager(_ m: CLLocationManager, didFailWithError error: Error) { // Non-fatal — photos still upload, just without coordinates. print("[JQC] Photo location fix failed: \(error.localizedDescription)") } func locationManagerDidChangeAuthorization(_ m: CLLocationManager) { if m.authorizationStatus == .authorizedWhenInUse || m.authorizationStatus == .authorizedAlways { manager.startUpdatingLocation() } } } // MARK: - Wire format enum PhotoCaptureFormat { /// ISO-8601 with an explicit offset — the format the server's /// `_parse_client_datetime()` expects. A naive string with no offset would /// be read as Eastern wall time, so the offset must always be present. static let iso8601: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() }