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]
+ );