diff --git a/ios/WingDex.xcodeproj/project.pbxproj b/ios/WingDex.xcodeproj/project.pbxproj index 123e0356..2d324778 100644 --- a/ios/WingDex.xcodeproj/project.pbxproj +++ b/ios/WingDex.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 43921D83ECB05301E343D2ED /* collage11.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 99DD81C22AA9F61ABDD60B50 /* collage11.jpg */; }; 48572F12742996B68226EECF /* FunNames.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D47136847CF32C6251A4BD /* FunNames.swift */; }; 4BF3798F0ADD7B34948B1C65 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 494CD419AD323D541E04D2AB /* AuthService.swift */; }; + DCEF48F0819EB6083C514EA5 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C391CE53E6D744A318E8B40 /* LocationService.swift */; }; 4D2754D2E24874B1AB391719 /* collage19.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 1758F97178B6D85D64F1363D /* collage19.jpg */; }; 4F4AEE471B77C7087B5DB09F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F34071627BDA774005F033 /* SettingsView.swift */; }; 58B69A80EFAF3294811D2CE3 /* AuthIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E4A1B21654CC24CA891696 /* AuthIntegrationTests.swift */; }; @@ -123,6 +124,7 @@ 452C55049EB9AB7E09E62CA9 /* FunNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunNamesTests.swift; sourceTree = ""; }; 487950E4852A38D4FFA3B405 /* collage24.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage24.jpg; sourceTree = ""; }; 494CD419AD323D541E04D2AB /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; + 3C391CE53E6D744A318E8B40 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; 4BDBF72550893F931771EA36 /* CelebrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CelebrationTests.swift; sourceTree = ""; }; 4C56DDD787BF000019EB3051 /* TaxonomyOrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaxonomyOrderTests.swift; sourceTree = ""; }; 557A070AA631D1A44FE4F407 /* GitInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitInfo.swift; sourceTree = ""; }; @@ -213,6 +215,7 @@ 5CACACCB759F9881B6989222 /* CropService.swift */, CFBE1598DFE8F27ED8313060 /* DataService.swift */, 8DF3BE4EAF4D781A2029593E /* DataStore.swift */, + 3C391CE53E6D744A318E8B40 /* LocationService.swift */, 19D47136847CF32C6251A4BD /* FunNames.swift */, C55734005BE9F01B6719A826 /* PasskeyService.swift */, 5873328D24830AA514049E5C /* PhotoService.swift */, @@ -535,6 +538,7 @@ CAF5CA6AE5E559BB812F83AC /* Assets.xcassets in Resources */, CCB60C37FEB5B3C7781CF308 /* AppIconView.swift in Sources */, 0C26BD71C3D1128D41123339 /* AppModels.swift in Sources */, 4BF3798F0ADD7B34948B1C65 /* AuthService.swift in Sources */, + DCEF48F0819EB6083C514EA5 /* LocationService.swift in Sources */, 78A345C5C3608103CBE304D2 /* AuthenticatedRequest.swift in Sources */, 701F62DDB95D5BDF65E3E23D /* CelebrationOverlay.swift in Sources */, 28C700594AF36399A460FAAB /* CollageImageCache.swift in Sources */, diff --git a/ios/WingDex/Info.plist b/ios/WingDex/Info.plist index f4a00d22..74a9bcd9 100644 --- a/ios/WingDex/Info.plist +++ b/ios/WingDex/Info.plist @@ -33,6 +33,8 @@ NSCameraUsageDescription WingDex uses the camera so you can take bird photos for identification. + NSLocationWhenInUseUsageDescription + WingDex uses your location to tag where a bird photo was taken, which improves identification accuracy. NSPhotoLibraryAddUsageDescription WingDex may save captured bird photos to your library when you choose to keep them. NSPhotoLibraryUsageDescription diff --git a/ios/WingDex/Services/LocationService.swift b/ios/WingDex/Services/LocationService.swift new file mode 100644 index 00000000..d6f7514f --- /dev/null +++ b/ios/WingDex/Services/LocationService.swift @@ -0,0 +1,67 @@ +import CoreLocation +import Foundation + +/// Provides the device's current location for geotagging in-app camera captures. +/// +/// The photo-library path already reads GPS from each photo's EXIF metadata +/// (`PhotoService.extractEXIF`), but photos captured with the in-app camera are +/// bare pixels with no embedded location. This service supplies the device's +/// current coordinate at capture time so camera photos get the same GPS signal +/// the range-prior pipeline (and future on-device model) relies on. +/// +/// Uses the modern `CLLocationUpdate.liveUpdates()` async sequence (iOS 17+) +/// rather than the delegate pattern, which keeps it clean under Swift 6 strict +/// concurrency. When-in-use authorization only; no background tracking. +/// +/// Usage: call `start()` when the camera opens, read `latestCoordinate` when a +/// photo is taken, and `stop()` when the capture flow closes. +@MainActor +final class LocationService: ObservableObject { + /// Most recent location fix, if any. + @Published private(set) var currentLocation: CLLocation? + + /// Manager retained only to request authorization (no delegate is used). + private let manager = CLLocationManager() + private var updatesTask: Task? + + /// Convenience: the latest coordinate as (lat, lon), or nil if unavailable. + var latestCoordinate: (lat: Double, lon: Double)? { + guard let coord = currentLocation?.coordinate, + CLLocationCoordinate2DIsValid(coord) + else { return nil } + return (coord.latitude, coord.longitude) + } + + /// Request when-in-use permission (if needed) and begin streaming location. + /// Safe to call repeatedly; a second call is a no-op while already running. + func start() { + if manager.authorizationStatus == .notDetermined { + manager.requestWhenInUseAuthorization() + } + guard updatesTask == nil else { return } + updatesTask = Task { [weak self] in + do { + for try await update in CLLocationUpdate.liveUpdates() { + guard let self else { return } + if let location = update.location { + self.currentLocation = location + } + } + } catch { + // Non-fatal: capture still works, photo just won't be geotagged. + } + // Clear our own handle when the stream ends/throws, but only if a + // newer start() hasn't already replaced it (avoids clobbering a + // restart). Cancellation is handled by stop(). + if let self, !Task.isCancelled { + self.updatesTask = nil + } + } + } + + /// Stop location updates (call when the capture flow closes to save battery). + func stop() { + updatesTask?.cancel() + updatesTask = nil + } +} diff --git a/ios/WingDex/ViewModels/AddPhotosViewModel.swift b/ios/WingDex/ViewModels/AddPhotosViewModel.swift index e7b996a6..8194047c 100644 --- a/ios/WingDex/ViewModels/AddPhotosViewModel.swift +++ b/ios/WingDex/ViewModels/AddPhotosViewModel.swift @@ -56,8 +56,10 @@ final class AddPhotosViewModel { var selectedItems: [PhotosPickerItem] = [] var processedPhotos: [ProcessedPhoto] = [] - /// Photos captured via the camera (UIImage, not from PhotosPicker). - var cameraPhotos: [UIImage] = [] + /// Photos captured via the camera (UIImage + capture-time location, not from + /// PhotosPicker). The in-app camera returns bare pixels with no EXIF GPS, so + /// we carry the device location captured alongside each shot. + var cameraPhotos: [(image: UIImage, lat: Double?, lon: Double?)] = [] // MARK: - Clustering @@ -172,9 +174,10 @@ final class AddPhotosViewModel { // MARK: - Camera Support - /// Add a photo captured from the camera. - func addCameraPhoto(_ image: UIImage) { - cameraPhotos.append(image) + /// Add a photo captured from the camera, with the device location at capture + /// time (nil if location was unavailable or permission was denied). + func addCameraPhoto(_ image: UIImage, lat: Double?, lon: Double?) { + cameraPhotos.append((image: image, lat: lat, lon: lon)) } // MARK: - Step 1: Process Selected Photos @@ -236,8 +239,10 @@ final class AddPhotosViewModel { extractionProgress = Double(processedCount) / Double(totalCount) * 100 } - // Process camera-captured photos (no EXIF GPS, use capture time as now) - for uiImage in cameraPhotos { + // Process camera-captured photos (no EXIF GPS; use the device location + // captured at shot time, and the processing time as the timestamp). + for camera in cameraPhotos { + let uiImage = camera.image let id = UUID().uuidString let compressed = PhotoService.compressImage(uiImage, quality: 0.7) ?? Data() let thumbnail = PhotoService.generateThumbnail(from: compressed, maxDimension: 200) ?? compressed @@ -248,8 +253,8 @@ final class AddPhotosViewModel { image: compressed, thumbnail: thumbnail, exifTime: Date(), - gpsLat: nil, - gpsLon: nil, + gpsLat: camera.lat, + gpsLon: camera.lon, fileHash: fileHash, fileName: "camera_\(id).jpg" ) diff --git a/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift b/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift index f9cc3f62..f07be743 100644 --- a/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift +++ b/ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift @@ -36,6 +36,7 @@ struct PhotoSelectionView: View { @Bindable var viewModel: AddPhotosViewModel @State private var showCamera = false @State private var collageDrag: CGSize = .zero + @StateObject private var locationService = LocationService() private static let collageImages = CollageImageCache.names @@ -119,6 +120,7 @@ struct PhotoSelectionView: View { .buttonSizing(.flexible) Button { + locationService.start() showCamera = true } label: { Label("Take Photo", systemImage: "camera") @@ -167,12 +169,14 @@ struct PhotoSelectionView: View { } } .fullScreenCover(isPresented: $showCamera, onDismiss: { + locationService.stop() if !viewModel.cameraPhotos.isEmpty { Task { await viewModel.processSelectedPhotos() } } }) { CameraCaptureView { image in - viewModel.addCameraPhoto(image) + let coord = locationService.latestCoordinate + viewModel.addCameraPhoto(image, lat: coord?.lat, lon: coord?.lon) } .ignoresSafeArea() }