Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ios/WingDex.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -123,6 +124,7 @@
452C55049EB9AB7E09E62CA9 /* FunNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunNamesTests.swift; sourceTree = "<group>"; };
487950E4852A38D4FFA3B405 /* collage24.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = collage24.jpg; sourceTree = "<group>"; };
494CD419AD323D541E04D2AB /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
3C391CE53E6D744A318E8B40 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
4BDBF72550893F931771EA36 /* CelebrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CelebrationTests.swift; sourceTree = "<group>"; };
4C56DDD787BF000019EB3051 /* TaxonomyOrderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaxonomyOrderTests.swift; sourceTree = "<group>"; };
557A070AA631D1A44FE4F407 /* GitInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitInfo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 2 additions & 0 deletions ios/WingDex/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
<false/>
<key>NSCameraUsageDescription</key>
<string>WingDex uses the camera so you can take bird photos for identification.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>WingDex uses your location to tag where a bird photo was taken, which improves identification accuracy.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>WingDex may save captured bird photos to your library when you choose to keep them.</string>
<key>NSPhotoLibraryUsageDescription</key>
Expand Down
67 changes: 67 additions & 0 deletions ios/WingDex/Services/LocationService.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

/// 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
}
}
23 changes: 14 additions & 9 deletions ios/WingDex/ViewModels/AddPhotosViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
)
Expand Down
6 changes: 5 additions & 1 deletion ios/WingDex/Views/AddPhotosFlow/PhotoSelectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -119,6 +120,7 @@ struct PhotoSelectionView: View {
.buttonSizing(.flexible)

Button {
locationService.start()
showCamera = true
} label: {
Label("Take Photo", systemImage: "camera")
Expand Down Expand Up @@ -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)
}
Comment thread
jlian marked this conversation as resolved.
.ignoresSafeArea()
}
Expand Down
Loading