diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a665c80 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index cef1d38..93770af 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,7 @@ fastlane/test_output iOSInjectionProject/ +# Ignore local test fixtures +PathRecorderTests/fixture.json + # End of https://www.toptal.com/developers/gitignore/api/swift \ No newline at end of file diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 3a6a589..3da2d8d 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -12,6 +12,12 @@ B93D3C352E07111700B158C8 /* PathRecorderWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B93D3C1F2E07111600B158C8 /* PathRecorderWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B93D3C422E07157000B158C8 /* Shared in Frameworks */ = {isa = PBXBuildFile; productRef = B93D3C412E07157000B158C8 /* Shared */; }; B93D3C442E07158300B158C8 /* Shared in Frameworks */ = {isa = PBXBuildFile; productRef = B93D3C432E07158300B158C8 /* Shared */; }; + B9AD176E2F65C4AF00DB89CE /* Auth in Frameworks */ = {isa = PBXBuildFile; productRef = B9AD176D2F65C4AF00DB89CE /* Auth */; }; + B9AD17702F65C4AF00DB89CE /* Functions in Frameworks */ = {isa = PBXBuildFile; productRef = B9AD176F2F65C4AF00DB89CE /* Functions */; }; + B9AD17722F65C4AF00DB89CE /* PostgREST in Frameworks */ = {isa = PBXBuildFile; productRef = B9AD17712F65C4AF00DB89CE /* PostgREST */; }; + B9AD17742F65C4AF00DB89CE /* Realtime in Frameworks */ = {isa = PBXBuildFile; productRef = B9AD17732F65C4AF00DB89CE /* Realtime */; }; + B9AD17762F65C4AF00DB89CE /* Storage in Frameworks */ = {isa = PBXBuildFile; productRef = B9AD17752F65C4AF00DB89CE /* Storage */; }; + B9AD18862F65D8AE00DB89CE /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = B9AD18852F65D8AE00DB89CE /* Supabase */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -112,6 +118,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B9AD18862F65D8AE00DB89CE /* Supabase in Frameworks */, + B9AD17762F65C4AF00DB89CE /* Storage in Frameworks */, + B9AD17722F65C4AF00DB89CE /* PostgREST in Frameworks */, + B9AD17702F65C4AF00DB89CE /* Functions in Frameworks */, + B9AD176E2F65C4AF00DB89CE /* Auth in Frameworks */, + B9AD17742F65C4AF00DB89CE /* Realtime in Frameworks */, B93D3C422E07157000B158C8 /* Shared in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -198,6 +210,12 @@ name = PathRecorder; packageProductDependencies = ( B93D3C412E07157000B158C8 /* Shared */, + B9AD176D2F65C4AF00DB89CE /* Auth */, + B9AD176F2F65C4AF00DB89CE /* Functions */, + B9AD17712F65C4AF00DB89CE /* PostgREST */, + B9AD17732F65C4AF00DB89CE /* Realtime */, + B9AD17752F65C4AF00DB89CE /* Storage */, + B9AD18852F65D8AE00DB89CE /* Supabase */, ); productName = PathRecorder; productReference = 6141C8D32DECACB90034946C /* PathRecorder.app */; @@ -309,6 +327,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( B93D3C402E07152A00B158C8 /* XCLocalSwiftPackageReference "Shared" */, + B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6141C8D42DECACB90034946C /* Products */; @@ -551,7 +570,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -588,7 +607,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -687,7 +706,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -715,7 +734,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -782,6 +801,17 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/supabase/supabase-swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ B93D3C412E07157000B158C8 /* Shared */ = { isa = XCSwiftPackageProductDependency; @@ -791,6 +821,36 @@ isa = XCSwiftPackageProductDependency; productName = Shared; }; + B9AD176D2F65C4AF00DB89CE /* Auth */ = { + isa = XCSwiftPackageProductDependency; + package = B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Auth; + }; + B9AD176F2F65C4AF00DB89CE /* Functions */ = { + isa = XCSwiftPackageProductDependency; + package = B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Functions; + }; + B9AD17712F65C4AF00DB89CE /* PostgREST */ = { + isa = XCSwiftPackageProductDependency; + package = B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = PostgREST; + }; + B9AD17732F65C4AF00DB89CE /* Realtime */ = { + isa = XCSwiftPackageProductDependency; + package = B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Realtime; + }; + B9AD17752F65C4AF00DB89CE /* Storage */ = { + isa = XCSwiftPackageProductDependency; + package = B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Storage; + }; + B9AD18852F65D8AE00DB89CE /* Supabase */ = { + isa = XCSwiftPackageProductDependency; + package = B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */; + productName = Supabase; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6141C8CB2DECACB90034946C /* Project object */; diff --git a/PathRecorder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PathRecorder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..b2d8bac --- /dev/null +++ b/PathRecorder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "767828bc91c1417044a802858d3eed0ee05d42b82288a486b5d47e6ae0cfb4ba", + "pins" : [ + { + "identity" : "supabase-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/supabase/supabase-swift.git", + "state" : { + "revision" : "0f8bf83b55709e5530cd842a0185b9abba7a3c6c", + "version" : "2.41.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", + "version" : "1.9.0" + } + } + ], + "version" : 3 +} diff --git a/PathRecorder/AppDelegate.swift b/PathRecorder/AppDelegate.swift index 1fc4ee4..c185b66 100644 --- a/PathRecorder/AppDelegate.swift +++ b/PathRecorder/AppDelegate.swift @@ -1,4 +1,5 @@ import UIKit +import BackgroundTasks class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index ee793e2..4ac587f 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -8,29 +8,82 @@ import SwiftUI import SwiftData import CoreLocation +import Shared // Import the module if needed +import StoreKit struct ContentView: View { + private let rateAlertKey = "PathRecorder.HasShownRateAlert" + // Computed property for sort order label + var sortOrderLabel: String { + switch selectedSortField { + case .date: + return sortAscending ? "Least recent" : "Most recent" + case .time: + return sortAscending ? "Shortest first" : "Longest first" + case .distance: + return sortAscending ? "Shortest first" : "Longest first" + case .pace: + return sortAscending ? "Fastest first" : "Slowest first" + } + } + @EnvironmentObject private var authManager: AuthManager + @EnvironmentObject private var backupService: BackupRestoreService @StateObject private var locationManager = LocationManager() @StateObject private var pathStorage = PathStorage() @StateObject private var settings = Settings() @State private var showRecordingSheet = false + @State private var recordingPulse = false @State private var selectedPathForRename: RecordedPath? = nil @State private var navigationPath = NavigationPath() @State private var showRenameSheet = false @State private var showLocationAlert = false @State private var showSettingsSheet = false + enum SortField: String, CaseIterable, Identifiable { + case date = "Date" + case pace = "Pace" + case time = "Time" + case distance = "Distance" + var id: String { rawValue } + } + @State private var selectedSortField: SortField = .date + @State private var sortAscending: Bool = false + var body: some View { NavigationStack(path: $navigationPath) { - VStack(spacing: 20) { + VStack(spacing: 10) { Text("No history yet — start recording to track your journeys.") .font(.headline) .foregroundColor(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, maxHeight: pathStorage.recordedPaths.isEmpty ? .infinity : 0, alignment: .center) .opacity(pathStorage.recordedPaths.isEmpty ? 1 : 0) + + if pathStorage.recordedPaths.count > 1 { + HStack { + Menu { + Picker("Sort by", selection: $selectedSortField) { + ForEach(SortField.allCases) { field in + Text(field.rawValue).tag(field) + } + } + } label: { + Text("Sort by \(selectedSortField.rawValue)") + } + .font(.subheadline) + Spacer() + Button(action: { + sortAscending.toggle() + }) { + Text(sortOrderLabel) + } + .font(.subheadline) + } + .padding(.horizontal) + } + List { - ForEach(pathStorage.recordedPaths.sorted(by: { $0.startTime > $1.startTime })) { path in + ForEach(sortedPaths) { path in RecordedPathRow( path: path, onEdit: { @@ -38,7 +91,7 @@ struct ContentView: View { locationManager.loadPathForEditing(path, pathStorage: pathStorage) }, onDelete: { - pathStorage.deletePath(id: path.id) + authManager.deletePath(path, pathStorage: pathStorage) }, formatTime: formatTime, onSelect: { @@ -50,59 +103,96 @@ struct ContentView: View { } } .listStyle(.plain) - - Button(action: { - if locationManager.authorizationStatus == .authorizedAlways || locationManager.authorizationStatus == .authorizedWhenInUse { - locationManager.startRecording() + + if locationManager.isRecording { + Button { showRecordingSheet = true - } else { - showLocationAlert = true - } - }) { - Text("Start Recording") - .font(.headline) - .foregroundColor(.white) + } label: { + HStack(spacing: 8) { + Text(locationManager.isPaused ? "Paused — Tap to Return" : "Recording — Tap to Return") + .font(.headline) + .foregroundColor(.white) + } .padding() .frame(maxWidth: .infinity) - .background(Color.green) + .background(locationManager.isPaused ? Color.orange : Color.red) .cornerRadius(10) - } - .padding(.horizontal) - .alert("Location Access Needed", isPresented: $showLocationAlert) { - Button("Open Settings") { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) + } + .padding(.horizontal) + .onAppear { recordingPulse = true } + } else { + Button(action: { + if locationManager.authorizationStatus == .authorizedAlways || locationManager.authorizationStatus == .authorizedWhenInUse { + locationManager.startRecording() + showRecordingSheet = true + } else { + showLocationAlert = true } + }) { + Text("Start Recording") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.green) + .cornerRadius(10) + } + .padding(.horizontal) + .alert("Location Access Needed", isPresented: $showLocationAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("To record your path, please allow location access in Settings.") } - Button("Cancel", role: .cancel) { } - } message: { - Text("To record your path, please allow location access in Settings.") } } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .onAppear { locationManager.requestPermission() - // Automatically show recording view if in-progress recording exists - if locationManager.isRecording && locationManager.isPaused { - showRecordingSheet = true + // Show StoreKit review prompt if more than 3 recordings and not shown before + let hasShownRateAlert = UserDefaults.standard.bool(forKey: rateAlertKey) + if pathStorage.recordedPaths.count >= 3 && !hasShownRateAlert { + if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { + AppStore.requestReview(in: windowScene) + } + UserDefaults.standard.set(true, forKey: rateAlertKey) } } - .onReceive(locationManager.$pathNeedingRename) { path in + .onChange(of: authManager.currentUser?.id) { _, userId in + if userId != nil { + Task { await authManager.syncOnLogin(pathStorage: pathStorage, backupService: backupService) } + } else { + authManager.unsyncedPathIds = [] + authManager.dirtyPathIds = [] + } + } + .onChange(of: pathStorage.recordedPaths.count) { _, _ in + guard authManager.currentUser != nil else { return } + Task { await authManager.refreshSyncStatus(localPaths: pathStorage.recordedPaths) } + } + .onChange(of: locationManager.lastEditedPathId) { _, editedId in + guard let id = editedId, authManager.currentUser != nil else { return } + authManager.dirtyPathIds.insert(id) + locationManager.lastEditedPathId = nil + } + .onChange(of: pathStorage.lastUpdatedPathId) { _, updatedId in + guard let id = updatedId, authManager.currentUser != nil else { return } + authManager.dirtyPathIds.insert(id) + pathStorage.lastUpdatedPathId = nil + } + .onReceive(locationManager.$pathToNavigateTo) { path in if let path = path { selectedPathForRename = path navigationPath.append(path) - showRenameSheet = true + showRenameSheet = locationManager.editingPathName == nil } } - .fullScreenCover(isPresented: Binding( - get: { showRecordingSheet }, - set: { newValue in - if !newValue { - showRecordingSheet = false - } - }) - ) { + .navigationDestination(isPresented: $showRecordingSheet) { RecordingView( locationManager: locationManager, pathStorage: pathStorage, @@ -123,24 +213,53 @@ struct ContentView: View { } } .sheet(isPresented: $showSettingsSheet) { - SettingsView(settings: settings) + SettingsView(settings: settings, pathStorage: pathStorage) + .environmentObject(backupService) } .navigationDestination(for: RecordedPath.self) { path in - let view = PathMapView( + PathMapView( recordedPath: path, locationManager: locationManager, pathStorage: pathStorage, + settings: settings, showRenameSheetOnAppear: showRenameSheet, onModifyPath: { showRecordingSheet = true } ) - showRenameSheet = false - return view + .onAppear { + showRenameSheet = false + } + } + } + } + + // Computed property for sorted paths + var sortedPaths: [RecordedPath] { + let paths = pathStorage.recordedPaths + switch selectedSortField { + case .date: + return paths.sorted { sortAscending ? $0.startTime < $1.startTime : $0.startTime > $1.startTime } + case .pace: + // Lower pace = faster, so ascending = fastest first + return paths.sorted { + let pace0 = computePaceValue(distanceMeters: $0.totalDistance, elapsedSeconds: $0.totalDuration) + let pace1 = computePaceValue(distanceMeters: $1.totalDistance, elapsedSeconds: $1.totalDuration) + return sortAscending ? pace0 < pace1 : pace0 > pace1 } + case .time: + return paths.sorted { sortAscending ? $0.totalDuration < $1.totalDuration : $0.totalDuration > $1.totalDuration } + case .distance: + return paths.sorted { sortAscending ? $0.totalDistance < $1.totalDistance : $0.totalDistance > $1.totalDistance } } } + // Helper to get pace as seconds per meter (or per km/mi, but for sorting, use SI) + func computePaceValue(distanceMeters: Double, elapsedSeconds: Double) -> Double { + guard distanceMeters > 0 else { return Double.greatestFiniteMagnitude } + return elapsedSeconds / distanceMeters + } + private func formatTime(_ timeInterval: TimeInterval) -> String { let hours = Int(timeInterval) / 3600 let minutes = Int(timeInterval) / 60 % 60 @@ -180,11 +299,17 @@ struct RecordedPathRow: View { Text(settings.formatDistance(path.totalDistance)) } HStack(spacing: 6) { - Image(systemName: "timer") + Image(systemName: "alarm") .foregroundColor(.orange) .font(.subheadline) Text(formatTime(path.totalDuration)) } + HStack(spacing: 6) { + Image(systemName: "timer") + .foregroundColor(.blue) + .font(.subheadline) + Text(computePace(distanceMeters: path.totalDistance, elapsedSeconds: path.totalDuration, unit: settings.distanceUnit.rawValue)) + } } .font(.subheadline) .foregroundColor(.secondary) @@ -219,38 +344,3 @@ struct RecordedPathRow: View { } } } - -struct SettingsView: View { - @ObservedObject var settings: Settings - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationView { - Form { - Section(header: Text("Distance Units")) { - Picker("Distance Unit", selection: $settings.distanceUnit) { - ForEach(DistanceUnit.allCases, id: \.self) { unit in - Text(unit.displayName).tag(unit) - } - } - .pickerStyle(SegmentedPickerStyle()) - } - } - .navigationTitle("Settings") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { - dismiss() - } - } - } - } - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) - .environmentObject(LocationManager()) -} diff --git a/PathRecorder/Info.plist b/PathRecorder/Info.plist index db761d0..700b28c 100644 --- a/PathRecorder/Info.plist +++ b/PathRecorder/Info.plist @@ -5,6 +5,11 @@ UIBackgroundModes location + processing + + BGTaskSchedulerPermittedIdentifiers + + com.pathrecorder.backup diff --git a/PathRecorder/Item.swift b/PathRecorder/Item.swift deleted file mode 100644 index 601986c..0000000 --- a/PathRecorder/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// PathRecorder -// -// Created by Brad Dettmer on 6/1/25. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/PathRecorder/LocationManager.swift b/PathRecorder/LocationManager.swift index 8941b03..0ff13f2 100644 --- a/PathRecorder/LocationManager.swift +++ b/PathRecorder/LocationManager.swift @@ -9,7 +9,22 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var capturedPhotos: [PathPhoto] = [] func addPhoto(_ photo: PathPhoto) { capturedPhotos.append(photo) - saveRecordingState() // Persist photos immediately after adding + saveRecordingState() + } + + /// Snapshots the current GPS position into the recorded path and returns its id. + /// Call this at the moment a photo is captured so the photo has a precise location pin. + func recordPhotoLocation() -> UUID? { + guard let current = currentLocation else { return nil } + let gpsLocation = GPSLocation( + latitude: current.coordinate.latitude, + longitude: current.coordinate.longitude, + timestamp: current.timestamp, + segmentId: currentSegmentId + ) + locations.append(gpsLocation) + saveRecordingState() + return gpsLocation.id } private let locationManager = CLLocationManager() @Published var locations: [GPSLocation] = [] @@ -22,7 +37,8 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var currentActivity: Activity? @Published var editingPathId: UUID? = nil @Published var editingPathName: String? = nil - @Published var pathNeedingRename: RecordedPath? = nil // Track path needing rename + @Published var pathToNavigateTo: RecordedPath? = nil // Track path to navigate to after recording + @Published var lastEditedPathId: UUID? = nil // Properties for improved distance calculation private var lastProcessedTime: Date? @@ -137,8 +153,9 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { currentSegmentId = UUID() // Start a new segment isRecording = true isPaused = false + self.editingPathId = nil + self.editingPathName = nil locationManager.startUpdatingLocation() - self.markSegment() // Ensure segment starts with a coordinate startLiveActivity() // Start a timer to update elapsed time and Live Activity every second startActivityTimer() @@ -155,8 +172,6 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { self.stopActivityTimer() self.endLiveActivity() self.saveCurrentPath(to: pathStorage) - self.editingPathId = nil - self.editingPathName = nil UserDefaults.standard.removeObject(forKey: self.recordingStateKey) // Clear saved state } } @@ -184,10 +199,11 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { self.lastProcessedTime = nil self.lastProcessedLocation = nil self.recentLocations.removeAll() - // Start a new segment when resuming + // Start a new segment when resuming; assign the segment ID before location updates begin self.currentSegmentId = UUID() self.locationManager.startUpdatingLocation() - self.markSegment() // Ensure segment starts with a coordinate + // Do not duplicate the last paused location in the new segment. + // Subsequent location updates will belong to this new segment. // Recreate the timer when resuming self.startActivityTimer() // Update Live Activity to show resumed state @@ -418,8 +434,8 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { return } - // Load the existing data - self.locations = path.locations + // Flatten segments back to locations for editing + self.locations = path.segments.flatMap { $0.locations } self.totalDistance = path.totalDistance self.elapsedTime = path.totalDuration self.startTime = path.startTime @@ -429,7 +445,10 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { self.isRecording = true self.isPaused = true // Start in paused state as requested self.editingPathId = path.id + + // Restore all photos associated with the selected path self.capturedPhotos = path.photos + // Clear current location to prevent showing stale location annotation self.currentLocation = nil // Set up for continuing the path @@ -438,11 +457,8 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { // Start Live Activity immediately with the correct initial values self.startLiveActivity() - // Don't start the timer yet since we're starting in paused state - // The timer will be created when resumeRecording() is called - - // Don't automatically resume - let the user manually resume when ready - // self.resumeRecording() + // Automatically resume when editing the path + self.resumeRecording() print("Loaded existing path for editing - Distance: \(totalDistance)m, Duration: \(elapsedTime)s") } @@ -450,28 +466,32 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { func saveCurrentPath(to pathStorage: PathStorage) { guard let startTime = startTime else { return } - if (editingPathId != nil) { - // If editing, delete the old path immediately after loading for editing - pathStorage.deletePath(id: editingPathId!) - } + // Group locations by segmentId to create PathSegments + let groupedBySegment = Dictionary(grouping: locations) { $0.segmentId } + let segments = groupedBySegment + .sorted { segments1, segments2 in + (segments1.value.first?.timestamp ?? Date()) < (segments2.value.first?.timestamp ?? Date()) + } + .map { _, groupedLocations in + let sortedLocations = groupedLocations.sorted { $0.timestamp < $1.timestamp } + return PathSegment(locations: sortedLocations) + } - // Create new path - let recordedPath = RecordedPath( - startTime: startTime, - totalDuration: elapsedTime, - totalDistance: totalDistance, - locations: locations, - photos: capturedPhotos, - name: editingPathName - ) + let recordedPath: RecordedPath + if let editId = editingPathId { + // Preserve the original ID so the server record is updated in place via upsert + recordedPath = RecordedPath(id: editId, segments: segments, + name: editingPathName ?? "Unnamed", photos: capturedPhotos) + lastEditedPathId = editId + } else { + recordedPath = RecordedPath(segments: segments, name: editingPathName, photos: capturedPhotos) + } pathStorage.savePath(recordedPath) capturedPhotos.removeAll() - // If name is nil, trigger UI to show rename sheet for this path - if editingPathName == nil { - DispatchQueue.main.async { - self.pathNeedingRename = recordedPath - } + // Always navigate to the path, but only show rename sheet if name is nil + DispatchQueue.main.async { + self.pathToNavigateTo = recordedPath } } diff --git a/PathRecorder/MapComponents/CameraView.swift b/PathRecorder/MapComponents/CameraView.swift deleted file mode 100644 index f48d6fb..0000000 --- a/PathRecorder/MapComponents/CameraView.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI -import UIKit - -struct CameraView: UIViewControllerRepresentable { - // Listen for app background notification and dismiss camera if needed - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.sourceType = .camera - picker.delegate = context.coordinator - picker.allowsEditing = false - picker.modalPresentationStyle = .fullScreen - NotificationCenter.default.addObserver(context.coordinator, selector: #selector(context.coordinator.handleAppDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) - return picker - } - @Binding var isPresented: Bool - var onImageCaptured: (UIImage?) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { - // noop, isPresented manages visibility of the camera - } - - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - @objc func handleAppDidEnterBackground() { - print("[CameraView] App sent to background, dismissing camera.") - DispatchQueue.main.async { - self.parent.isPresented = false - } - } - let parent: CameraView - init(_ parent: CameraView) { - self.parent = parent - } - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { - print("[CameraView] didFinishPickingMediaWithInfo called") - let image = info[.originalImage] as? UIImage - let fixedImage = image.flatMap { Self.fixOrientation($0) } - if let fixedImage = fixedImage { - print("[CameraView] Photo captured, calling onImageCaptured.") - parent.onImageCaptured(fixedImage) - } else { - print("[CameraView] No image captured.") - } - DispatchQueue.main.async { - self.parent.isPresented = false - } - } - - // Helper to fix image orientation for landscape photos - static func fixOrientation(_ image: UIImage) -> UIImage { - if image.imageOrientation == .up { - return image - } - UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) - image.draw(in: CGRect(origin: .zero, size: image.size)) - let normalizedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image - UIGraphicsEndImageContext() - return normalizedImage - } - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - print("[CameraView] Camera cancelled, dismissing.") - DispatchQueue.main.async { - self.parent.isPresented = false - } - } - } -} diff --git a/PathRecorder/MapComponents/Double+Extensions.swift b/PathRecorder/MapComponents/Double+Extensions.swift deleted file mode 100644 index efee924..0000000 --- a/PathRecorder/MapComponents/Double+Extensions.swift +++ /dev/null @@ -1,5 +0,0 @@ -extension Double { - func isEqual(to other: Double, accuracy: Double) -> Bool { - return abs(self - other) < accuracy - } -} \ No newline at end of file diff --git a/PathRecorder/MapComponents/LiveMap/CameraView.swift b/PathRecorder/MapComponents/LiveMap/CameraView.swift new file mode 100644 index 0000000..e2a1ffb --- /dev/null +++ b/PathRecorder/MapComponents/LiveMap/CameraView.swift @@ -0,0 +1,581 @@ +import SwiftUI +import AVFoundation +import UIKit + +// MARK: - Flash Mode Enum +enum FlashMode { + case off, on, auto + + var avFlashMode: AVCaptureDevice.FlashMode { + switch self { + case .off: return .off + case .on: return .on + case .auto: return .auto + } + } +} + +// MARK: - Main Camera View +struct CameraView: View { + @StateObject private var cameraService = CameraService() + @Binding var isPresented: Bool + var onImageCaptured: (UIImage) -> Void + + @State private var previewImage: UIImage? + @State private var showZoomSlider = false + + var body: some View { + ZStack { + if let image = previewImage { + VStack { + Spacer() + HStack { + Button("Retake") { + previewImage = nil + cameraService.clearPendingPhoto() + cameraService.restartSession() + } + .padding() + .foregroundColor(.white) + Spacer() + Button("Use Photo") { + cameraService.confirmCapturedPhoto() + } + .padding() + .foregroundColor(.white) + } + .background(Color.black.opacity(0.6)) + } + .background( + Image(uiImage: image) + .resizable() + .scaledToFit() + .ignoresSafeArea() + ) + } else { + CameraPreview( + session: cameraService.session, + cameraPosition: cameraService.currentCameraPosition, + cameraService: cameraService, + showZoomSlider: $showZoomSlider + ) + .ignoresSafeArea() + + VStack { + // Top row with close button and camera controls + HStack { + // Close button on top left + Button(action: { + previewImage = nil + cameraService.clearPendingPhoto() // Clear pending photo when retaking + isPresented = false + }) { + Image(systemName: "xmark") + .font(.system(size: 24)) + .foregroundColor(.white) + .padding() + } + + Spacer() + + // Camera switch button on top right + Button(action: { + cameraService.switchCamera() + }) { + Image(systemName: "arrow.triangle.2.circlepath.camera") + .font(.system(size: 24)) + .foregroundColor(.white) + .padding() + } + } + + // Flash toggle button below camera switch + HStack { + Spacer() + Button(action: { + cameraService.toggleFlashMode() + }) { + Image(systemName: flashIcon(for: cameraService.flashMode)) + .font(.system(size: 24)) + .foregroundColor(.white) + .padding() + } + } + + Spacer() + + // Zoom slider (only visible during zoom and on back camera) + if showZoomSlider && cameraService.currentCameraPosition == .back { + HStack { + Text("1×") + .font(.caption) + .foregroundColor(.white) + + Slider( + value: $cameraService.zoomFactor, + in: 1.0...min(cameraService.maxZoomFactor, 5.0), + step: 0.1 + ) + .accentColor(.white) + + Text("\(String(format: "%.1f", min(cameraService.maxZoomFactor, 5.0)))×") + .font(.caption) + .foregroundColor(.white) + } + .padding(.horizontal, 40) + .padding(.vertical, 8) + .background(Color.black.opacity(0.6)) + .cornerRadius(20) + .transition(.opacity.combined(with: .move(edge: .bottom))) + .animation(.easeInOut(duration: 0.3), value: showZoomSlider) + } + + // Capture button at bottom + ZStack { + // Decorative border + Circle() + .stroke(Color.black, lineWidth: 6) + .frame(width: 70, height: 70) + Circle() + .stroke(Color.white, lineWidth: 4) + .frame(width: 70, height: 70) + // Tappable inner button + Button(action: { + cameraService.capturePhoto() + }) { + Circle() + .fill(Color.white) + .frame(width: 62, height: 62) + } + } + .shadow(radius: 5) + .padding(.bottom, 30) + } + .padding() + } + } + .onAppear { + cameraService.start() + cameraService.onPhotoCapture = { image in + onImageCaptured(image) + isPresented = false + } + cameraService.onImageCapturedForPreview = { image in + previewImage = image + } + // Set up callback to close camera when app is backgrounded + cameraService.onAppBackgrounded = { + isPresented = false + } + } + .onDisappear { + cameraService.stop() + } + .alert("Camera Error", isPresented: Binding(get: { cameraService.captureError != nil }, set: { if !$0 { cameraService.captureError = nil }})) { + Button("OK", role: .cancel) { + cameraService.captureError = nil + } + } message: { + Text(cameraService.captureError ?? "An unknown camera error occurred.") + } + } + + // Helper for flash icon + func flashIcon(for mode: FlashMode) -> String { + switch mode { + case .off: return "bolt.slash.fill" + case .on: return "bolt.fill" + case .auto: return "bolt.badge.a.fill" + } + } +} + +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + let cameraPosition: AVCaptureDevice.Position + + @ObservedObject var cameraService: CameraService + @Binding var showZoomSlider: Bool + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.backgroundColor = .black + + // Setup preview layer + let previewLayer = AVCaptureVideoPreviewLayer(session: session) + previewLayer.videoGravity = .resizeAspectFill + + // Set initial frame + previewLayer.frame = view.bounds + view.layer.addSublayer(previewLayer) + context.coordinator.previewLayer = previewLayer + + // Configure mirroring + configureMirroring(for: previewLayer, position: cameraPosition) + + // Add pinch gesture recognizer + let pinchGesture = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch(_:))) + view.addGestureRecognizer(pinchGesture) + + // Store reference to the binding in coordinator + context.coordinator.showZoomSlider = $showZoomSlider + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + guard let previewLayer = context.coordinator.previewLayer else { return } + + // Update frame + previewLayer.frame = uiView.bounds + + // Update mirroring when camera position changes + configureMirroring(for: previewLayer, position: cameraPosition) + } + + private func configureMirroring(for previewLayer: AVCaptureVideoPreviewLayer, position: AVCaptureDevice.Position) { + // Small delay to ensure connection is established + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let connection = previewLayer.connection else { return } + + if position == .front { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = true + } else { + connection.automaticallyAdjustsVideoMirroring = true + // Don't set isVideoMirrored when automatic mirroring is enabled + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(cameraService: cameraService, showZoomSlider: $showZoomSlider) + } + + class Coordinator: NSObject { + var previewLayer: AVCaptureVideoPreviewLayer? + private var cameraService: CameraService + var showZoomSlider: Binding! + + init(cameraService: CameraService, showZoomSlider: Binding) { + self.cameraService = cameraService + self.showZoomSlider = showZoomSlider + } + + private var lastZoom: CGFloat = 1.0 + private var hideSliderTimer: Timer? + + @objc func handlePinch(_ pinch: UIPinchGestureRecognizer) { + // Only allow zoom on back camera + guard cameraService.currentCameraPosition == .back else { return } + guard let device = cameraService.currentDevice else { return } + + if pinch.state == .began { + lastZoom = cameraService.zoomFactor + + // Show zoom slider + DispatchQueue.main.async { + self.showZoomSlider.wrappedValue = true + } + + // Cancel any existing timer + hideSliderTimer?.invalidate() + } + + let newZoom = lastZoom * pinch.scale + let clampedZoom = max(1.0, min(newZoom, min(device.activeFormat.videoMaxZoomFactor, 5.0))) + + cameraService.zoomFactor = clampedZoom + + if pinch.state == .ended || pinch.state == .cancelled { + // Start timer to hide slider after 2 seconds of inactivity + hideSliderTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { _ in + DispatchQueue.main.async { + self.showZoomSlider.wrappedValue = false + } + } + } + } + } +} + +// MARK: - Camera Service (AVCaptureSession) - UPDATED +class CameraService: NSObject, ObservableObject { + let session = AVCaptureSession() + private let output = AVCapturePhotoOutput() + private var capturedImagePendingConfirmation: UIImage? + + @Published var currentCameraPosition: AVCaptureDevice.Position = .back + @Published var flashMode: FlashMode = .auto + @Published var isSessionConfigured = false + @Published var captureError: String? = nil + + @Published var zoomFactor: CGFloat = 1.0 { + didSet { + setZoom(factor: zoomFactor) + } + } + + @Published var maxZoomFactor: CGFloat = 10.0 + + var onPhotoCapture: ((UIImage) -> Void)? + var onImageCapturedForPreview: ((UIImage) -> Void)? + var onAppBackgrounded: (() -> Void)? // New callback for when app is backgrounded + + private var isConfigured = false + + var currentDevice: AVCaptureDevice? { + session.inputs.compactMap { ($0 as? AVCaptureDeviceInput)?.device }.first + } + + override init() { + super.init() + // Don't configure here - wait for start() to be called + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func configureSession(position: AVCaptureDevice.Position) { + // Ensure we're on a background queue for session configuration + DispatchQueue.global(qos: .userInitiated).async { + self.session.beginConfiguration() + self.session.sessionPreset = .photo + + // Remove all inputs + for input in self.session.inputs { + self.session.removeInput(input) + } + + // Remove all outputs + for output in self.session.outputs { + self.session.removeOutput(output) + } + + // Discover all camera device types for this position + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [ + .builtInWideAngleCamera, + .builtInTelephotoCamera, + .builtInUltraWideCamera + ], + mediaType: .video, + position: position + ) + + guard let device = discoverySession.devices.first else { + print("[CameraService] No camera found for position \(position).") + self.session.commitConfiguration() + return + } + + do { + let input = try AVCaptureDeviceInput(device: device) + if self.session.canAddInput(input) && self.session.canAddOutput(self.output) { + self.session.addInput(input) + self.session.addOutput(self.output) + } else { + print("[CameraService] Cannot add input or output") + self.session.commitConfiguration() + return + } + } catch { + print("[CameraService] Error creating AVCaptureDeviceInput: \(error)") + self.session.commitConfiguration() + return + } + + self.session.commitConfiguration() + + // Update on main thread + DispatchQueue.main.async { + self.isConfigured = true + self.isSessionConfigured = true + // Update max zoom factor based on current device + if let device = self.currentDevice { + self.maxZoomFactor = device.activeFormat.videoMaxZoomFactor + } + } + + // Start the session immediately after configuration + if !self.session.isRunning { + self.session.startRunning() + } + } + } + + func start() { + // Configure session if not already configured + if !isConfigured { + configureSession(position: currentCameraPosition) + } else if !session.isRunning { + DispatchQueue.global(qos: .userInitiated).async { + self.session.startRunning() + } + } + } + + func stop() { + if session.isRunning { + DispatchQueue.global(qos: .userInitiated).async { + self.session.stopRunning() + } + } + } + + func switchCamera() { + currentCameraPosition = (currentCameraPosition == .back) ? .front : .back + isConfigured = false // Reset configuration flag + configureSession(position: currentCameraPosition) + zoomFactor = 1.0 // Reset zoom when switching cameras + } + + func toggleFlashMode() { + switch flashMode { + case .off: flashMode = .on + case .on: flashMode = .auto + case .auto: flashMode = .off + } + } + + func capturePhoto() { + let settings = AVCapturePhotoSettings() + if output.supportedFlashModes.contains(flashMode.avFlashMode) { + settings.flashMode = flashMode.avFlashMode + } + + guard let connection = output.connection(with: .video), connection.isActive, connection.isEnabled else { + print("[CameraService] Cannot capture photo: no active video connection.") + DispatchQueue.main.async { + self.captureError = "Camera is unavailable. Please try again when the camera is ready." + } + return + } + + connection.videoOrientation = currentVideoOrientation() + output.capturePhoto(with: settings, delegate: self) + } + + private func currentVideoOrientation() -> AVCaptureVideoOrientation { + let deviceOrientation = UIDevice.current.orientation + + switch deviceOrientation { + case .portrait: + return .portrait + case .portraitUpsideDown: + return .portraitUpsideDown + case .landscapeLeft: + return .landscapeRight + case .landscapeRight: + return .landscapeLeft + default: + return .portrait + } + } + + func confirmCapturedPhoto() { + guard let image = capturedImagePendingConfirmation else { return } + print("[CameraService] User confirmed photo") + onPhotoCapture?(image) + capturedImagePendingConfirmation = nil + } + + // New function to clear pending photo when retaking + func clearPendingPhoto() { + print("[CameraService] Clearing pending photo") + capturedImagePendingConfirmation = nil + } + + // New function to properly restart the session for retake + func restartSession() { + print("[CameraService] Restarting session for retake") + DispatchQueue.global(qos: .userInitiated).async { + if self.session.isRunning { + self.session.stopRunning() + } + + // Small delay to ensure session is fully stopped + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.isConfigured = false + self.isSessionConfigured = false + self.start() + } + } + } + + private func setZoom(factor: CGFloat) { + guard let device = currentDevice else { return } + do { + try device.lockForConfiguration() + let zoom = max(1.0, min(factor, device.activeFormat.videoMaxZoomFactor)) + device.videoZoomFactor = zoom + device.unlockForConfiguration() + } catch { + print("[CameraService] Failed to set zoom: \(error)") + } + } + + @objc func handleAppDidEnterBackground() { + print("[CameraService] App entered background") + + // First, trigger camera close callback + DispatchQueue.main.async { + self.onAppBackgrounded?() + } + + // Only save photo if there's a pending image (user is in preview mode) + if let image = capturedImagePendingConfirmation { + print("[CameraService] Auto-saving photo due to background") + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + onPhotoCapture?(image) + capturedImagePendingConfirmation = nil + } else { + print("[CameraService] No pending photo to save - user was in live camera view") + } + } +} + +// MARK: - AVCapturePhotoCaptureDelegate +extension CameraService: AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard let data = photo.fileDataRepresentation(), + var image = UIImage(data: data) else { + print("[CameraService] Failed to process photo") + return + } + + // Fix orientation if needed + image = fixImageOrientation(image) + + print("[CameraService] Photo captured, awaiting confirmation") + capturedImagePendingConfirmation = image + + DispatchQueue.main.async { + self.onImageCapturedForPreview?(image) + } + } + + private func fixImageOrientation(_ image: UIImage) -> UIImage { + // If the image is already in the correct orientation, return it as-is + if image.imageOrientation == .up { + return image + } + + // Create a graphics context and draw the image in the correct orientation + UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) + image.draw(in: CGRect(origin: .zero, size: image.size)) + let normalizedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image + UIGraphicsEndImageContext() + + return normalizedImage + } +} \ No newline at end of file diff --git a/PathRecorder/MapComponents/LiveMapViewController.swift b/PathRecorder/MapComponents/LiveMap/LiveMapViewController.swift similarity index 91% rename from PathRecorder/MapComponents/LiveMapViewController.swift rename to PathRecorder/MapComponents/LiveMap/LiveMapViewController.swift index 39d3692..44609b8 100644 --- a/PathRecorder/MapComponents/LiveMapViewController.swift +++ b/PathRecorder/MapComponents/LiveMap/LiveMapViewController.swift @@ -77,14 +77,19 @@ class LiveMapViewController: UIViewController, MKMapViewDelegate { mapView.removeOverlay(polyline) overlays.removeValue(forKey: id) } - for (id, locs) in grouped { - let coords = locs.sorted(by: { $0.timestamp < $1.timestamp }).map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } - if let polyline = overlays[id] { + let orderedSegments = grouped + .map { (id: $0.key, locations: $0.value.sorted(by: { $0.timestamp < $1.timestamp })) } + .sorted { $0.locations.first?.timestamp ?? .distantPast < $1.locations.first?.timestamp ?? .distantPast } + + for (index, segment) in orderedSegments.enumerated() { + let coords = segment.locations.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } + if let polyline = overlays[segment.id] { mapView.removeOverlay(polyline) } if coords.count >= 2 { let polyline = MKPolyline(coordinates: coords, count: coords.count) - overlays[id] = polyline + polyline.title = "segment_\(index)" + overlays[segment.id] = polyline mapView.addOverlay(polyline) } } @@ -148,7 +153,7 @@ class LiveMapViewController: UIViewController, MKMapViewDelegate { } else { annotationView?.annotation = annotation } - annotationView?.image = MapRenderingHelpers.cachedGlowingBlueDotImage + annotationView?.image = MapRenderingHelpers.cachedGlowingBlueDotImage() annotationView?.centerOffset = CGPoint(x: 0, y: 0) return annotationView } @@ -169,4 +174,4 @@ class LiveMapViewController: UIViewController, MKMapViewDelegate { // Notify delegate about the touch immediately when finger touches down delegate?.mapTouched(at: coordinate, point: touchPoint) } -} \ No newline at end of file +} diff --git a/PathRecorder/MapComponents/LiveMapViewControllerRepresentable.swift b/PathRecorder/MapComponents/LiveMap/LiveMapViewControllerRepresentable.swift similarity index 100% rename from PathRecorder/MapComponents/LiveMapViewControllerRepresentable.swift rename to PathRecorder/MapComponents/LiveMap/LiveMapViewControllerRepresentable.swift diff --git a/PathRecorder/MapComponents/LivePathMapView.swift b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift similarity index 95% rename from PathRecorder/MapComponents/LivePathMapView.swift rename to PathRecorder/MapComponents/LiveMap/LivePathMapView.swift index e0ae040..b07edee 100644 --- a/PathRecorder/MapComponents/LivePathMapView.swift +++ b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift @@ -8,7 +8,6 @@ struct LivePathMapView: View { @ObservedObject var pathStorage: PathStorage @State private var region: MKCoordinateRegion? @State private var isAutoCentering: Bool = true - @State private var lastCenterLocation: CLLocationCoordinate2D? @State private var showCamera = false @State private var capturedImage: UIImage? @State private var hasCurrentGPS: Bool = false // Track if we have current GPS @@ -78,7 +77,7 @@ struct LivePathMapView: View { Spacer() ProgressView() .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(1.5) + .scaleEffect(1.6) Text("Waiting for GPS...") .font(.headline) .padding(.top, 8) @@ -146,13 +145,13 @@ struct LivePathMapView: View { CameraView(isPresented: $showCamera, onImageCaptured: { image in capturedImage = image // Save photo to current path - if let image = image, let location = locationManager.currentLocation { + if let locationId = locationManager.recordPhotoLocation() { let filename = "photo_\(UUID().uuidString).jpg" let photo = PathPhoto( - coordinate: location.coordinate, timestamp: Date(), image: image, - imageFilename: filename + imageFilename: filename, + locationId: locationId ) locationManager.addPhoto(photo) } diff --git a/PathRecorder/MapComponents/MapRenderingHelpers.swift b/PathRecorder/MapComponents/MapRenderingHelpers.swift index b23a141..1ab322f 100644 --- a/PathRecorder/MapComponents/MapRenderingHelpers.swift +++ b/PathRecorder/MapComponents/MapRenderingHelpers.swift @@ -1,60 +1,84 @@ import UIKit import MapKit +import SwiftUI struct MapRenderingHelpers { -static func photoAnnotationImage(preview: UIImage?) -> UIImage? { - let width: CGFloat = 40 - let height: CGFloat = 48 - let bubbleRect = CGRect(x: 0, y: 0, width: width, height: height - 10) - let tipHeight: CGFloat = 10 - UIGraphicsBeginImageContextWithOptions(CGSize(width: width, height: height), false, 0.0) - guard let ctx = UIGraphicsGetCurrentContext() else { return nil } - // Draw bubble - let bubblePath = UIBezierPath(roundedRect: bubbleRect, cornerRadius: 12) - ctx.setFillColor(UIColor.blue.cgColor) - ctx.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.15).cgColor) - bubblePath.fill() - ctx.setShadow(offset: .zero, blur: 0, color: nil) - ctx.setStrokeColor(UIColor.blue.cgColor) - ctx.setLineWidth(2) - bubblePath.stroke() - // Draw tip (triangle) - let tipPath = UIBezierPath() - tipPath.move(to: CGPoint(x: width/2 - 6, y: height - tipHeight)) - tipPath.addLine(to: CGPoint(x: width/2, y: height)) - tipPath.addLine(to: CGPoint(x: width/2 + 6, y: height - tipHeight)) - tipPath.close() - ctx.setFillColor(UIColor.blue.cgColor) - ctx.setStrokeColor(UIColor.blue.cgColor) - tipPath.fill() - tipPath.stroke() - // Draw photo preview inside bubble - if let preview = preview { - let previewRect = CGRect(x: (width-28)/2, y: 6, width: 28, height: 28) - let path = UIBezierPath(roundedRect: previewRect, cornerRadius: 6) - ctx.saveGState() - path.addClip() - preview.draw(in: previewRect) - ctx.restoreGState() - // Add border to preview - ctx.setStrokeColor(UIColor.lightGray.cgColor) - ctx.setLineWidth(1) - path.stroke() - } else { - // fallback to photo icon if no preview - if let baseImage = UIImage(systemName: "photo")?.withTintColor(.red, renderingMode: .alwaysOriginal) { - baseImage.draw(in: CGRect(x: (width-20)/2, y: 8, width: 20, height: 20)) + static func defaultStrokeColor() -> UIColor { + return .systemBlue + } + + static func photoAnnotationImage(preview: UIImage?) -> UIImage? { + let width: CGFloat = 40 + let height: CGFloat = 48 + let bubbleRect = CGRect(x: 0, y: 0, width: width, height: height - 10) + let tipHeight: CGFloat = 10 + UIGraphicsBeginImageContextWithOptions(CGSize(width: width, height: height), false, 0.0) + guard let ctx = UIGraphicsGetCurrentContext() else { return nil } + // Draw bubble + let bubblePath = UIBezierPath(roundedRect: bubbleRect, cornerRadius: 12) + let annotationColor = defaultStrokeColor() + ctx.setFillColor(annotationColor.cgColor) + ctx.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.15).cgColor) + bubblePath.fill() + ctx.setShadow(offset: .zero, blur: 0, color: nil) + ctx.setStrokeColor(annotationColor.cgColor) + ctx.setLineWidth(2) + bubblePath.stroke() + // Draw tip (triangle) + let tipPath = UIBezierPath() + tipPath.move(to: CGPoint(x: width/2 - 6, y: height - tipHeight)) + tipPath.addLine(to: CGPoint(x: width/2, y: height)) + tipPath.addLine(to: CGPoint(x: width/2 + 6, y: height - tipHeight)) + tipPath.close() + ctx.setFillColor(annotationColor.cgColor) + ctx.setStrokeColor(annotationColor.cgColor) + tipPath.fill() + tipPath.stroke() + // Draw photo preview inside bubble + if let preview = preview { + let previewRect = CGRect(x: (width-28)/2, y: 6, width: 28, height: 28) + let path = UIBezierPath(roundedRect: previewRect, cornerRadius: 6) + ctx.saveGState() + path.addClip() + preview.draw(in: previewRect) + ctx.restoreGState() + // Add border to preview + ctx.setStrokeColor(UIColor.lightGray.cgColor) + ctx.setLineWidth(1) + path.stroke() + } else { + // fallback to photo icon if no preview + if let baseImage = UIImage(systemName: "photo")?.withTintColor(.red, renderingMode: .alwaysOriginal) { + baseImage.draw(in: CGRect(x: (width-20)/2, y: 8, width: 20, height: 20)) + } } + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image } - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return image -} static let polylineWidth: CGFloat = 5.0 + static func segmentColor(for title: String?) -> UIColor { + let palette: [UIColor] = [ + .systemBlue, + .systemGreen, + .systemOrange, + .systemPurple, + .systemPink, + .systemTeal, + .systemYellow + ] + guard let title = title, + title.starts(with: "segment_"), + let segmentIndex = Int(title.dropFirst("segment_".count)) else { + return defaultStrokeColor() + } + return palette[segmentIndex % palette.count] + } + static func polylineRenderer(for overlay: MKOverlay) -> MKOverlayRenderer { if let polyline = overlay as? MKPolyline { let renderer = MKPolylineRenderer(polyline: polyline) - renderer.strokeColor = UIColor.blue + renderer.strokeColor = segmentColor(for: polyline.title) renderer.lineWidth = polylineWidth renderer.lineCap = .round renderer.lineJoin = .round @@ -62,37 +86,37 @@ static func photoAnnotationImage(preview: UIImage?) -> UIImage? { } return MKOverlayRenderer(overlay: overlay) } - static var cachedGlowingBlueDotImage: UIImage? = { + static func cachedGlowingBlueDotImage() -> UIImage? { let size: CGFloat = 32 let dotRadius: CGFloat = 8 UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 0) guard let ctx = UIGraphicsGetCurrentContext() else { return nil } // Draw glow - let glowColor = UIColor.blue.withAlphaComponent(0.3).cgColor + let glowColor = defaultStrokeColor().withAlphaComponent(0.3).cgColor ctx.setFillColor(glowColor) ctx.addEllipse(in: CGRect(x: (size-dotRadius*3)/2, y: (size-dotRadius*3)/2, width: dotRadius*3, height: dotRadius*3)) ctx.fillPath() - // Draw solid blue dot - let dotColor = UIColor.blue.cgColor + // Draw solid dot + let dotColor = defaultStrokeColor().cgColor ctx.setFillColor(dotColor) ctx.addEllipse(in: CGRect(x: (size-dotRadius)/2, y: (size-dotRadius)/2, width: dotRadius, height: dotRadius)) ctx.fillPath() let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image - }() - static var cachedBlueDotImage: UIImage? = { + } + static func cachedBlueDotImage() -> UIImage? { let size: CGFloat = 32 let dotRadius: CGFloat = polylineWidth UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 0) guard let ctx = UIGraphicsGetCurrentContext() else { return nil } - // Draw solid blue dot - let dotColor = UIColor.blue.cgColor + // Draw solid dot + let dotColor = defaultStrokeColor().cgColor ctx.setFillColor(dotColor) ctx.addEllipse(in: CGRect(x: (size-dotRadius)/2, y: (size-dotRadius)/2, width: dotRadius, height: dotRadius)) ctx.fillPath() let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image - }() -} \ No newline at end of file + } +} diff --git a/PathRecorder/MapComponents/PathSegment.swift b/PathRecorder/MapComponents/PathSegment.swift deleted file mode 100644 index 2f1532c..0000000 --- a/PathRecorder/MapComponents/PathSegment.swift +++ /dev/null @@ -1,10 +0,0 @@ -import MapKit - -/// Represents a segment of a path, used for drawing polylines. -struct PathSegment: Identifiable { - let id: UUID - let coordinates: [CLLocationCoordinate2D] - var polyline: MKPolyline { - MKPolyline(coordinates: coordinates, count: coordinates.count) - } -} \ No newline at end of file diff --git a/PathRecorder/MapComponents/PhotoPagerView.swift b/PathRecorder/MapComponents/PhotoPagerView.swift deleted file mode 100644 index f8f7735..0000000 --- a/PathRecorder/MapComponents/PhotoPagerView.swift +++ /dev/null @@ -1,174 +0,0 @@ -import SwiftUI -import Photos - -struct PhotoPagerView: View { - let photos: [PathPhoto] // Replace with your actual model type - @Binding var selectedIndex: Int - @State private var showShareSheet = false - @State private var imageToShare: ShareImage? - @State private var showDeleteAlert = false - @State private var showPhotoLibraryAlert = false - let onDeletePhoto: (PathPhoto) -> Void - - var body: some View { - Group { - if photos.isEmpty { - Text("No photos at this location.") - .padding() - } else { - ZStack(alignment: .topLeading) { - VStack(spacing: 0) { - TabView(selection: $selectedIndex) { - ForEach(Array(photos.enumerated()), id: \.element.id) { idx, photo in - VStack { - if let image = photo.image { - Text(DateFormatter.localizedString(from: photo.timestamp, dateStyle: .medium, timeStyle: .short)) - .font(.subheadline) - // Display GPS coordinate in readable format - Text(String(format: "Lat: %.5f, Lon: %.5f", photo.coordinate.latitude, photo.coordinate.longitude)) - .font(.caption) - Image(uiImage: image) - .resizable() - .scaledToFit() - .frame(maxWidth: 400, maxHeight: 400) - .cornerRadius(16) - .padding() - .contextMenu { - Button(action: { - let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(photo.imageFilename) - - // Ensure the temp file exists and has content, create it if not - if !FileManager.default.fileExists(atPath: fileURL.path) { - if let data = image.jpegData(compressionQuality: 0.9) { - try? data.write(to: fileURL) - } - } - - imageToShare = ShareImage(image: image, fileURL: fileURL) - showShareSheet = true - }) { - Label("Share", systemImage: "square.and.arrow.up") - } - - Button(action: { - saveImageToPhotos(image) - }) { - Label("Save to Photos", systemImage: "square.and.arrow.down") - } - - Button(action: { - UIPasteboard.general.image = image - }) { - Label("Copy", systemImage: "doc.on.doc") - } - } - } else { - Text("Photo unavailable") - } - } - .frame(maxHeight: .infinity) - .tag(idx) - } - } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) - .frame(maxHeight: .infinity) - } - .frame(maxHeight: .infinity) - .padding() - - // Delete button in top left corner - Button(action: { - showDeleteAlert = true - }) { - Image(systemName: "trash") - .font(.title2) - .foregroundColor(.red) - .padding(12) - .background(Color.white.opacity(0.8)) - .clipShape(Circle()) - .shadow(radius: 4) - } - .padding() - } - } - } - .alert("Delete Photo", isPresented: $showDeleteAlert) { - Button("Delete", role: .destructive) { - if selectedIndex < photos.count { - let photoToDelete = photos[selectedIndex] - onDeletePhoto(photoToDelete) - } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Are you sure you want to delete this photo? This action cannot be undone.") - } - .sheet(item: $imageToShare) { shareImage in - ShareSheet(activityItems: [shareImage.fileURL]) - } - .onChange(of: imageToShare) { oldValue, newValue in - // When share sheet is dismissed, clean up temp file - if oldValue != nil && newValue == nil { - if let fileURL = oldValue?.fileURL { - try? FileManager.default.removeItem(at: fileURL) - } - } - } - .alert("Photo Library Access Needed", isPresented: $showPhotoLibraryAlert) { - Button("Open Settings") { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("To save photos, please allow full access to your photo library in Settings.") - } - } - - private func saveImageToPhotos(_ image: UIImage) { - let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) - switch status { - case .authorized: - UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) - case .notDetermined: - PHPhotoLibrary.requestAuthorization(for: .readWrite) { newStatus in - DispatchQueue.main.async { - if newStatus == .authorized { - UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) - } else { - showPhotoLibraryAlert = true - } - } - } - case .denied, .restricted, .limited: - showPhotoLibraryAlert = true - @unknown default: - showPhotoLibraryAlert = true - } - } - - // UIKit share sheet wrapper - struct ShareSheet: UIViewControllerRepresentable { - var activityItems: [Any] - var applicationActivities: [UIActivity]? = nil - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} - } -} - -// Wrapper for sharing images in .sheet(item:) -struct ShareImage: Identifiable, Equatable { - let id = UUID() - let image: UIImage - let fileURL: URL - - static func == (lhs: ShareImage, rhs: ShareImage) -> Bool { - lhs.id == rhs.id && lhs.fileURL == rhs.fileURL - } -} - diff --git a/PathRecorder/MapComponents/MapWithPolylines.swift b/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift similarity index 55% rename from PathRecorder/MapComponents/MapWithPolylines.swift rename to PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift index dc7e1e1..3a94916 100644 --- a/PathRecorder/MapComponents/MapWithPolylines.swift +++ b/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift @@ -11,12 +11,17 @@ class PhotoAnnotation: NSObject, MKAnnotation { } } +private struct ClusterKey: Hashable { + let x: Int + let y: Int +} + struct MapWithPolylines: UIViewRepresentable { var region: MKCoordinateRegion let locations: [GPSLocation] let pathSegments: [PathSegment] let photos: [PathPhoto] - let onPhotoTapped: (PathPhoto) -> Void + let onPhotoTapped: ([PathPhoto], PathPhoto) -> Void func makeUIView(context: Context) -> MKMapView { let mapView = MKMapView() @@ -28,49 +33,76 @@ struct MapWithPolylines: UIViewRepresentable { func updateUIView(_ mapView: MKMapView, context: Context) { mapView.removeOverlays(mapView.overlays) mapView.removeAnnotations(mapView.annotations) - for segment in pathSegments { + for (index, segment) in pathSegments.enumerated() { if segment.coordinates.count >= 2 { - mapView.addOverlay(segment.polyline) + let polyline = segment.mkPolyline + polyline.title = "segment_\(index)" + mapView.addOverlay(polyline) } - // Only add GPS point annotation if no photo annotation is nearby (within 10 meters) - let startCoord = segment.coordinates.first! - let endCoord = segment.coordinates.last! - let startLocation = CLLocation(latitude: startCoord.latitude, longitude: startCoord.longitude) - let endLocation = CLLocation(latitude: endCoord.latitude, longitude: endCoord.longitude) - let photoLocations = photos.map { CLLocation(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) } - let startHasNearbyPhoto = photoLocations.contains { $0.distance(from: startLocation) <= 10.0 } - let endHasNearbyPhoto = photoLocations.contains { $0.distance(from: endLocation) <= 10.0 } - if !startHasNearbyPhoto { + // Add GPS point annotations for segment start/end + if let startCoord = segment.coordinates.first, + let endCoord = segment.coordinates.last { let startAnnotation = MKPointAnnotation() startAnnotation.coordinate = startCoord mapView.addAnnotation(startAnnotation) - } - if !endHasNearbyPhoto { + let endAnnotation = MKPointAnnotation() endAnnotation.coordinate = endCoord mapView.addAnnotation(endAnnotation) } } - // Group photos within 10 meters - var clusters: [[PathPhoto]] = [] + + updatePhotoAnnotations(on: mapView) + } + + private func updatePhotoAnnotations(on mapView: MKMapView) { + let threshold: CGFloat = 52.0 + var photoGroups: [[PathPhoto]] = [] + var groupCoordinates: [[CLLocationCoordinate2D]] = [] + var groupScreenPoints: [CGPoint] = [] + for photo in photos { - let location = CLLocation(latitude: photo.coordinate.latitude, longitude: photo.coordinate.longitude) - if let idx = clusters.firstIndex(where: { cluster in - guard let first = cluster.first else { return false } - let firstLoc = CLLocation(latitude: first.coordinate.latitude, longitude: first.coordinate.longitude) - return location.distance(from: firstLoc) <= 10.0 + guard let coordinate = coordinate(for: photo, in: locations) else { continue } + let screenPoint = mapView.convert(coordinate, toPointTo: mapView) + if let matchingIndex = groupScreenPoints.firstIndex(where: { existingPoint in + abs(existingPoint.x - screenPoint.x) < threshold && + abs(existingPoint.y - screenPoint.y) < threshold }) { - clusters[idx].append(photo) + photoGroups[matchingIndex].append(photo) + groupCoordinates[matchingIndex].append(coordinate) } else { - clusters.append([photo]) + photoGroups.append([photo]) + groupCoordinates.append([coordinate]) + groupScreenPoints.append(screenPoint) } } - // Add one annotation per cluster - for cluster in clusters { - guard let first = cluster.first else { continue } - let coord = first.coordinate - mapView.addAnnotation(PhotoAnnotation(photos: cluster, coordinate: coord)) + + let existingPhotoAnnotations = mapView.annotations.compactMap { $0 as? PhotoAnnotation } + mapView.removeAnnotations(existingPhotoAnnotations) + + for (index, groupedPhotos) in photoGroups.enumerated() { + let coordinates = groupCoordinates[index] + guard !coordinates.isEmpty else { continue } + let sortedPhotos = groupedPhotos.sorted { $0.timestamp < $1.timestamp } + let centerCoordinate = averageCoordinate(from: coordinates) + let annotation = PhotoAnnotation(photos: sortedPhotos, coordinate: centerCoordinate) + mapView.addAnnotation(annotation) + } + } + + private func averageCoordinate(from coordinates: [CLLocationCoordinate2D]) -> CLLocationCoordinate2D { + let total = coordinates.reduce((lat: 0.0, lon: 0.0)) { acc, coord in + (acc.lat + coord.latitude, acc.lon + coord.longitude) } + return CLLocationCoordinate2D( + latitude: total.lat / Double(coordinates.count), + longitude: total.lon / Double(coordinates.count) + ) + } + + private func coordinate(for photo: PathPhoto, in locations: [GPSLocation]) -> CLLocationCoordinate2D? { + guard let location = locations.first(where: { $0.id == photo.locationId }) else { return nil } + return CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude) } func makeCoordinator() -> Coordinator { @@ -79,8 +111,8 @@ struct MapWithPolylines: UIViewRepresentable { class Coordinator: NSObject, MKMapViewDelegate { var parent: MapWithPolylines - let onPhotoTapped: (PathPhoto) -> Void - init(_ parent: MapWithPolylines, onPhotoTapped: @escaping (PathPhoto) -> Void) { + let onPhotoTapped: ([PathPhoto], PathPhoto) -> Void + init(_ parent: MapWithPolylines, onPhotoTapped: @escaping ([PathPhoto], PathPhoto) -> Void) { self.parent = parent self.onPhotoTapped = onPhotoTapped } @@ -113,7 +145,7 @@ struct MapWithPolylines: UIViewRepresentable { } else { annotationView?.annotation = annotation } - annotationView?.image = MapRenderingHelpers.cachedBlueDotImage + annotationView?.image = MapRenderingHelpers.cachedBlueDotImage() annotationView?.centerOffset = CGPoint(x: 0, y: 0) annotationView?.isUserInteractionEnabled = false // Don't block touches annotationView?.layer.zPosition = 0 @@ -123,11 +155,14 @@ struct MapWithPolylines: UIViewRepresentable { func mapView(_ mapView: MKMapView, didSelect annotationView: MKAnnotationView) { if let photoAnnotation = annotationView.annotation as? PhotoAnnotation { print("Photo annotation tapped at coordinate: \(photoAnnotation.coordinate.latitude), \(photoAnnotation.coordinate.longitude)") - // Pass all photos in the cluster to the sheet - if let firstPhoto = photoAnnotation.photos.first { - onPhotoTapped(firstPhoto) - } + let sortedPhotos = photoAnnotation.photos.sorted { $0.timestamp < $1.timestamp } + guard let firstPhoto = sortedPhotos.first else { return } + onPhotoTapped(sortedPhotos, firstPhoto) } } + + func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { + parent.updatePhotoAnnotations(on: mapView) + } } } diff --git a/PathRecorder/MapComponents/PathEditingSheet.swift b/PathRecorder/MapComponents/StaticMap/PathEditingSheet.swift similarity index 100% rename from PathRecorder/MapComponents/PathEditingSheet.swift rename to PathRecorder/MapComponents/StaticMap/PathEditingSheet.swift diff --git a/PathRecorder/MapComponents/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift similarity index 52% rename from PathRecorder/MapComponents/PathMapView.swift rename to PathRecorder/MapComponents/StaticMap/PathMapView.swift index c4a5166..5ec1ceb 100644 --- a/PathRecorder/MapComponents/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -1,12 +1,15 @@ import SwiftUI import MapKit +import Shared /// Displays a map with polylines and GPS point annotations for a recorded path. struct PathMapView: View { @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var authManager: AuthManager @State private var sheetDetent: PresentationDetent = .fraction(0.25) @ObservedObject var locationManager: LocationManager @ObservedObject var pathStorage: PathStorage + @ObservedObject var settings: Settings @State private var region: MKCoordinateRegion @State private var pathSegments: [PathSegment] = [] @State private var showEditingSheet = false @@ -14,22 +17,17 @@ struct PathMapView: View { @State private var recordedPath: RecordedPath var showRenameSheetOnAppear: Bool var onModifyPath: (() -> Void)? + @State private var bottomSheetDetent: PresentationDetent = .height(100) - init(recordedPath: RecordedPath, locationManager: LocationManager, pathStorage: PathStorage, showRenameSheetOnAppear: Bool = false, onModifyPath: (() -> Void)? = nil) { + init(recordedPath: RecordedPath, locationManager: LocationManager, pathStorage: PathStorage, settings: Settings, showRenameSheetOnAppear: Bool = false, onModifyPath: (() -> Void)? = nil) { self.locationManager = locationManager self.pathStorage = pathStorage + self.settings = settings _recordedPath = State(initialValue: recordedPath) - // Group locations by segment first - let segments = Dictionary(grouping: recordedPath.locations, by: { $0.segmentId }) - var tempSegments: [PathSegment] = [] - for (segmentId, locations) in segments { - let sortedLocations = locations.sorted { $0.timestamp < $1.timestamp } - let coordinates = sortedLocations.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } - tempSegments.append(PathSegment(id: segmentId, coordinates: coordinates)) - } - _pathSegments = State(initialValue: tempSegments) + // Use segments directly from the new data model + _pathSegments = State(initialValue: recordedPath.segments) // Calculate the proper region to fit all coordinates - let allCoordinates = tempSegments.flatMap { $0.coordinates } + let allCoordinates = recordedPath.segments.flatMap { $0.coordinates } let minLat = allCoordinates.map { $0.latitude }.min() ?? 0 let maxLat = allCoordinates.map { $0.latitude }.max() ?? 0 let minLon = allCoordinates.map { $0.longitude }.min() ?? 0 @@ -54,42 +52,127 @@ struct PathMapView: View { // Holds all photos at a tapped coordinate @State private var selectedPhotos: [PathPhoto]? = nil @State private var selectedPhotoIndex: Int = 0 + @State private var showPhotoGrid: Bool = false @State private var pickedPathPhotos: [PathPhoto] = [] @State private var showAssociationAlert = false @State private var associatedCount = 0 @State private var pendingPhotos: [PathPhoto] = [] - var body: some View { - let currentPath = pathStorage.path(for: recordedPath.id) ?? recordedPath + // MARK: - View Components + private func mapView(for currentPath: RecordedPath) -> some View { MapWithPolylines( region: region, locations: currentPath.locations, - pathSegments: pathSegments, + pathSegments: currentPath.segments, photos: currentPath.photos, - onPhotoTapped: { tappedPhoto in - // Always get the most current path data when a photo is tapped - let latestPath = pathStorage.path(for: recordedPath.id) ?? recordedPath - - // Find all photos within 10 meters of the tapped coordinate - let tappedLocation = CLLocation(latitude: tappedPhoto.coordinate.latitude, longitude: tappedPhoto.coordinate.longitude) - let nearbyPhotos = latestPath.photos.filter { - let photoLocation = CLLocation(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude) - return tappedLocation.distance(from: photoLocation) <= 10.0 // meters - } - selectedPhotos = nearbyPhotos - // Show the tapped photo first if multiple (only if it still exists) - if let idx = nearbyPhotos.firstIndex(where: { $0.id == tappedPhoto.id }) { - selectedPhotoIndex = idx - } else { - selectedPhotoIndex = 0 - } + onPhotoTapped: { tappedPhotos, selectedPhoto in + handlePhotoTap(tappedPhotos, selectedPhoto: selectedPhoto) } ) - .id(currentPath.photos.count) // Force refresh when photo count changes - .navigationTitle(currentPath.name) + .id(currentPath.photos.count) + } + + private func bottomInfoSheet(for currentPath: RecordedPath) -> some View { + VStack(spacing: 0) { + Spacer() + pathInfoContent(for: currentPath) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(.ultraThinMaterial) + .shadow(radius: 8) + ) + .padding(.horizontal, 12) + .padding(.bottom, 20) + } + } + + private func pathInfoContent(for currentPath: RecordedPath) -> some View { + VStack(alignment: .center, spacing: 8) { + // Title line + Text(currentPath.name) + .font(.headline) + .padding(.horizontal, 16) + .padding(.top, 16) + + // Metrics line + pathMetricsRow(for: currentPath) + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .frame(maxWidth: nil, alignment: .center) + } + + private func pathMetricsRow(for currentPath: RecordedPath) -> some View { + HStack(spacing: 12) { + // Distance + metricItem( + icon: "figure.walk", + color: .green, + text: settings.formatDistance(currentPath.totalDistance) + ) + + + // Total time + metricItem( + icon: "clock", + color: .orange, + text: formatTime(currentPath.totalDuration) + ) + + + // Pace + metricItem( + icon: "timer", + color: .purple, + text: computePace( + distanceMeters: currentPath.totalDistance, + elapsedSeconds: currentPath.totalDuration, + unit: settings.distanceUnit.rawValue + ) + ) + } + } + + private func metricItem(icon: String, color: Color, text: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundColor(color) + .font(.caption) + Text(text) + .font(.subheadline) + } + } + + // MARK: - Helper Methods + private func handlePhotoTap(_ tappedPhotos: [PathPhoto], selectedPhoto: PathPhoto) { + let sortedPhotos = tappedPhotos.sorted { $0.timestamp < $1.timestamp } + selectedPhotos = sortedPhotos + if let idx = sortedPhotos.firstIndex(where: { $0.id == selectedPhoto.id }) { + selectedPhotoIndex = idx + } else { + selectedPhotoIndex = 0 + } + } + + var body: some View { + let currentPath = pathStorage.path(for: recordedPath.id) ?? recordedPath + ZStack(alignment: .bottom) { + mapView(for: currentPath) + bottomInfoSheet(for: currentPath) + } .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if !currentPath.photos.isEmpty { + NavigationLink(destination: PhotoGridView(photos: currentPath.photos, pathStorage: pathStorage, pathId: recordedPath.id), isActive: $showPhotoGrid) { + EmptyView() + } + Button(action: { + showPhotoGrid = true + }) { + Image(systemName: "photo.on.rectangle") + } + } Button(action: { showEditingSheet = true }) { @@ -97,6 +180,31 @@ struct PathMapView: View { } } } + // Hidden NavigationLink for photo pager + .background( + NavigationLink( + destination: Group { + if let photos = selectedPhotos { + PhotoPagerView( + photos: photos, + selectedIndex: $selectedPhotoIndex, + pathStorage: pathStorage, + pathId: recordedPath.id + ) + } else { + Text("No photos at this location.") + .padding() + } + }, + isActive: Binding( + get: { selectedPhotos != nil }, + set: { if !$0 { selectedPhotos = nil } } + ) + ) { + EmptyView() + } + ) + // Removed sheet for all photos; now uses navigation to PhotoGridView .onAppear { if showRenameSheetOnAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { @@ -134,10 +242,15 @@ struct PathMapView: View { locationManager.loadPathForEditing(recordedPath, pathStorage: pathStorage) showEditingSheet = false dismiss() - onModifyPath?() + // Defer the push until the pop from dismiss() has settled — pairing + // a NavigationPath pop with an isPresented push in the same transaction + // races and can leave the pushed RecordingView rendering blank. + DispatchQueue.main.async { + onModifyPath?() + } }, onDeletePath: { - pathStorage.deletePath(id: recordedPath.id) + authManager.deletePath(recordedPath, pathStorage: pathStorage) showEditingSheet = false dismiss() } @@ -151,46 +264,6 @@ struct PathMapView: View { } } } - .sheet(isPresented: Binding( - get: { selectedPhotos != nil }, - set: { if !$0 { selectedPhotos = nil } } - )) { - if let photos = selectedPhotos { - PhotoPagerView( - photos: photos, - selectedIndex: $selectedPhotoIndex, - onDeletePhoto: { photoToDelete in - // Get the current path from storage - if var currentPath = pathStorage.path(for: recordedPath.id) { - // Remove photo from the path - currentPath.deletePhoto(photoToDelete) - - // Update the stored path - pathStorage.updatePath(currentPath) - - // Update the local recordedPath state as well - recordedPath = currentPath - - // Update the selected photos list with the latest data - selectedPhotos?.removeAll { $0.id == photoToDelete.id } - - // If no photos left, close the sheet - if selectedPhotos?.isEmpty == true { - selectedPhotos = nil - } else if let remainingPhotos = selectedPhotos { - // Adjust selected index if needed - if selectedPhotoIndex >= remainingPhotos.count { - selectedPhotoIndex = max(0, remainingPhotos.count - 1) - } - } - } - } - ) - } else { - Text("No photos at this location.") - .padding() - } - } .sheet(isPresented: Binding(get: { !showEditingSheet && showAssociationAlert && associatedCount > 0 }, set: { show in showAssociationAlert = show })) { PhotoAssociationConfirmationSheet( associatedCount: associatedCount, @@ -218,4 +291,12 @@ struct PathMapView: View { } } } + + // Helper function for formatting time + private func formatTime(_ timeInterval: TimeInterval) -> String { + let hours = Int(timeInterval) / 3600 + let minutes = Int(timeInterval) / 60 % 60 + let seconds = Int(timeInterval) % 60 + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } } diff --git a/PathRecorder/MapComponents/PhotoAssociationConfirmationSheet.swift b/PathRecorder/MapComponents/StaticMap/PhotoAssociationConfirmationSheet.swift similarity index 100% rename from PathRecorder/MapComponents/PhotoAssociationConfirmationSheet.swift rename to PathRecorder/MapComponents/StaticMap/PhotoAssociationConfirmationSheet.swift diff --git a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift new file mode 100644 index 0000000..dba79d6 --- /dev/null +++ b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift @@ -0,0 +1,230 @@ +import SwiftUI +import Photos +import CoreLocation + +struct PhotoGridView: View { + @State private var photos: [PathPhoto] + @ObservedObject var pathStorage: PathStorage + let pathId: UUID + + init(photos: [PathPhoto], pathStorage: PathStorage, pathId: UUID) { + self._photos = State(initialValue: photos) + self.pathStorage = pathStorage + self.pathId = pathId + } + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + + @Environment(\.dismiss) private var dismiss + @State private var selectedPhotoIndex: Int = 0 + @State private var showSaveAllAlert = false + @State private var showPhotoLibraryAlert = false + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in + if let image = photo.uiImage { + NavigationLink( + destination: PhotoPagerView( + photos: photos, + selectedIndex: $selectedPhotoIndex, + pathStorage: pathStorage, + pathId: pathId + ) + .onDisappear { + // Update photos when returning from pager + if let updatedPath = pathStorage.path(for: pathId) { + photos = updatedPath.photos + } + // Dismiss if no photos left + if photos.isEmpty { + dismiss() + } + } + ) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: gridItemSize, height: gridItemSize) + .clipped() + .cornerRadius(8) + } + .simultaneousGesture( + TapGesture().onEnded { + selectedPhotoIndex = index + } + ) + } + } + } + .padding(8) + } + .navigationTitle("All Photos") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showSaveAllAlert = true + }) { + Image(systemName: "square.and.arrow.down.on.square") + .foregroundColor(.blue) + } + } + } + .alert("Save All Photos", isPresented: $showSaveAllAlert) { + Button("Save All", role: .destructive) { + saveAllPhotosToAlbum() + } + Button("Cancel", role: .cancel) { } + } message: { + if let path = pathStorage.path(for: pathId) { + Text("Create an album '\(path.name)' and save all \(photos.count) photos to your photo library?") + } else { + Text("Save all \(photos.count) photos to your photo library?") + } + } + .alert("Photo Library Access Needed", isPresented: $showPhotoLibraryAlert) { + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("To save photos, please allow full access to your photo library in Settings.") + } + } + + private var gridItemSize: CGFloat { + let screenWidth = UIScreen.main.bounds.width + return (screenWidth - 32) / 3 // 3 columns, 8pt spacing, 8pt padding + } + + private func saveAllPhotosToAlbum() { + guard let path = pathStorage.path(for: pathId) else { return } + let albumName = path.name + + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + switch status { + case .authorized: + createAlbumAndSavePhotos(albumName: albumName) + case .notDetermined: + PHPhotoLibrary.requestAuthorization(for: .readWrite) { newStatus in + DispatchQueue.main.async { + if newStatus == .authorized { + createAlbumAndSavePhotos(albumName: albumName) + } else { + showPhotoLibraryAlert = true + } + } + } + case .denied, .restricted, .limited: + showPhotoLibraryAlert = true + @unknown default: + showPhotoLibraryAlert = true + } + } + + private func createAlbumAndSavePhotos(albumName: String) { + let uniqueAlbumName = getUniqueAlbumName(baseName: albumName) + var albumPlaceholder: PHObjectPlaceholder? + var assetPlaceholders: [PHObjectPlaceholder] = [] + + // First, create album and save photos + PHPhotoLibrary.shared().performChanges({ + // Create album with unique name + let albumRequest = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: uniqueAlbumName) + albumPlaceholder = albumRequest.placeholderForCreatedAssetCollection + + // Save all photos + for photo in photos { + if let image = photo.uiImage { + let assetRequest = PHAssetChangeRequest.creationRequestForAsset(from: image) + + // Set original creation date + assetRequest.creationDate = photo.timestamp + + if let assetPlaceholder = assetRequest.placeholderForCreatedAsset { + assetPlaceholders.append(assetPlaceholder) + } + } + } + }) { success, error in + if success, let albumPlaceholder = albumPlaceholder, !assetPlaceholders.isEmpty { + // Second, add photos to the created album + PHPhotoLibrary.shared().performChanges({ + let fetchResult = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumPlaceholder.localIdentifier], options: nil) + if let album = fetchResult.firstObject { + let albumChangeRequest = PHAssetCollectionChangeRequest(for: album) + let assets = PHAsset.fetchAssets(withLocalIdentifiers: assetPlaceholders.map { $0.localIdentifier }, options: nil) + albumChangeRequest?.addAssets(assets) + } + }) { success, error in + DispatchQueue.main.async { + if success { + print("Successfully saved \(photos.count) photos to album '\(uniqueAlbumName)'") + } else { + print("Error adding photos to album: \(error?.localizedDescription ?? "Unknown error")") + } + } + } + } else { + DispatchQueue.main.async { + print("Error creating album or saving photos: \(error?.localizedDescription ?? "Unknown error")") + } + } + } + } + + private func getUniqueAlbumName(baseName: String) -> String { + // Fetch all user albums + let fetchOptions = PHFetchOptions() + fetchOptions.predicate = NSPredicate(format: "estimatedAssetCount > 0 OR estimatedAssetCount = 0") + let albums = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .any, options: fetchOptions) + + var existingNames = Set() + albums.enumerateObjects { album, _, _ in + if let title = album.localizedTitle { + existingNames.insert(title) + } + } + + // Check if base name is available + if !existingNames.contains(baseName) { + return baseName + } + + // Find the next available number + var counter = 1 + var candidateName = "\(baseName) \(counter)" + + while existingNames.contains(candidateName) { + counter += 1 + candidateName = "\(baseName) \(counter)" + } + + return candidateName + } +} + +// Helper to get UIImage from PathPhoto +extension PathPhoto { + var uiImage: UIImage? { + // Try to load from file if possible, otherwise use in-memory image if available + if let image = self.image { return image } + // Try to construct file URL from imageFilename + let fileManager = FileManager.default + // Look in Documents directory + if let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let url = docs.appendingPathComponent(imageFilename) + if fileManager.fileExists(atPath: url.path) { + return UIImage(contentsOfFile: url.path) + } + } + return nil + } +} diff --git a/PathRecorder/MapComponents/PhotoLibraryPicker.swift b/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift similarity index 82% rename from PathRecorder/MapComponents/PhotoLibraryPicker.swift rename to PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift index 790cf1d..406cfb5 100644 --- a/PathRecorder/MapComponents/PhotoLibraryPicker.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift @@ -49,24 +49,25 @@ struct PhotoLibraryPicker: View { for (image, asset) in zip(images, assets) { let creationDate: Date? = asset?.creationDate if let creationDate = creationDate { + // Find which segment contains this photo's creation date for segment in pathSegments { - let segmentLocations = recordedPath.locations.filter { $0.segmentId == segment.id } + let segmentLocations = segment.locations guard let first = segmentLocations.first, let last = segmentLocations.last else { continue } if creationDate >= first.timestamp && creationDate <= last.timestamp { - let closest = segmentLocations.min(by: { abs($0.timestamp.timeIntervalSince(creationDate)) < abs($1.timestamp.timeIntervalSince(creationDate)) }) - if let closestLocation = closest { - var filename = "photo_\(UUID().uuidString).jpg" - if let asset = asset, let resource = PHAssetResource.assetResources(for: asset).first { - filename = resource.originalFilename - } - let pathPhoto = PathPhoto( - coordinate: CLLocationCoordinate2D(latitude: closestLocation.latitude, longitude: closestLocation.longitude), - timestamp: creationDate, - image: image, - imageFilename: filename - ) - pending.append(pathPhoto) + var filename = "photo_\(UUID().uuidString).jpg" + if let asset = asset, let resource = PHAssetResource.assetResources(for: asset).first { + filename = resource.originalFilename } + guard let closestLocation = segmentLocations.min(by: { + abs($0.timestamp.timeIntervalSince(creationDate)) < abs($1.timestamp.timeIntervalSince(creationDate)) + }) else { continue } + let pathPhoto = PathPhoto( + timestamp: creationDate, + image: image, + imageFilename: filename, + locationId: closestLocation.id + ) + pending.append(pathPhoto) break } } diff --git a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift new file mode 100644 index 0000000..5f66fa4 --- /dev/null +++ b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift @@ -0,0 +1,172 @@ +import SwiftUI +import Photos +import CoreLocation +import ImageIO +import UniformTypeIdentifiers + +struct PhotoPagerView: View { + @Environment(\.dismiss) private var dismiss + @State var photos: [PathPhoto] + @Binding var selectedIndex: Int + @State private var showShareSheet = false + @State private var imageToShare: ShareImage? + @State private var showDeleteAlert = false + @ObservedObject var pathStorage: PathStorage + let pathId: UUID + + var body: some View { + Group { + if photos.isEmpty { + Text("No photos at this location.") + .padding() + } else { + VStack(spacing: 0) { + TabView(selection: $selectedIndex) { + ForEach(Array(photos.enumerated()), id: \.element.id) { idx, photo in + VStack { + if let image = photo.image { + Text(DateFormatter.localizedString(from: photo.timestamp, dateStyle: .medium, timeStyle: .short)) + .font(.subheadline) + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: 400, maxHeight: 400) + .cornerRadius(16) + .padding() + } else { + Text("Photo unavailable") + } + } + .frame(maxHeight: .infinity) + .tag(idx) + } + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + .padding() + } + } + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button(action: { + if selectedIndex < photos.count, let image = photos[selectedIndex].image { + let photo = photos[selectedIndex] + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(photo.imageFilename) + + // Ensure the temp file exists with metadata, create it if not + if !FileManager.default.fileExists(atPath: fileURL.path) { + let success = createImageFileWithMetadata(photo: photo, image: image, fileURL: fileURL) + if !success { + // Fallback to simple JPEG if metadata creation fails + if let data = image.jpegData(compressionQuality: 0.9) { + try? data.write(to: fileURL) + } + } + } + + imageToShare = ShareImage(image: image, fileURL: fileURL) + showShareSheet = true + } + }) { + Image(systemName: "square.and.arrow.up") + .foregroundColor(.blue) + } + + Button(action: { + showDeleteAlert = true + }) { + Image(systemName: "trash") + .foregroundColor(.blue) + } + } + } + .alert("Delete Photo", isPresented: $showDeleteAlert) { + Button("Delete", role: .destructive) { + if selectedIndex < photos.count { + let photoToDelete = photos[selectedIndex] + deletePhoto(photoToDelete) + } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Are you sure you want to delete this photo? This action cannot be undone.") + } + .sheet(item: $imageToShare) { shareImage in + ShareSheet(activityItems: [shareImage.fileURL]) + } + .onChange(of: imageToShare) { oldValue, newValue in + // When share sheet is dismissed, clean up temp file + if oldValue != nil && newValue == nil { + if let fileURL = oldValue?.fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + } + } + } + + private func deletePhoto(_ photo: PathPhoto) { + // Use PathStorage's deletePhoto method + pathStorage.deletePhoto(from: pathId, photo: photo) + + // Remove photo from local photos array + photos.removeAll { $0.id == photo.id } + + // Only dismiss if no photos are left + if photos.isEmpty { + dismiss() + } else { + // Ensure selectedIndex stays within bounds + selectedIndex = min(selectedIndex, photos.count - 1) + } + } + + private func createImageFileWithMetadata(photo: PathPhoto, image: UIImage, fileURL: URL) -> Bool { + guard let imageData = image.jpegData(compressionQuality: 0.9) else { return false } + + // Create image source from the data + guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil) else { return false } + + // Create image destination + guard let imageDestination = CGImageDestinationCreateWithURL(fileURL as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else { return false } + + // Create metadata dictionary with timestamp only + let metadata: [String: Any] = [ + kCGImagePropertyExifDictionary as String: [ + kCGImagePropertyExifDateTimeOriginal as String: ISO8601DateFormatter().string(from: photo.timestamp), + kCGImagePropertyExifDateTimeDigitized as String: ISO8601DateFormatter().string(from: photo.timestamp) + ] + ] + + // Add image with metadata + CGImageDestinationAddImageFromSource(imageDestination, imageSource, 0, metadata as CFDictionary) + + // Finalize the image destination + return CGImageDestinationFinalize(imageDestination) + } + + // UIKit share sheet wrapper + struct ShareSheet: UIViewControllerRepresentable { + var activityItems: [Any] + var applicationActivities: [UIActivity]? = nil + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} + } +} + +// Wrapper for sharing images in .sheet(item:) +struct ShareImage: Identifiable, Equatable { + let id = UUID() + let image: UIImage + let fileURL: URL + + static func == (lhs: ShareImage, rhs: ShareImage) -> Bool { + lhs.id == rhs.id && lhs.fileURL == rhs.fileURL + } +} + diff --git a/PathRecorder/Models/PathPhoto.swift b/PathRecorder/Models/PathPhoto.swift new file mode 100644 index 0000000..893fdcc --- /dev/null +++ b/PathRecorder/Models/PathPhoto.swift @@ -0,0 +1,64 @@ +// +// PathPhoto.swift +// PathRecorder +// +// Created by Aparna Natarajan on 7/20/25. +// +import UIKit +import Foundation +import CoreLocation + +// Model for storing photos taken during a path +struct PathPhoto: Identifiable, Codable, Hashable { + let id: UUID + let timestamp: Date + let imageFilename: String + let locationId: UUID + + init(timestamp: Date, image: UIImage, imageFilename: String, locationId: UUID) { + self.id = UUID() + self.timestamp = timestamp + self.imageFilename = imageFilename + self.locationId = locationId + if let data = image.jpegData(compressionQuality: 0.9) { + let url = PathPhoto.imagesDirectory.appendingPathComponent(imageFilename) + try? data.write(to: url) + } + } + + init(id: UUID, timestamp: Date, imageFilename: String, locationId: UUID) { + self.id = id + self.timestamp = timestamp + self.imageFilename = imageFilename + self.locationId = locationId + } + + var image: UIImage? { + let url = PathPhoto.imagesDirectory.appendingPathComponent(imageFilename) + return UIImage(contentsOfFile: url.path) + } + + static func == (lhs: PathPhoto, rhs: PathPhoto) -> Bool { + return lhs.id == rhs.id && + lhs.timestamp == rhs.timestamp && + lhs.imageFilename == rhs.imageFilename && + lhs.locationId == rhs.locationId + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(timestamp) + hasher.combine(imageFilename) + hasher.combine(locationId) + } + + // Directory for storing images + static var imagesDirectory: URL { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + let dir = paths[0].appendingPathComponent("PathPhotos") + if !FileManager.default.fileExists(atPath: dir.path) { + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + return dir + } +} diff --git a/PathRecorder/Models/PathSegment.swift b/PathRecorder/Models/PathSegment.swift new file mode 100644 index 0000000..3e8092c --- /dev/null +++ b/PathRecorder/Models/PathSegment.swift @@ -0,0 +1,39 @@ +import MapKit +import Foundation + +/// Represents a continuous segment of a path (between pause/resume events) +struct PathSegment: Identifiable, Codable { + let id: UUID + let locations: [GPSLocation] + + init(locations: [GPSLocation]) { + self.id = UUID() + self.locations = locations + } + + init(id: UUID, locations: [GPSLocation]) { + self.id = id + self.locations = locations + } + + var startTime: Date { + locations.first?.timestamp ?? Date() + } + + var endTime: Date { + locations.last?.timestamp ?? Date() + } + + var duration: TimeInterval { + endTime.timeIntervalSince(startTime) + } + + var coordinates: [CLLocationCoordinate2D] { + locations.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } + } + + var mkPolyline: MKPolyline { + let coords = coordinates + return MKPolyline(coordinates: coords, count: coords.count) + } +} \ No newline at end of file diff --git a/PathRecorder/Models/RecordedPath.swift b/PathRecorder/Models/RecordedPath.swift new file mode 100644 index 0000000..3ca3583 --- /dev/null +++ b/PathRecorder/Models/RecordedPath.swift @@ -0,0 +1,107 @@ +import UIKit +import Foundation +import CoreLocation + +struct RecordedPath: Identifiable, Codable, Hashable { + let id: UUID + var segments: [PathSegment] + var name: String + var photos: [PathPhoto] + + init(segments: [PathSegment], name: String? = nil, photos: [PathPhoto] = []) { + self.id = UUID() + self.segments = segments + self.photos = photos + if let name = name { + self.name = name + } else { + let startTime = segments.first?.startTime ?? Date() + self.name = "Path \(DateFormatter.localizedString(from: startTime, dateStyle: .short, timeStyle: .short))" + } + } + + init(id: UUID, segments: [PathSegment], name: String, photos: [PathPhoto] = []) { + self.id = id + self.segments = segments + self.name = name + self.photos = photos + } + + /// Start time of the first segment + var startTime: Date { + segments.first?.startTime ?? Date() + } + + /// Total duration across all segments + var totalDuration: TimeInterval { + segments.reduce(0) { $0 + $1.duration } + } + + /// Total distance traveled across all segments + var totalDistance: Double { + segments.reduce(0) { total, segment in + var distance = total + guard segment.locations.count > 1 else { return distance } + for i in 0..<(segment.locations.count - 1) { + let loc1 = CLLocationCoordinate2D(latitude: segment.locations[i].latitude, + longitude: segment.locations[i].longitude) + let loc2 = CLLocationCoordinate2D(latitude: segment.locations[i + 1].latitude, + longitude: segment.locations[i + 1].longitude) + let c1 = CLLocation(latitude: loc1.latitude, longitude: loc1.longitude) + let c2 = CLLocation(latitude: loc2.latitude, longitude: loc2.longitude) + distance += c1.distance(from: c2) + } + return distance + } + } + + /// All GPS locations from all segments (for backward compatibility with display code) + var locations: [GPSLocation] { + segments.flatMap { $0.locations } + } + + mutating func editName(_ newName: String) { + self.name = newName + } + + mutating func addSegment(_ segment: PathSegment) { + segments.append(segment) + } + + static func == (lhs: RecordedPath, rhs: RecordedPath) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +struct GPSLocation: Identifiable, Codable, Equatable { + let id: UUID + let latitude: Double + let longitude: Double + let timestamp: Date + let segmentId: UUID // Tracks which segment this location belongs to + + init(latitude: Double, longitude: Double, timestamp: Date, segmentId: UUID) { + self.id = UUID() + self.latitude = latitude + self.longitude = longitude + self.timestamp = timestamp + self.segmentId = segmentId + } + + init(id: UUID, latitude: Double, longitude: Double, timestamp: Date, segmentId: UUID) { + self.id = id + self.latitude = latitude + self.longitude = longitude + self.timestamp = timestamp + self.segmentId = segmentId + } + + static func == (lhs: GPSLocation, rhs: GPSLocation) -> Bool { + return lhs.id == rhs.id + } +} + diff --git a/PathRecorder/PathPhoto.swift b/PathRecorder/PathPhoto.swift deleted file mode 100644 index 69cfa0e..0000000 --- a/PathRecorder/PathPhoto.swift +++ /dev/null @@ -1,82 +0,0 @@ -// -// PathPhoto.swift -// PathRecorder -// -// Created by Aparna Natarajan on 7/20/25. -// -import UIKit -import Foundation -import CoreLocation - -// Model for storing photos taken during a path -struct PathPhoto: Identifiable, Codable, Hashable { - let id: UUID - let coordinate: CLLocationCoordinate2D - let timestamp: Date - let imageFilename: String // Store only filename, not image data - - init(coordinate: CLLocationCoordinate2D, timestamp: Date, image: UIImage, imageFilename: String) { - self.id = UUID() - self.coordinate = coordinate - self.timestamp = timestamp - self.imageFilename = imageFilename - // Save image to disk when creating - if let data = image.jpegData(compressionQuality: 0.9) { - let url = PathPhoto.imagesDirectory.appendingPathComponent(imageFilename) - try? data.write(to: url) - } - } - - var image: UIImage? { - let url = PathPhoto.imagesDirectory.appendingPathComponent(imageFilename) - return UIImage(contentsOfFile: url.path) - } - - enum CodingKeys: String, CodingKey { - case id, latitude, longitude, timestamp, imageFilename - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - let latitude = try container.decode(Double.self, forKey: .latitude) - let longitude = try container.decode(Double.self, forKey: .longitude) - coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - timestamp = try container.decode(Date.self, forKey: .timestamp) - imageFilename = try container.decode(String.self, forKey: .imageFilename) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(coordinate.latitude, forKey: .latitude) - try container.encode(coordinate.longitude, forKey: .longitude) - try container.encode(timestamp, forKey: .timestamp) - try container.encode(imageFilename, forKey: .imageFilename) - } - static func == (lhs: PathPhoto, rhs: PathPhoto) -> Bool { - return lhs.id == rhs.id && - lhs.coordinate.latitude == rhs.coordinate.latitude && - lhs.coordinate.longitude == rhs.coordinate.longitude && - lhs.timestamp == rhs.timestamp && - lhs.imageFilename == rhs.imageFilename - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(coordinate.latitude) - hasher.combine(coordinate.longitude) - hasher.combine(timestamp) - hasher.combine(imageFilename) - } - - // Directory for storing images - static var imagesDirectory: URL { - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - let dir = paths[0].appendingPathComponent("PathPhotos") - if !FileManager.default.fileExists(atPath: dir.path) { - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } - return dir - } -} diff --git a/PathRecorder/PathRecorderApp.swift b/PathRecorder/PathRecorderApp.swift index c2eb737..c6ce1d9 100644 --- a/PathRecorder/PathRecorderApp.swift +++ b/PathRecorder/PathRecorderApp.swift @@ -13,9 +13,16 @@ import UIKit @main struct PathRecorderApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate + @StateObject private var authManager = AuthManager() + @StateObject private var backupService = BackupRestoreService() + + init() { + // Run data migrations on app startup + DataMigration.shared.runMigrations() + } + var sharedModelContainer: ModelContainer = { let schema = Schema([ - Item.self, ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) @@ -29,6 +36,8 @@ struct PathRecorderApp: App { var body: some Scene { WindowGroup { ContentView() + .environmentObject(authManager) + .environmentObject(backupService) } .modelContainer(sharedModelContainer) } diff --git a/PathRecorder/RecordedPath.swift b/PathRecorder/RecordedPath.swift deleted file mode 100644 index 92455b3..0000000 --- a/PathRecorder/RecordedPath.swift +++ /dev/null @@ -1,109 +0,0 @@ -import UIKit -import Foundation -import CoreLocation - -struct RecordedPath: Identifiable, Codable, Hashable { - let id: UUID - let startTime: Date // Keep start time for naming and reference - let totalDuration: TimeInterval // Total time in seconds - let totalDistance: Double - let locations: [GPSLocation] - var photos: [PathPhoto] - var name: String - - init(startTime: Date, totalDuration: TimeInterval, totalDistance: Double, locations: [GPSLocation], photos: [PathPhoto] = [], name: String? = nil) { - self.id = UUID() - self.startTime = startTime - self.totalDuration = totalDuration - self.totalDistance = totalDistance - self.locations = locations - self.photos = photos - if let name = name { - self.name = name - } else { - self.name = "Path \(DateFormatter.localizedString(from: startTime, dateStyle: .short, timeStyle: .short))" - } - } - - static func == (lhs: RecordedPath, rhs: RecordedPath) -> Bool { - return lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - mutating func editName(_ newName: String) { - self.name = newName - } - - mutating func deletePhoto(_ photo: PathPhoto) { - photos.removeAll { $0.id == photo.id } - // Also delete the image file from disk - let url = PathPhoto.imagesDirectory.appendingPathComponent(photo.imageFilename) - try? FileManager.default.removeItem(at: url) - } -} - -struct GPSLocation: Identifiable, Codable, Equatable { - let id: UUID - let latitude: Double - let longitude: Double - let timestamp: Date - let segmentId: UUID // Track which recording segment this belongs to - - init(latitude: Double, longitude: Double, timestamp: Date, segmentId: UUID = UUID()) { - self.id = UUID() - self.latitude = latitude - self.longitude = longitude - self.timestamp = timestamp - self.segmentId = segmentId - } - - static func == (lhs: GPSLocation, rhs: GPSLocation) -> Bool { - return lhs.id == rhs.id - } -} - -class PathStorage: ObservableObject { - func path(for id: UUID) -> RecordedPath? { - recordedPaths.first(where: { $0.id == id }) - } - @Published var recordedPaths: [RecordedPath] = [] - private let userDefaults = UserDefaults.standard - private let key = "RecordedPaths" - - init() { - loadPaths() - } - - func savePath(_ path: RecordedPath) { - recordedPaths.append(path) - saveToUserDefaults() - } - - func deletePath(id: UUID) { - recordedPaths.removeAll { $0.id == id } - saveToUserDefaults() - } - - func updatePath(_ path: RecordedPath) { - if let index = recordedPaths.firstIndex(where: { $0.id == path.id }) { - recordedPaths[index] = path - saveToUserDefaults() - } - } - - private func saveToUserDefaults() { - if let encoded = try? JSONEncoder().encode(recordedPaths) { - userDefaults.set(encoded, forKey: key) - } - } - - private func loadPaths() { - if let data = userDefaults.data(forKey: key), - let decoded = try? JSONDecoder().decode([RecordedPath].self, from: data) { - recordedPaths = decoded - } - } -} diff --git a/PathRecorder/RecordingView.swift b/PathRecorder/RecordingView.swift index 9c2102c..6a86d8d 100644 --- a/PathRecorder/RecordingView.swift +++ b/PathRecorder/RecordingView.swift @@ -6,110 +6,112 @@ import SwiftUI import MapKit +import Shared struct RecordingView: View { @ObservedObject var locationManager: LocationManager @ObservedObject var pathStorage: PathStorage @ObservedObject var settings: Settings var onStop: () -> Void - + var body: some View { - NavigationStack { - VStack(spacing: 20) { - if locationManager.isPaused { - Text(locationManager.editingPathName != nil ? "PAUSED EDIT" : "PAUSED") - .foregroundColor(.orange) + VStack(spacing: 20) { + if locationManager.isPaused { + Text(locationManager.editingPathName != nil ? "PAUSED EDIT" : "PAUSED") + .foregroundColor(.orange) + .fontWeight(.bold) + } else { + if locationManager.editingPathName != nil { + Text("EDITING") + .foregroundColor(.purple) .fontWeight(.bold) } else { - if(locationManager.editingPathName != nil) { - Text("EDITING") - .foregroundColor(.purple) - .fontWeight(.bold) - } else { - Text("RECORDING") - .foregroundColor(.red) - .fontWeight(.bold) - } + Text("RECORDING") + .foregroundColor(.red) + .fontWeight(.bold) } - VStack(alignment: .leading, spacing: 10) { - if let location = locationManager.currentLocation { - Text("GPS: \(String(format: "%.6f", location.coordinate.latitude)), \(String(format: "%.6f", location.coordinate.longitude))") + } + + VStack(alignment: .center, spacing: 10) { + HStack(spacing: 10) { + HStack(spacing: 10) { + Image(systemName: "figure.walk") + .foregroundColor(.green) + .font(.subheadline) + Text(settings.formatDistance(locationManager.totalDistance)) } - HStack(spacing: 20) { + if locationManager.elapsedTime > 0 { HStack(spacing: 10) { - Image(systemName: "figure.walk") - .foregroundColor(.green) + Image(systemName: "alarm") + .foregroundColor(.orange) .font(.subheadline) - Text(settings.formatDistance(locationManager.totalDistance)) - } - if locationManager.elapsedTime > 0 { - HStack(spacing: 10) { - Image(systemName: "timer") - .foregroundColor(.orange) - .font(.subheadline) - Text(formatTime(locationManager.elapsedTime)) - } + Text(formatTime(locationManager.elapsedTime)) } } } - .padding() - .frame(maxWidth: .infinity, alignment: .center) - .background(Color.blue.opacity(0.25)) - .cornerRadius(10) + if !locationManager.isPaused { + HStack(spacing: 10) { + Image(systemName: "timer") + .foregroundColor(.blue) + .font(.subheadline) + Text("Pace: " + computePace(distanceMeters: locationManager.totalDistance, elapsedSeconds: locationManager.elapsedTime, unit: settings.distanceUnit.rawValue)) + } + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .center) + .background(Color.blue.opacity(0.25)) + .cornerRadius(10) + .padding(.horizontal) + + LivePathMapView(locationManager: locationManager, pathStorage: pathStorage) + .cornerRadius(12) .padding(.horizontal) + .frame(maxWidth: .infinity, maxHeight: .infinity) - LivePathMapView(locationManager: locationManager, pathStorage: pathStorage) - .cornerRadius(12) - .padding(.horizontal) - .frame(maxWidth: .infinity, maxHeight: .infinity) - HStack(spacing: 20) { - Button(action: { - onStop() - locationManager.stopRecording(pathStorage: pathStorage) - }) { - Text("Stop Recording") + HStack(spacing: 20) { + Button(action: { + onStop() + locationManager.stopRecording(pathStorage: pathStorage) + }) { + Text("Stop Recording") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(maxWidth: .infinity) + .background(Color.red) + .cornerRadius(10) + } + if locationManager.isPaused { + Button(action: { locationManager.resumeRecording() }) { + Text("Resume") .font(.headline) .foregroundColor(.white) .padding() - .frame(maxWidth: .infinity) - .background(Color.red) + .background(Color.green) .cornerRadius(10) } - if locationManager.isPaused { - Button(action: { - locationManager.resumeRecording() - }) { - Text("Resume") - .font(.headline) - .foregroundColor(.white) - .padding() - .background(Color.green) - .cornerRadius(10) - } - } else { - Button(action: { - locationManager.pauseRecording() - }) { - Text("Pause") - .font(.headline) - .foregroundColor(.white) - .padding() - .background(Color.orange) - .cornerRadius(10) - } + } else { + Button(action: { locationManager.pauseRecording() }) { + Text("Pause") + .font(.headline) + .foregroundColor(.white) + .padding() + .background(Color.orange) + .cornerRadius(10) } } - .padding(.horizontal) } - .padding(.vertical) - .navigationBarTitleDisplayMode(.inline) + .padding(.horizontal) } + .padding(.vertical) + .navigationBarTitleDisplayMode(.inline) } - + private func formatTime(_ timeInterval: TimeInterval) -> String { let hours = Int(timeInterval) / 3600 let minutes = Int(timeInterval) / 60 % 60 let seconds = Int(timeInterval) % 60 return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } -} \ No newline at end of file +} diff --git a/PathRecorder/Services/BackupRestoreService.swift b/PathRecorder/Services/BackupRestoreService.swift new file mode 100644 index 0000000..0e94711 --- /dev/null +++ b/PathRecorder/Services/BackupRestoreService.swift @@ -0,0 +1,328 @@ +// +// BackupRestoreService.swift +// PathRecorder +// +// Owns long-lived backup/restore work so it survives view dismissal, +// persists a resume checkpoint to UserDefaults, and can continue in the +// background via BGTaskScheduler. +// + +import Foundation +import BackgroundTasks +import Supabase + +@MainActor +final class BackupRestoreService: ObservableObject { + // Progress state + @Published var isUploadingBackup = false + @Published var backupProgress: Double = 0.0 + @Published var backupStartTime: Date? = nil + @Published var isRestoringFromCloud = false + @Published var restoreProgress: Double = 0.0 + @Published var lastBackupError: String? = nil + @Published var backupSuccessMessage: String? = nil + + // Resume checkpoint persisted to UserDefaults + private struct BackupCheckpoint: Codable { + var pendingPathIds: [UUID] + var uploadedPhotoIds: Set + } + + private let checkpointKey = "BackupRestoreService.checkpoint" + private var activeBackupTask: Task? + + static let bgTaskIdentifier = "com.pathrecorder.backup" + + init() { + registerBackgroundTask() + } + + // MARK: - Checkpoint persistence + + private func saveCheckpoint(_ checkpoint: BackupCheckpoint) { + if let data = try? JSONEncoder().encode(checkpoint) { + UserDefaults.standard.set(data, forKey: checkpointKey) + } + } + + private func loadCheckpoint() -> BackupCheckpoint? { + guard let data = UserDefaults.standard.data(forKey: checkpointKey), + let checkpoint = try? JSONDecoder().decode(BackupCheckpoint.self, from: data) else { + return nil + } + return checkpoint + } + + private func clearCheckpoint() { + UserDefaults.standard.removeObject(forKey: checkpointKey) + } + + // MARK: - Backup entry points + + /// Called from Settings. + func startBackup(pathIds: Set, allPaths: [RecordedPath], userId: UUID, authManager: AuthManager) { + guard !isUploadingBackup else { return } + // Save checkpoint + saveCheckpoint(BackupCheckpoint(pendingPathIds: Array(pathIds), uploadedPhotoIds: [])) + activeBackupTask?.cancel() + activeBackupTask = Task { + await performBackup(allPaths: allPaths, userId: userId, authManager: authManager) + } + } + + /// Resume an interrupted backup (called on app launch if checkpoint exists). + func resumeIfNeeded(allPaths: [RecordedPath], userId: UUID, authManager: AuthManager) { + guard !isUploadingBackup, loadCheckpoint() != nil else { return } + activeBackupTask = Task { + await performBackup(allPaths: allPaths, userId: userId, authManager: authManager) + } + } + + // MARK: - Backup implementation + + private struct PathRow: Encodable { + let id: UUID + let user_id: UUID + let name: String + let created_at: Date + } + private struct SegmentRow: Encodable { + let id: UUID + let path_id: UUID + } + private struct LocationRow: Encodable { + let id: UUID + let segment_id: UUID + let latitude: Double + let longitude: Double + let timestamp: Date + } + private struct PhotoRow: Encodable { + let id: UUID + let user_id: UUID + let location_id: UUID + let timestamp: Date + let storage_path: String + } + + private func performBackup(allPaths: [RecordedPath], userId: UUID, authManager: AuthManager) async { + var checkpoint = loadCheckpoint() ?? BackupCheckpoint( + pendingPathIds: allPaths.map { $0.id }, + uploadedPhotoIds: [] + ) + + isUploadingBackup = true + backupProgress = 0.0 + backupStartTime = Date() + lastBackupError = nil + defer { + isUploadingBackup = false + backupProgress = 0.0 + backupStartTime = nil + } + + let pendingIds = Set(checkpoint.pendingPathIds) + let pathsToBackup = allPaths.filter { pendingIds.contains($0.id) } + let totalPaths = pathsToBackup.count + var completedPaths = 0 + var failedCount = 0 + + print("[Backup] \(totalPaths) paths to back up") + + for path in pathsToBackup { + do { + try await backupSinglePath(path, userId: userId, checkpoint: &checkpoint, + completedPaths: completedPaths, totalPaths: totalPaths) + completedPaths += 1 + backupProgress = Double(completedPaths) / Double(totalPaths) + checkpoint.pendingPathIds.removeAll { $0 == path.id } + checkpoint.uploadedPhotoIds.subtract(path.photos.map { $0.id }) + saveCheckpoint(checkpoint) + authManager.dirtyPathIds.remove(path.id) + print("[Backup] ✓ '\(path.name)' (\(completedPaths)/\(totalPaths))") + } catch { + failedCount += 1 + lastBackupError = error.localizedDescription + print("[Backup] ❌ '\(path.name)': \(error)") + } + } + + if failedCount == 0 { + clearCheckpoint() + backupSuccessMessage = "Your data has been backed up to the cloud." + } else { + backupSuccessMessage = "\(completedPaths) of \(totalPaths) paths backed up. \(failedCount) failed and will retry." + } + await authManager.refreshSyncStatus(localPaths: allPaths) + } + + private func backupSinglePath( + _ path: RecordedPath, + userId: UUID, + checkpoint: inout BackupCheckpoint, + completedPaths: Int, + totalPaths: Int + ) async throws { + print("[Backup] path '\(path.name)' — segments: \(path.segments.count), photos: \(path.photos.count)") + + var segmentRows: [SegmentRow] = [] + var locationRows: [LocationRow] = [] + var photoRows: [PhotoRow] = [] + + for segment in path.segments { + segmentRows.append(SegmentRow(id: segment.id, path_id: path.id)) + for location in segment.locations { + locationRows.append(LocationRow( + id: location.id, segment_id: segment.id, + latitude: location.latitude, longitude: location.longitude, + timestamp: location.timestamp + )) + } + } + + let totalPhotos = path.photos.count + var uploadedPhotos = 0 + for photo in path.photos { + let storagePath = "\(userId.uuidString.lowercased())/\(photo.id.uuidString.lowercased()).jpg" + if checkpoint.uploadedPhotoIds.contains(photo.id) { + uploadedPhotos += 1 + } else { + guard let image = photo.image, + let jpegData = image.jpegData(compressionQuality: 0.9) else { + print("[Backup] ⚠️ skipping photo \(photo.id) — image missing") + continue + } + try await supabase.storage + .from("path-photos") + .upload(storagePath, data: jpegData, options: FileOptions(contentType: "image/jpeg", upsert: true)) + checkpoint.uploadedPhotoIds.insert(photo.id) + saveCheckpoint(checkpoint) + uploadedPhotos += 1 + print("[Backup] ✓ photo (\(uploadedPhotos)/\(totalPhotos))") + } + backupProgress = (Double(completedPaths) + Double(uploadedPhotos) / Double(max(1, totalPhotos))) / Double(totalPaths) + photoRows.append(PhotoRow( + id: photo.id, user_id: userId, location_id: photo.locationId, + timestamp: photo.timestamp, storage_path: storagePath + )) + } + + let pathRow = PathRow(id: path.id, user_id: userId, name: path.name, created_at: path.startTime) + try await supabase.from("paths").upsert([pathRow], onConflict: "id").execute() + if !segmentRows.isEmpty { + try await supabase.from("path_segments").upsert(segmentRows, onConflict: "id").execute() + } + if !locationRows.isEmpty { + try await supabase.from("gps_locations").upsert(locationRows, onConflict: "id").execute() + } + if !photoRows.isEmpty { + try await supabase.from("path_photos").upsert(photoRows, onConflict: "id").execute() + } + } + + // MARK: - Restore + + /// Called from AuthManager.syncOnLogin. Copied verbatim from AuthManager.restorePaths. + func restorePaths(ids: [UUID], pathStorage: PathStorage, authManager: AuthManager) async { + let totalCount = ids.count + var restoredCount = 0 + struct ServerPhoto: Decodable { + let id: UUID; let timestamp: Date; let storage_path: String + } + struct ServerLocation: Decodable { + let id: UUID; let latitude: Double; let longitude: Double + let timestamp: Date; let path_photos: [ServerPhoto] + } + struct ServerSegment: Decodable { + let id: UUID; let gps_locations: [ServerLocation] + } + struct ServerPath: Decodable { + let id: UUID; let name: String; let path_segments: [ServerSegment] + } + + // Batch into chunks of 30 to avoid PostgREST URL length limits + let chunkSize = 30 + let chunks = stride(from: 0, to: ids.count, by: chunkSize).map { + Array(ids[$0.. PathSegment in + let locs = seg.gps_locations + .sorted { $0.timestamp < $1.timestamp } + .map { GPSLocation(id: $0.id, latitude: $0.latitude, longitude: $0.longitude, + timestamp: $0.timestamp, segmentId: seg.id) } + return PathSegment(id: seg.id, locations: locs) + }.sorted { $0.startTime < $1.startTime } + + let photos = allLocations.flatMap { loc in + loc.path_photos.map { + PathPhoto(id: $0.id, timestamp: $0.timestamp, + imageFilename: "\($0.id.uuidString.lowercased()).jpg", + locationId: loc.id) + } + } + + let recordedPath = RecordedPath(id: path.id, segments: segments, + name: path.name, photos: photos) + restoredCount += 1 + let progress = Double(restoredCount) / Double(totalCount) + print("[Restore] ✓ '\(path.name)': \(segments.count) segs, \(allLocations.count) locs, \(photos.count) photos (\(restoredCount)/\(totalCount))") + await MainActor.run { + pathStorage.savePath(recordedPath) + self.restoreProgress = progress + } + } + } + } + + // MARK: - Background task + + private func registerBackgroundTask() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.bgTaskIdentifier, using: nil) { task in + Task { @MainActor in + // schedule next + self.scheduleBackgroundBackup() + // if backup in progress, extend time; otherwise do nothing + task.setTaskCompleted(success: true) + } + } + } + + func scheduleBackgroundBackup() { + let request = BGProcessingTaskRequest(identifier: Self.bgTaskIdentifier) + request.requiresNetworkConnectivity = true + try? BGTaskScheduler.shared.submit(request) + } +} diff --git a/PathRecorder/Services/DataMigration.swift b/PathRecorder/Services/DataMigration.swift new file mode 100644 index 0000000..175b8ee --- /dev/null +++ b/PathRecorder/Services/DataMigration.swift @@ -0,0 +1,194 @@ +import Foundation +import CoreLocation + +class DataMigration { + static let shared = DataMigration() + + private let userDefaults: UserDefaults + private let migratedV1Key = "DataMigrationV1Completed" + private let migratedV2Key = "DataMigrationV2Completed" + private let migratedV3Key = "DataMigrationV3Completed" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func runMigrations() { + if !userDefaults.bool(forKey: migratedV1Key) { + migrateV1() + userDefaults.set(true, forKey: migratedV1Key) + } + if !userDefaults.bool(forKey: migratedV2Key) { + migrateV2() + userDefaults.set(true, forKey: migratedV2Key) + } + if !userDefaults.bool(forKey: migratedV3Key) { + migrateV3() + userDefaults.set(true, forKey: migratedV3Key) + } + } + + // MARK: - V1: flat locations → segment-based format + + private func migrateV1() { + guard let data = userDefaults.data(forKey: "RecordedPaths") else { return } + do { + let oldPaths = try JSONDecoder().decode([RecordedPathOld].self, from: data) + let migrated = oldPaths.map { convertOldPath($0) } + if let encoded = try? JSONEncoder().encode(migrated) { + userDefaults.set(encoded, forKey: "RecordedPaths") + } + } catch { + print("V1 migration error: \(error)") + } + } + + private func convertOldPath(_ oldPath: RecordedPathOld) -> RecordedPathLenient { + var allPhotos: [PathPhotoLenient] = [] + var segments: [PathSegmentLenient] = [] + + if !oldPath.locations.isEmpty { + let defaultSegmentId = UUID() + let grouped = Dictionary(grouping: oldPath.locations) { $0.segmentId ?? defaultSegmentId } + + segments = grouped + .sorted { ($0.value.first?.timestamp ?? Date()) < ($1.value.first?.timestamp ?? Date()) } + .map { _, locs in + let sorted = locs.sorted { $0.timestamp < $1.timestamp } + // Collect GPS-level photos — locationId will be resolved in V2 + sorted.forEach { loc in + loc.photos?.forEach { photo in + allPhotos.append(PathPhotoLenient( + id: photo.id, + timestamp: photo.timestamp, + imageFilename: photo.imageFilename, + locationId: nil // V2 infers from timestamp + )) + } + } + let segmentId = UUID() + let locations = sorted.map { + GPSLocation(latitude: $0.latitude, longitude: $0.longitude, + timestamp: $0.timestamp, segmentId: segmentId) + } + return PathSegmentLenient(id: UUID(), locations: locations) + } + } + + // Path-level photos (no location context): V2 infers from timestamp + oldPath.photos?.forEach { photo in + allPhotos.append(PathPhotoLenient( + id: photo.id, timestamp: photo.timestamp, + imageFilename: photo.imageFilename, locationId: nil + )) + } + + return RecordedPathLenient(id: oldPath.id, segments: segments, + name: oldPath.name, photos: allPhotos) + } + + // MARK: - V2: ensure every photo has a valid locationId + + private func migrateV2() { + guard let data = userDefaults.data(forKey: "RecordedPaths") else { return } + do { + var paths = try JSONDecoder().decode([RecordedPathLenient].self, from: data) + + for i in paths.indices { + let allLocations = paths[i].segments.flatMap { $0.locations } + paths[i].photos = paths[i].photos.compactMap { photo in + let validId = photo.locationId.flatMap { lid in + allLocations.contains(where: { $0.id == lid }) ? lid : nil + } + let resolved = validId ?? allLocations.min(by: { + abs($0.timestamp.timeIntervalSince(photo.timestamp)) < + abs($1.timestamp.timeIntervalSince(photo.timestamp)) + })?.id + guard let locationId = resolved else { return nil } + return PathPhotoLenient(id: photo.id, timestamp: photo.timestamp, + imageFilename: photo.imageFilename, locationId: locationId) + } + } + + if let encoded = try? JSONEncoder().encode(paths) { + userDefaults.set(encoded, forKey: "RecordedPaths") + } + } catch { + print("V2 migration error: \(error)") + } + } + + // MARK: - V3: drop stale segment-start anchors from markSegment() called before GPS delivered a fresh location + + private func migrateV3() { + guard let data = userDefaults.data(forKey: "RecordedPaths") else { return } + do { + var paths = try JSONDecoder().decode([RecordedPath].self, from: data) + // If the gap between a segment's first and second location exceeds this, the first + // location is a stale cached reading injected at recording start, not a real GPS fix. + let staleThreshold: TimeInterval = 60 + + for i in paths.indices { + for j in paths[i].segments.indices { + let locs = paths[i].segments[j].locations + guard locs.count >= 2 else { continue } + let gap = locs[1].timestamp.timeIntervalSince(locs[0].timestamp) + if gap > staleThreshold { + paths[i].segments[j] = PathSegment( + id: paths[i].segments[j].id, + locations: Array(locs.dropFirst()) + ) + } + } + } + + if let encoded = try? JSONEncoder().encode(paths) { + userDefaults.set(encoded, forKey: "RecordedPaths") + } + } catch { + print("V3 migration error: \(error)") + } + } +} + +// MARK: - Shared lenient types (same JSON shape as RecordedPath / PathPhoto) + +private struct RecordedPathLenient: Codable { + let id: UUID + var segments: [PathSegmentLenient] + var name: String + var photos: [PathPhotoLenient] +} + +private struct PathSegmentLenient: Codable { + let id: UUID + let locations: [GPSLocation] +} + +private struct PathPhotoLenient: Codable { + let id: UUID + let timestamp: Date + let imageFilename: String + let locationId: UUID? +} + +// MARK: - V1 old model shapes + +private struct RecordedPathOld: Codable { + let id: UUID + let startTime: Date? + let totalDuration: TimeInterval? + let totalDistance: Double? + let locations: [GPSLocationOld] + let photos: [PathPhotoLenient]? + let name: String +} + +private struct GPSLocationOld: Codable { + let id: UUID + let latitude: Double + let longitude: Double + let timestamp: Date + let segmentId: UUID? + let photos: [PathPhotoLenient]? +} diff --git a/PathRecorder/Services/PathStorage.swift b/PathRecorder/Services/PathStorage.swift new file mode 100644 index 0000000..23e3414 --- /dev/null +++ b/PathRecorder/Services/PathStorage.swift @@ -0,0 +1,82 @@ +import Foundation +import Combine + +final class PathStorage: ObservableObject { + func path(for id: UUID) -> RecordedPath? { + recordedPaths.first(where: { $0.id == id }) + } + + @Published var recordedPaths: [RecordedPath] = [] + @Published var lastUpdatedPathId: UUID? = nil + private let userDefaults = UserDefaults.standard + private let key = "RecordedPaths" + + init() { + loadPaths() + } + + func savePath(_ path: RecordedPath) { + if let index = recordedPaths.firstIndex(where: { $0.id == path.id }) { + recordedPaths[index] = path + } else { + recordedPaths.append(path) + } + saveToUserDefaults() + } + + func deletePath(id: UUID) { + recordedPaths.removeAll { $0.id == id } + saveToUserDefaults() + } + + func updatePath(_ path: RecordedPath) { + if let index = recordedPaths.firstIndex(where: { $0.id == path.id }) { + let existing = recordedPaths[index] + guard existing.name != path.name || existing.photos != path.photos else { return } + recordedPaths[index] = path + saveToUserDefaults() + lastUpdatedPathId = path.id + } + } + + func deletePhoto(from pathId: UUID, photo: PathPhoto) { + if let index = recordedPaths.firstIndex(where: { $0.id == pathId }) { + let originalCount = recordedPaths[index].photos.count + recordedPaths[index].photos.removeAll { $0.id == photo.id } + guard recordedPaths[index].photos.count != originalCount else { return } + // Delete image file from disk + let url = PathPhoto.imagesDirectory.appendingPathComponent(photo.imageFilename) + try? FileManager.default.removeItem(at: url) + saveToUserDefaults() + lastUpdatedPathId = pathId + } + } + + private func saveToUserDefaults() { + if let encoded = try? JSONEncoder().encode(recordedPaths) { + userDefaults.set(encoded, forKey: key) + } + } + + /// Export the stored paths as a JSON file and return a file URL to the temporary file. + /// Returns `nil` if encoding or writing fails. + func exportJSONToTemporaryFile() -> URL? { + guard let data = try? JSONEncoder().encode(recordedPaths) else { return nil } + let tmpDir = FileManager.default.temporaryDirectory + let filename = "PathRecorderExport-\(Int(Date().timeIntervalSince1970)).json" + let url = tmpDir.appendingPathComponent(filename) + do { + try data.write(to: url) + return url + } catch { + return nil + } + } + + private func loadPaths() { + if let data = userDefaults.data(forKey: key), + let decoded = try? JSONDecoder().decode([RecordedPath].self, from: data) { + recordedPaths = decoded + } + } +} diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index 68af276..443c9b8 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -1,9 +1,120 @@ import Foundation +import SwiftUI +import UIKit +import Supabase + +struct CountryDialCode: Identifiable, Equatable { + let id: String + let flag: String + let name: String + let dialCode: String + + static let us = CountryDialCode(id: "US", flag: "🇺🇸", name: "United States", dialCode: "+1") + + static let all: [CountryDialCode] = [ + .us, + CountryDialCode(id: "CA", flag: "🇨🇦", name: "Canada", dialCode: "+1"), + CountryDialCode(id: "GB", flag: "🇬🇧", name: "United Kingdom", dialCode: "+44"), + CountryDialCode(id: "AU", flag: "🇦🇺", name: "Australia", dialCode: "+61"), + CountryDialCode(id: "DE", flag: "🇩🇪", name: "Germany", dialCode: "+49"), + CountryDialCode(id: "FR", flag: "🇫🇷", name: "France", dialCode: "+33"), + CountryDialCode(id: "IT", flag: "🇮🇹", name: "Italy", dialCode: "+39"), + CountryDialCode(id: "ES", flag: "🇪🇸", name: "Spain", dialCode: "+34"), + CountryDialCode(id: "NL", flag: "🇳🇱", name: "Netherlands", dialCode: "+31"), + CountryDialCode(id: "BE", flag: "🇧🇪", name: "Belgium", dialCode: "+32"), + CountryDialCode(id: "CH", flag: "🇨🇭", name: "Switzerland", dialCode: "+41"), + CountryDialCode(id: "AT", flag: "🇦🇹", name: "Austria", dialCode: "+43"), + CountryDialCode(id: "SE", flag: "🇸🇪", name: "Sweden", dialCode: "+46"), + CountryDialCode(id: "NO", flag: "🇳🇴", name: "Norway", dialCode: "+47"), + CountryDialCode(id: "DK", flag: "🇩🇰", name: "Denmark", dialCode: "+45"), + CountryDialCode(id: "FI", flag: "🇫🇮", name: "Finland", dialCode: "+358"), + CountryDialCode(id: "PL", flag: "🇵🇱", name: "Poland", dialCode: "+48"), + CountryDialCode(id: "CZ", flag: "🇨🇿", name: "Czech Republic", dialCode: "+420"), + CountryDialCode(id: "PT", flag: "🇵🇹", name: "Portugal", dialCode: "+351"), + CountryDialCode(id: "GR", flag: "🇬🇷", name: "Greece", dialCode: "+30"), + CountryDialCode(id: "RU", flag: "🇷🇺", name: "Russia", dialCode: "+7"), + CountryDialCode(id: "TR", flag: "🇹🇷", name: "Turkey", dialCode: "+90"), + CountryDialCode(id: "IN", flag: "🇮🇳", name: "India", dialCode: "+91"), + CountryDialCode(id: "CN", flag: "🇨🇳", name: "China", dialCode: "+86"), + CountryDialCode(id: "JP", flag: "🇯🇵", name: "Japan", dialCode: "+81"), + CountryDialCode(id: "KR", flag: "🇰🇷", name: "South Korea", dialCode: "+82"), + CountryDialCode(id: "SG", flag: "🇸🇬", name: "Singapore", dialCode: "+65"), + CountryDialCode(id: "HK", flag: "🇭🇰", name: "Hong Kong", dialCode: "+852"), + CountryDialCode(id: "TW", flag: "🇹🇼", name: "Taiwan", dialCode: "+886"), + CountryDialCode(id: "PH", flag: "🇵🇭", name: "Philippines", dialCode: "+63"), + CountryDialCode(id: "ID", flag: "🇮🇩", name: "Indonesia", dialCode: "+62"), + CountryDialCode(id: "MY", flag: "🇲🇾", name: "Malaysia", dialCode: "+60"), + CountryDialCode(id: "TH", flag: "🇹🇭", name: "Thailand", dialCode: "+66"), + CountryDialCode(id: "VN", flag: "🇻🇳", name: "Vietnam", dialCode: "+84"), + CountryDialCode(id: "PK", flag: "🇵🇰", name: "Pakistan", dialCode: "+92"), + CountryDialCode(id: "BD", flag: "🇧🇩", name: "Bangladesh", dialCode: "+880"), + CountryDialCode(id: "AE", flag: "🇦🇪", name: "UAE", dialCode: "+971"), + CountryDialCode(id: "SA", flag: "🇸🇦", name: "Saudi Arabia", dialCode: "+966"), + CountryDialCode(id: "IL", flag: "🇮🇱", name: "Israel", dialCode: "+972"), + CountryDialCode(id: "EG", flag: "🇪🇬", name: "Egypt", dialCode: "+20"), + CountryDialCode(id: "MA", flag: "🇲🇦", name: "Morocco", dialCode: "+212"), + CountryDialCode(id: "NG", flag: "🇳🇬", name: "Nigeria", dialCode: "+234"), + CountryDialCode(id: "KE", flag: "🇰🇪", name: "Kenya", dialCode: "+254"), + CountryDialCode(id: "ZA", flag: "🇿🇦", name: "South Africa", dialCode: "+27"), + CountryDialCode(id: "BR", flag: "🇧🇷", name: "Brazil", dialCode: "+55"), + CountryDialCode(id: "MX", flag: "🇲🇽", name: "Mexico", dialCode: "+52"), + CountryDialCode(id: "AR", flag: "🇦🇷", name: "Argentina", dialCode: "+54"), + CountryDialCode(id: "CO", flag: "🇨🇴", name: "Colombia", dialCode: "+57"), + CountryDialCode(id: "CL", flag: "🇨🇱", name: "Chile", dialCode: "+56"), + CountryDialCode(id: "PE", flag: "🇵🇪", name: "Peru", dialCode: "+51"), + ] +} + +struct CountryPickerView: View { + @Binding var selectedCountry: CountryDialCode + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + + var filtered: [CountryDialCode] { + if searchText.isEmpty { return CountryDialCode.all } + return CountryDialCode.all.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + $0.dialCode.contains(searchText) + } + } + + var body: some View { + NavigationStack { + List(filtered) { country in + Button { + selectedCountry = country + dismiss() + } label: { + HStack { + Text(country.flag) + Text(country.name) + .foregroundColor(.primary) + Spacer() + Text(country.dialCode) + .foregroundColor(.secondary) + if country == selectedCountry { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + .searchable(text: $searchText, prompt: "Search country") + .navigationTitle("Country Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { dismiss() } + } + } + } + } +} enum DistanceUnit: String, CaseIterable, Codable { case kilometers = "km" case miles = "mi" - + var displayName: String { switch self { case .kilometers: @@ -12,7 +123,7 @@ enum DistanceUnit: String, CaseIterable, Codable { return "Miles" } } - + var conversionFactor: Double { switch self { case .kilometers: @@ -21,7 +132,7 @@ enum DistanceUnit: String, CaseIterable, Codable { return 0.621371 // Convert from meters to miles } } - + var unitLabel: String { switch self { case .kilometers: @@ -38,7 +149,7 @@ class Settings: ObservableObject { UserDefaults.standard.set(distanceUnit.rawValue, forKey: "distanceUnit") } } - + init() { if let savedUnit = UserDefaults.standard.string(forKey: "distanceUnit"), let unit = DistanceUnit(rawValue: savedUnit) { @@ -47,13 +158,285 @@ class Settings: ObservableObject { self.distanceUnit = .kilometers } } - + func convertDistance(_ meters: Double) -> Double { return meters / 1000 * distanceUnit.conversionFactor } - + func formatDistance(_ meters: Double) -> String { let convertedDistance = convertDistance(meters) return String(format: "%.2f %@", convertedDistance, distanceUnit.unitLabel) } -} \ No newline at end of file +} + +struct SettingsView: View { + @ObservedObject var settings: Settings + @ObservedObject var pathStorage: PathStorage + @EnvironmentObject private var authManager: AuthManager + @EnvironmentObject private var backupService: BackupRestoreService + @Environment(\.dismiss) private var dismiss + // Sign-out + @State private var isSigningOut = false + // Inline sign-in OTP flow + @State private var selectedCountry: CountryDialCode = .us + @State private var showCountryPicker = false + @State private var authPhone = "" + @State private var authOTP = "" + @State private var didRequestOTP = false + @State private var isSendingOTP = false + @State private var isVerifyingOTP = false + @State private var authErrorMessage: String? = nil + + private var fullPhone: String { + selectedCountry.dialCode + authPhone.filter(\.isNumber) + } + + private var isPhoneValid: Bool { + let digits = authPhone.filter(\.isNumber) + return digits.count >= 6 && digits.count <= 14 + } + + var body: some View { + NavigationView { + Form { + Section(header: Text("Account")) { + if authManager.isAuthenticated { + HStack { + Text("Phone") + Spacer() + Text(authManager.displayPhone(for: authManager.currentUser)) + .foregroundColor(.secondary) + } + if backupService.isRestoringFromCloud { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Restoring from cloud... \(Int(backupService.restoreProgress * 100))%") + .font(.subheadline) + Spacer() + } + ProgressView(value: backupService.restoreProgress) + } + } + if backupService.isUploadingBackup || authManager.hasUnsyncedPaths { + Button { + if let userId = authManager.currentUser?.id { + Task { + backupService.startBackup( + pathIds: authManager.unsyncedPathIds.union(authManager.dirtyPathIds), + allPaths: pathStorage.recordedPaths, + userId: userId, + authManager: authManager + ) + } + } + } label: { + if backupService.isUploadingBackup { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Backing up... \(Int(backupService.backupProgress * 100))%") + .font(.subheadline) + Spacer() + if let remaining = estimatedTimeRemaining { + Text(remaining) + .font(.caption) + .foregroundColor(.secondary) + } + } + ProgressView(value: backupService.backupProgress) + } + } else { + HStack { + Image(systemName: "icloud.and.arrow.up") + Text("Backup to Cloud") + } + } + } + .disabled(backupService.isUploadingBackup) + } + + Button(role: .destructive) { + Task { await signOut() } + } label: { + if isSigningOut { + HStack { ProgressView(); Text("Signing out...") } + } else { + Text("Sign Out") + } + } + .disabled(isSigningOut || backupService.isUploadingBackup) + } else { + HStack(spacing: 0) { + Button { + showCountryPicker = true + } label: { + HStack(spacing: 4) { + Text(selectedCountry.flag) + Text(selectedCountry.dialCode) + .foregroundColor(.primary) + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.trailing, 8) + } + .buttonStyle(.plain) + + TextField("Phone number", text: $authPhone) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .onChange(of: authPhone) { _ in + if didRequestOTP { didRequestOTP = false; authOTP = "" } + } + } + .sheet(isPresented: $showCountryPicker) { + CountryPickerView(selectedCountry: $selectedCountry) + } + .onChange(of: selectedCountry) { _ in + if didRequestOTP { didRequestOTP = false; authOTP = "" } + } + + if didRequestOTP { + TextField("6-digit code", text: $authOTP) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + } + if didRequestOTP && authOTP.filter(\.isNumber).count == 6 { + Button(isVerifyingOTP ? "Verifying..." : "Verify Code") { + Task { await verifyOTP() } + } + .disabled(isVerifyingOTP) + } else { + Button(isSendingOTP ? "Sending..." : didRequestOTP ? "Resend Code" : "Send Code") { + Task { await sendOTP() } + } + .disabled(isSendingOTP || !isPhoneValid) + } + + + Text("Enter your phone number to sign in or create an account.") + .font(.footnote) + .foregroundColor(.secondary) + } + } + Section(header: Text("Distance Units")) { + Picker("Distance Unit", selection: $settings.distanceUnit) { + ForEach(DistanceUnit.allCases, id: \.self) { unit in + Text(unit.displayName).tag(unit) + } + } + .pickerStyle(SegmentedPickerStyle()) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .alert("Backup Saved", isPresented: .constant(backupService.backupSuccessMessage != nil)) { + Button("OK") { backupService.backupSuccessMessage = nil } + } message: { + Text(backupService.backupSuccessMessage ?? "") + } + .alert("Auth Error", isPresented: .constant(authErrorMessage != nil || backupService.lastBackupError != nil)) { + Button("OK") { + authErrorMessage = nil + backupService.lastBackupError = nil + } + } message: { + Text(authErrorMessage ?? backupService.lastBackupError ?? "Unknown error") + } + } + + private var estimatedTimeRemaining: String? { + guard let start = backupService.backupStartTime, + backupService.backupProgress > 0.05 else { return nil } + let elapsed = Date().timeIntervalSince(start) + let total = elapsed / backupService.backupProgress + let remaining = total - elapsed + guard remaining > 1 else { return nil } + let secs = Int(remaining.rounded()) + let y = secs / (365 * 24 * 3600) + let d = (secs % (365 * 24 * 3600)) / (24 * 3600) + let h = (secs % (24 * 3600)) / 3600 + let m = (secs % 3600) / 60 + let s = secs % 60 + var parts: [String] = [] + if y > 0 { parts.append("\(y)y") } + if d > 0 { parts.append("\(d)d") } + if h > 0 { parts.append("\(h)h") } + if m > 0 { parts.append("\(m)m") } + if s > 0 || parts.isEmpty { parts.append("\(s)s") } + return "~\(parts.joined(separator: " ")) left" + } + + private func signOut() async { + isSigningOut = true + defer { isSigningOut = false } + do { + try await authManager.signOut() + } catch { + authErrorMessage = error.localizedDescription + } + } + + private func sendOTP() async { + isSendingOTP = true + authErrorMessage = nil + defer { isSendingOTP = false } + do { + try await authManager.requestOTP(phone: fullPhone) + didRequestOTP = true + } catch { + authErrorMessage = error.localizedDescription + } + } + + private func verifyOTP() async { + isVerifyingOTP = true + authErrorMessage = nil + defer { isVerifyingOTP = false } + do { + try await authManager.verifyOTP(phone: fullPhone, token: authOTP) + authOTP = "" + authPhone = "" + didRequestOTP = false + } catch { + authErrorMessage = error.localizedDescription + } + } + + +} + +// MARK: - Color <-> Hex helpers + +extension Color { + func toHexString() -> String { + let uiColor = UIColor(self) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let rgb: Int = (Int)(red*255)<<16 | (Int)(green*255)<<8 | (Int)(blue*255)<<0 + return String(format: "%06x", rgb) + } + + static func fromHexString(_ hex: String) -> Color? { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + var rgb: UInt64 = 0 + guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil } + let r = Double((rgb & 0xFF0000) >> 16) / 255.0 + let g = Double((rgb & 0x00FF00) >> 8) / 255.0 + let b = Double(rgb & 0x0000FF) / 255.0 + return Color(red: r, green: g, blue: b) + } +} diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift new file mode 100644 index 0000000..ab8e48e --- /dev/null +++ b/PathRecorder/Supabase.swift @@ -0,0 +1,179 @@ +// +// Supabase.swift +// PathRecorder +// +// Created by Aparna Natarajan on 3/14/26. +// + + +import Supabase +import SwiftUI + +let supabase = SupabaseClient( + supabaseURL: URL(string: "https://hsbnabtalqugbwspdhnq.supabase.co")!, + supabaseKey: "sb_publishable_plix2vRBUgoocyW2QacrVA_tqPYIO-M" +) + +@MainActor +final class AuthManager: ObservableObject { + @Published var currentUser: User? + @Published var isLoadingSession = true + @Published var unsyncedPathIds: Set = [] + @Published var dirtyPathIds: Set = [] + var hasUnsyncedPaths: Bool { !unsyncedPathIds.isEmpty || !dirtyPathIds.isEmpty } + + private var authListenerTask: Task? + + var isAuthenticated: Bool { + currentUser != nil + } + + init() { + authListenerTask = Task { + for await (_, session) in await supabase.auth.authStateChanges { + self.currentUser = session?.user + self.isLoadingSession = false + } + } + + Task { + await restoreSession() + } + } + + deinit { + authListenerTask?.cancel() + } + + func restoreSession() async { + do { + let session = try await supabase.auth.session + currentUser = session.user + } catch { + currentUser = nil + } + isLoadingSession = false + } + + /// Sends an SMS OTP. Creates the user if they don't exist yet, so this + /// doubles as both sign-in and sign-up. + func requestOTP(phone: String) async throws { + let normalizedPhone = normalized(phone: phone) + guard !normalizedPhone.isEmpty else { + throw AuthFlowError.invalidPhone + } + + try await supabase.auth.signInWithOTP( + phone: normalizedPhone, + shouldCreateUser: true + ) + } + + func verifyOTP(phone: String, token: String) async throws { + let normalizedPhone = normalized(phone: phone) + let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !normalizedPhone.isEmpty else { + throw AuthFlowError.invalidPhone + } + + guard !normalizedToken.isEmpty else { + throw AuthFlowError.invalidOTP + } + + _ = try await supabase.auth.verifyOTP( + phone: normalizedPhone, + token: normalizedToken, + type: .sms + ) + } + + func signOut() async throws { + try await supabase.auth.signOut() + currentUser = nil + } + + func displayPhone(for user: User?) -> String { + user?.phone ?? "Unknown" + } + + private func normalized(phone: String) -> String { + phone + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "") + } + + // MARK: - Cloud Delete + + func deleteFromCloud(pathId: UUID, photoIds: [UUID]) async { + guard let userId = currentUser?.id else { return } + let storagePaths = photoIds.map { "\(userId.uuidString.lowercased())/\($0.uuidString.lowercased()).jpg" } + if !storagePaths.isEmpty { + try? await supabase.storage.from("path-photos").remove(paths: storagePaths) + } + try? await supabase.from("paths").delete().eq("id", value: pathId).execute() + } + + /// Deletes a path locally and, if signed in, removes it from cloud storage/DB too. + func deletePath(_ path: RecordedPath, pathStorage: PathStorage) { + let photoIds = path.photos.map { $0.id } + pathStorage.deletePath(id: path.id) + Task { await deleteFromCloud(pathId: path.id, photoIds: photoIds) } + } + + // MARK: - Cloud Sync + + func syncOnLogin(pathStorage: PathStorage, backupService: BackupRestoreService) async { + guard let userId = currentUser?.id else { return } + struct ServerPathId: Decodable { let id: UUID } + guard let entries: [ServerPathId] = try? await supabase + .from("paths").select("id").eq("user_id", value: userId) + .execute().value else { return } + + let serverIds = Set(entries.map { $0.id }) + let localIds = Set(pathStorage.recordedPaths.map { $0.id }) + + let toRestore = Array(serverIds.subtracting(localIds)) + if !toRestore.isEmpty { + print("[Restore] \(toRestore.count) paths to restore from cloud") + backupService.isRestoringFromCloud = true + backupService.restoreProgress = 0.0 + await backupService.restorePaths(ids: toRestore, pathStorage: pathStorage, authManager: self) + backupService.isRestoringFromCloud = false + backupService.restoreProgress = 0.0 + } + + let updatedLocalIds = Set(pathStorage.recordedPaths.map { $0.id }) + await MainActor.run { unsyncedPathIds = updatedLocalIds.subtracting(serverIds) } + } + + func refreshSyncStatus(localPaths: [RecordedPath]) async { + guard let userId = currentUser?.id else { + await MainActor.run { unsyncedPathIds = [] } + return + } + struct ServerPathId: Decodable { let id: UUID } + guard let entries: [ServerPathId] = try? await supabase + .from("paths").select("id").eq("user_id", value: userId) + .execute().value else { return } + let serverIds = Set(entries.map { $0.id }) + let localIds = Set(localPaths.map { $0.id }) + await MainActor.run { unsyncedPathIds = localIds.subtracting(serverIds) } + } +} + +enum AuthFlowError: LocalizedError { + case invalidPhone + case invalidOTP + + var errorDescription: String? { + switch self { + case .invalidPhone: + return "Enter a valid phone number." + case .invalidOTP: + return "Enter the OTP code sent to your phone." + } + } +} + + diff --git a/PathRecorderTests/DataMigrationTests.swift b/PathRecorderTests/DataMigrationTests.swift new file mode 100644 index 0000000..5bc25d9 --- /dev/null +++ b/PathRecorderTests/DataMigrationTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import PathRecorder + +final class DataMigrationTests: XCTestCase { + private var userDefaults: UserDefaults! + private let suiteName = "DataMigrationTestsSuite" + + override func setUp() { + super.setUp() + userDefaults = UserDefaults(suiteName: suiteName) + userDefaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + userDefaults.removePersistentDomain(forName: suiteName) + userDefaults = nil + super.tearDown() + } + + func testMigrationConvertsOldRecordedPathToSegmentedFormat() throws { + // Try to load an external fixture file inside the tests folder named "fixture.json". + let cwd = FileManager.default.currentDirectoryPath + let fixturePath = cwd + "/PathRecorder/PathRecorderTests/fixture.json" + var oldPathsToEncode: [RecordedPathOldTest] + + if let data = FileManager.default.contents(atPath: fixturePath), !data.isEmpty { + // If fixture exists, try decoding it as the old model (ISO-8601 timestamps) + let fixtureDecoder = JSONDecoder() + fixtureDecoder.dateDecodingStrategy = .iso8601 + oldPathsToEncode = try fixtureDecoder.decode([RecordedPathOldTest].self, from: data) + } else { + // Fallback: construct an inline legacy path as before + let pathPhotos = [PathPhoto(timestamp: Date(), image: UIImage(), imageFilename: "pathPhoto.jpg")] + let segmentPhoto = PathPhoto(timestamp: Date(), image: UIImage(), imageFilename: "segmentPhoto.jpg") + + let segmentId = UUID() + let startTime = Date() + let location1 = GPSLocationOldTest( + id: UUID(), + latitude: 37.7749, + longitude: -122.4194, + timestamp: startTime, + segmentId: nil, + photos: [segmentPhoto] + ) + let location2 = GPSLocationOldTest( + id: UUID(), + latitude: 37.7750, + longitude: -122.4195, + timestamp: startTime.addingTimeInterval(60), + segmentId: nil, + photos: nil + ) + let location3 = GPSLocationOldTest( + id: UUID(), + latitude: 37.7760, + longitude: -122.4200, + timestamp: startTime.addingTimeInterval(120), + segmentId: segmentId, + photos: nil + ) + + let oldPath = RecordedPathOldTest( + id: UUID(), + startTime: startTime, + totalDuration: 120, + totalDistance: 100, + locations: [location1, location2, location3], + photos: pathPhotos, + name: "My Legacy Path" + ) + + oldPathsToEncode = [oldPath] + } + + let encoder = JSONEncoder() + userDefaults.set(try encoder.encode(oldPathsToEncode), forKey: "RecordedPaths") + + let migration = DataMigration(userDefaults: userDefaults) + migration.runMigrations() + + XCTAssertTrue(userDefaults.bool(forKey: "DataMigrationV1Completed")) + + let migratedData = userDefaults.data(forKey: "RecordedPaths") + XCTAssertNotNil(migratedData, "Migrated data should be written back to user defaults") + + let decoder = JSONDecoder() + let migratedPaths = try decoder.decode([RecordedPath].self, from: migratedData!) + + XCTAssertEqual(migratedPaths.count, oldPathsToEncode.count) + let migratedPath = migratedPaths[0] + XCTAssertEqual(migratedPath.name, oldPathsToEncode[0].name) + XCTAssertGreaterThanOrEqual(migratedPath.photos.count, 1) + XCTAssertGreaterThanOrEqual(migratedPath.segments.count, 1) + + // Basic consistency checks + let allTimestamps = migratedPath.locations.map { $0.timestamp } + let originalTimestamps = oldPathsToEncode[0].locations.map { $0.timestamp } + XCTAssertEqual(allTimestamps, originalTimestamps) + } + + func testMigrationPreservesCoordinatesForLegacyLocationPhotos() throws { + let locationPhoto = PathPhoto(timestamp: Date(), image: UIImage(), imageFilename: "segmentPhoto.jpg") + let photoLatitude = 37.7749 + let photoLongitude = -122.4194 + + let location = GPSLocationOldTest( + id: UUID(), + latitude: photoLatitude, + longitude: photoLongitude, + timestamp: Date(), + segmentId: nil, + photos: [locationPhoto] + ) + + let oldPath = RecordedPathOldTest( + id: UUID(), + startTime: Date(), + totalDuration: 60, + totalDistance: 10, + locations: [location], + photos: nil, + name: "Legacy Photo Path" + ) + + let encoder = JSONEncoder() + userDefaults.set(try encoder.encode([oldPath]), forKey: "RecordedPaths") + + let migration = DataMigration(userDefaults: userDefaults) + migration.runMigrations() + + let migratedData = userDefaults.data(forKey: "RecordedPaths") + XCTAssertNotNil(migratedData) + + let decoder = JSONDecoder() + let migratedPaths = try decoder.decode([RecordedPath].self, from: migratedData!) + let migratedPhoto = migratedPaths[0].photos.first(where: { $0.imageFilename == "segmentPhoto.jpg" }) + + XCTAssertNotNil(migratedPhoto, "The location photo should still exist after migration") + XCTAssertEqual(migratedPhoto?.locationId, location.id) + } + +} + +private struct RecordedPathOldTest: Codable { + let id: UUID + let startTime: Date? + let totalDuration: TimeInterval? + let totalDistance: Double? + let locations: [GPSLocationOldTest] + let photos: [PathPhoto]? + let name: String +} + +private struct GPSLocationOldTest: Codable { + let id: UUID + let latitude: Double + let longitude: Double + let timestamp: Date + let segmentId: UUID? + let photos: [PathPhoto]? +} diff --git a/PathRecorderWidget/PathRecorderWidgetControl.swift b/PathRecorderWidget/PathRecorderWidgetControl.swift index 4fbb3a7..2e0cd85 100644 --- a/PathRecorderWidget/PathRecorderWidgetControl.swift +++ b/PathRecorderWidget/PathRecorderWidgetControl.swift @@ -23,7 +23,7 @@ struct PathRecorderWidgetControl: ControlWidget { isOn: value.isRunning, action: StartTimerIntent(value.name) ) { isRunning in - Label(isRunning ? "On" : "Off", systemImage: "timer") + Label(isRunning ? "On" : "Off", systemImage: "alarm") } } .displayName("Timer") diff --git a/PathRecorderWidget/PathRecorderWidgetLiveActivity.swift b/PathRecorderWidget/PathRecorderWidgetLiveActivity.swift index 595ba24..07d9be8 100644 --- a/PathRecorderWidget/PathRecorderWidgetLiveActivity.swift +++ b/PathRecorderWidget/PathRecorderWidgetLiveActivity.swift @@ -25,7 +25,7 @@ struct PathRecorderWidgetLiveActivity: Widget { .font(.headline) .foregroundColor(.primary) - HStack { + /*HStack { Label { Text(String(format: "%.6f, %.6f", context.state.latitude, @@ -35,7 +35,7 @@ struct PathRecorderWidgetLiveActivity: Widget { Image(systemName: "location.fill") .foregroundColor(.blue) } - } + }*/ HStack(spacing: 15) { Label { @@ -50,7 +50,7 @@ struct PathRecorderWidgetLiveActivity: Widget { Text(formatTime(context.state.elapsedTime)) .bold() } icon: { - Image(systemName: "timer") + Image(systemName: "alarm") .foregroundColor(.orange) } } @@ -61,7 +61,14 @@ struct PathRecorderWidgetLiveActivity: Widget { .foregroundColor(.orange) .fontWeight(.bold) .padding(.top, 2) - } + }/* else { + HStack { + Image(systemName: "timer") + .foregroundColor(.blue) + .font(.subheadline) + Text(context.state.pace) + } + }*/ } .padding() .multilineTextAlignment(.center) @@ -80,7 +87,7 @@ struct PathRecorderWidgetLiveActivity: Widget { DynamicIslandExpandedRegion(.trailing) { Label(formatTime(context.state.elapsedTime), - systemImage: "timer") + systemImage: "alarm") .foregroundColor(.orange) } @@ -150,7 +157,8 @@ struct PathRecorderLiveActivity_Previews: PreviewProvider { longitude: -122.03031, distance: 1234, elapsedTime: 3600, - isPaused: false + isPaused: false, + distanceUnit: "km" ) static var previews: some View { diff --git a/Shared/PathRecorderAttributes.swift b/Shared/PathRecorderAttributes.swift index f3202dd..ba4ebeb 100644 --- a/Shared/PathRecorderAttributes.swift +++ b/Shared/PathRecorderAttributes.swift @@ -11,6 +11,7 @@ public struct PathRecorderAttributes: ActivityAttributes { public var elapsedTime: TimeInterval public var isPaused: Bool public var distanceUnit: String // "km" or "mi" + // public var pace: String public init(latitude: Double, longitude: Double, distance: Double, elapsedTime: TimeInterval, isPaused: Bool = false, distanceUnit: String = "km") { self.latitude = latitude @@ -19,6 +20,7 @@ public struct PathRecorderAttributes: ActivityAttributes { self.elapsedTime = elapsedTime self.isPaused = isPaused self.distanceUnit = distanceUnit + // self.pace = computePace(distanceMeters: distance, elapsedSeconds: elapsedTime, unit: distanceUnit) } } @@ -33,3 +35,21 @@ public func formatTime(_ timeInterval: TimeInterval) -> String { return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } +/// Computes pace per mile or km (minutes per unit) given distance in meters and elapsed time in seconds. +/// - Parameters: +/// - distanceMeters: Distance in meters +/// - elapsedSeconds: Elapsed time in seconds +/// - unit: "km" or "mi" +/// - Returns: Pace as a formatted string "mm:ss /unit" +public func computePace(distanceMeters: Double, elapsedSeconds: TimeInterval, unit: String) -> String { + guard distanceMeters > 0 else { return "--:-- /" + unit } + let metersPerUnit: Double = (unit == "mi") ? 1609.34 : 1000.0 + let units = distanceMeters / metersPerUnit + guard units > 0 else { return "--:-- /" + unit } + let paceSeconds = elapsedSeconds / units + let paceMinutes = Int(paceSeconds) / 60 + let paceRemainderSeconds = Int(paceSeconds) % 60 + return String(format: "%02d:%02d /%@", paceMinutes, paceRemainderSeconds, unit) +} + + diff --git a/supabase/migrations/20260621000000_initial_schema.sql b/supabase/migrations/20260621000000_initial_schema.sql new file mode 100644 index 0000000..7bb486d --- /dev/null +++ b/supabase/migrations/20260621000000_initial_schema.sql @@ -0,0 +1,103 @@ +-- PathRecorder initial schema +-- Mirrors the local model: RecordedPath > PathSegment > GPSLocation, PathPhoto + +-- ============================================================ +-- Tables +-- ============================================================ + +create table if not exists paths ( + id uuid primary key, + user_id uuid not null references auth.users(id) on delete cascade, + name text not null, + created_at timestamptz not null default now() +); + +create table if not exists path_segments ( + id uuid primary key, + path_id uuid not null references paths(id) on delete cascade +); + +create table if not exists gps_locations ( + id uuid primary key, + segment_id uuid not null references path_segments(id) on delete cascade, + latitude double precision not null, + longitude double precision not null, + timestamp timestamptz not null +); + +create table if not exists path_photos ( + id uuid primary key, + user_id uuid not null references auth.users(id) on delete cascade, + location_id uuid not null references gps_locations(id) on delete cascade, + timestamp timestamptz not null, + storage_path text not null -- key into the 'path-photos' Storage bucket +); + +-- ============================================================ +-- Indexes +-- ============================================================ + +create index if not exists path_segments_path_id_idx on path_segments(path_id); +create index if not exists gps_locations_segment_id_idx on gps_locations(segment_id); +create index if not exists path_photos_location_id_idx on path_photos(location_id); + +-- ============================================================ +-- Row-Level Security +-- ============================================================ + +alter table paths enable row level security; +alter table path_segments enable row level security; +alter table gps_locations enable row level security; +alter table path_photos enable row level security; + +create policy "users manage own paths" + on paths for all + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +create policy "users manage own segments" + on path_segments for all + using ( + path_id in (select id from paths where user_id = auth.uid()) + ) + with check ( + path_id in (select id from paths where user_id = auth.uid()) + ); + +create policy "users manage own locations" + on gps_locations for all + using ( + segment_id in ( + select ps.id from path_segments ps + join paths p on p.id = ps.path_id + where p.user_id = auth.uid() + ) + ) + with check ( + segment_id in ( + select ps.id from path_segments ps + join paths p on p.id = ps.path_id + where p.user_id = auth.uid() + ) + ); + +create policy "users manage own photos" + on path_photos for all + using (auth.uid() = user_id) + with check (auth.uid() = user_id); + +-- ============================================================ +-- Storage bucket for photo binaries +-- ============================================================ +-- Run this once in the Supabase dashboard or via the Management API, +-- since storage buckets cannot be created in SQL migrations: +-- +-- insert into storage.buckets (id, name, public) +-- values ('path-photos', 'path-photos', false); +-- +-- create policy "users manage own photos" +-- on storage.objects for all +-- using (bucket_id = 'path-photos' and auth.uid()::text = (storage.foldername(name))[1]) +-- with check (bucket_id = 'path-photos' and auth.uid()::text = (storage.foldername(name))[1]); +-- +-- Objects are stored at: {user_id}/{photo_id}.jpg diff --git a/supabase/migrations/20260621000002_storage_setup.sql b/supabase/migrations/20260621000002_storage_setup.sql new file mode 100644 index 0000000..0ddedb4 --- /dev/null +++ b/supabase/migrations/20260621000002_storage_setup.sql @@ -0,0 +1,16 @@ +-- Create private storage bucket for path photos +insert into storage.buckets (id, name, public) +values ('path-photos', 'path-photos', false) +on conflict (id) do nothing; + +-- Users can only access objects under their own user_id folder +create policy "users manage own photos" + on storage.objects for all + using ( + bucket_id = 'path-photos' + and auth.uid()::text = (storage.foldername(name))[1] + ) + with check ( + bucket_id = 'path-photos' + and auth.uid()::text = (storage.foldername(name))[1] + );