From 99079e124d3f1e02d238d7d1b9f51e76571544ac Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 27 Jul 2025 19:55:46 -0400 Subject: [PATCH 01/42] add settings option to change path color --- PathRecorder/ContentView.swift | 37 +----- .../MapComponents/LiveMapViewController.swift | 4 +- .../MapComponents/MapRenderingHelpers.swift | 124 ++++++++++-------- .../MapComponents/MapWithPolylines.swift | 2 +- PathRecorder/Settings.swift | 83 +++++++++++- 5 files changed, 151 insertions(+), 99 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index ee793e2..0675a04 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -218,39 +218,4 @@ struct RecordedPathRow: View { Text("To record your path, please allow location access in Settings.") } } -} - -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()) -} +} \ No newline at end of file diff --git a/PathRecorder/MapComponents/LiveMapViewController.swift b/PathRecorder/MapComponents/LiveMapViewController.swift index 39d3692..73793ea 100644 --- a/PathRecorder/MapComponents/LiveMapViewController.swift +++ b/PathRecorder/MapComponents/LiveMapViewController.swift @@ -148,7 +148,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 +169,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/MapRenderingHelpers.swift b/PathRecorder/MapComponents/MapRenderingHelpers.swift index b23a141..ad39c64 100644 --- a/PathRecorder/MapComponents/MapRenderingHelpers.swift +++ b/PathRecorder/MapComponents/MapRenderingHelpers.swift @@ -1,60 +1,70 @@ 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 mapUIColor() -> UIColor { + if let hex = UserDefaults.standard.string(forKey: "mapColor"), + let color = Color.fromHexString(hex) { + return UIColor(color) } + return UIColor.blue + } + + 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 = mapUIColor() + 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 polylineRenderer(for overlay: MKOverlay) -> MKOverlayRenderer { if let polyline = overlay as? MKPolyline { let renderer = MKPolylineRenderer(polyline: polyline) - renderer.strokeColor = UIColor.blue + renderer.strokeColor = mapUIColor() renderer.lineWidth = polylineWidth renderer.lineCap = .round renderer.lineJoin = .round @@ -62,37 +72,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 = mapUIColor().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 = mapUIColor().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 = mapUIColor().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/MapWithPolylines.swift b/PathRecorder/MapComponents/MapWithPolylines.swift index dc7e1e1..ee59086 100644 --- a/PathRecorder/MapComponents/MapWithPolylines.swift +++ b/PathRecorder/MapComponents/MapWithPolylines.swift @@ -113,7 +113,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 diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index 68af276..48ba0a7 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI enum DistanceUnit: String, CaseIterable, Codable { case kilometers = "km" @@ -38,7 +39,18 @@ class Settings: ObservableObject { UserDefaults.standard.set(distanceUnit.rawValue, forKey: "distanceUnit") } } - + + @Published var mapColor: Color { + didSet { + // Store as hex string + UserDefaults.standard.set(mapColor.toHexString(), forKey: "mapColor") + } + } + + var mapColorUIColor: UIColor { + UIColor(mapColor) + } + init() { if let savedUnit = UserDefaults.standard.string(forKey: "distanceUnit"), let unit = DistanceUnit(rawValue: savedUnit) { @@ -46,14 +58,79 @@ class Settings: ObservableObject { } else { self.distanceUnit = .kilometers } + + if let savedColorHex = UserDefaults.standard.string(forKey: "mapColor"), + let color = Color.fromHexString(savedColorHex) { + self.mapColor = color + } else { + self.mapColor = .blue + } } 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 + @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()) + } + Section(header: Text("Map Path Color")) { + ColorPicker("Path Color", selection: $settings.mapColor, supportsOpacity: false) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} + +// 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) + } +} + From fe9f678a6b890aa71588c82ae633f51b51143178 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 27 Jul 2025 20:43:36 -0400 Subject: [PATCH 02/42] display pace --- PathRecorder/ContentView.swift | 9 +++++++- PathRecorder/RecordingView.swift | 21 +++++++++++++------ .../PathRecorderWidgetControl.swift | 2 +- .../PathRecorderWidgetLiveActivity.swift | 20 ++++++++++++------ Shared/PathRecorderAttributes.swift | 20 ++++++++++++++++++ 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index 0675a04..a183515 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -8,6 +8,7 @@ import SwiftUI import SwiftData import CoreLocation +import Shared // Import the module if needed struct ContentView: View { @StateObject private var locationManager = LocationManager() @@ -180,11 +181,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) diff --git a/PathRecorder/RecordingView.swift b/PathRecorder/RecordingView.swift index 9c2102c..734abc8 100644 --- a/PathRecorder/RecordingView.swift +++ b/PathRecorder/RecordingView.swift @@ -6,6 +6,7 @@ import SwiftUI import MapKit +import Shared struct RecordingView: View { @ObservedObject var locationManager: LocationManager @@ -31,11 +32,11 @@ struct RecordingView: View { .fontWeight(.bold) } } - VStack(alignment: .leading, spacing: 10) { - if let location = locationManager.currentLocation { + VStack(alignment: .center, spacing: 10) { + /*if let location = locationManager.currentLocation { Text("GPS: \(String(format: "%.6f", location.coordinate.latitude)), \(String(format: "%.6f", location.coordinate.longitude))") - } - HStack(spacing: 20) { + }*/ + HStack(spacing: 10) { HStack(spacing: 10) { Image(systemName: "figure.walk") .foregroundColor(.green) @@ -44,13 +45,21 @@ struct RecordingView: View { } if locationManager.elapsedTime > 0 { HStack(spacing: 10) { - Image(systemName: "timer") + Image(systemName: "alarm") .foregroundColor(.orange) .font(.subheadline) Text(formatTime(locationManager.elapsedTime)) } } } + 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) @@ -112,4 +121,4 @@ struct RecordingView: View { let seconds = Int(timeInterval) % 60 return String(format: "%02d:%02d:%02d", hours, minutes, seconds) } -} \ No newline at end of file +} 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) +} + + From ad9d4eaa798608937bd92370c3558db7cd67e510 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 29 Jul 2025 17:00:46 -0400 Subject: [PATCH 03/42] increment version to 1.1 --- PathRecorder.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 3a6a589..39cf37e 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -588,7 +588,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -687,7 +687,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -715,7 +715,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From 77f29f709dda2f5139bfe11bc011ed5b155cd1b1 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 29 Jul 2025 17:39:23 -0400 Subject: [PATCH 04/42] switch from using apple's uiimagepickercontroller to custom camera implementation via avfoundation to support autosaving photos when app is backgrounded --- PathRecorder/MapComponents/CameraView.swift | 607 ++++++++++++++++-- .../MapComponents/LivePathMapView.swift | 2 +- 2 files changed, 562 insertions(+), 47 deletions(-) diff --git a/PathRecorder/MapComponents/CameraView.swift b/PathRecorder/MapComponents/CameraView.swift index f48d6fb..b5c6cc5 100644 --- a/PathRecorder/MapComponents/CameraView.swift +++ b/PathRecorder/MapComponents/CameraView.swift @@ -1,70 +1,585 @@ import SwiftUI +import AVFoundation 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 +// 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 + var onImageCaptured: (UIImage) -> Void - func makeCoordinator() -> Coordinator { - Coordinator(self) + @State private var previewImage: UIImage? + @State private var showZoomSlider = false + + var body: some View { + ZStack { + if let image = previewImage { + VStack { + HStack { + Button(action: { + previewImage = nil + cameraService.clearPendingPhoto() // Clear pending photo when retaking + isPresented = false + }) { + Image(systemName: "xmark") + .font(.system(size: 24)) + .foregroundColor(.white) + .padding() + } + Spacer() + } + Spacer() + } + VStack { + Image(uiImage: image) + .resizable() + .scaledToFit() + .ignoresSafeArea() + } + VStack { + Spacer() + HStack { + Button("Retake") { + previewImage = nil + cameraService.clearPendingPhoto() // Clear pending photo when retaking + cameraService.restartSession() // Properly restart the session + } + .padding() + .foregroundColor(.white) + Spacer() + Button("Use Photo") { + cameraService.confirmCapturedPhoto() + } + .padding() + .foregroundColor(.white) + } + .background(Color.black.opacity(0.6)) + } + } 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() + } } - func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { - // noop, isPresented manages visibility of the camera + // 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" + } } +} - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - @objc func handleAppDidEnterBackground() { - print("[CameraView] App sent to background, dismissing camera.") - DispatchQueue.main.async { - self.parent.isPresented = false +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 } } - let parent: CameraView - init(_ parent: CameraView) { - self.parent = parent + } + + 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 } - 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.") + + 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 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.parent.isPresented = false + 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() } } + } - // Helper to fix image orientation for landscape photos - static func fixOrientation(_ image: UIImage) -> UIImage { - if image.imageOrientation == .up { - return image + 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() } - 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 + } + + 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 + } + + // Set the orientation for the photo + if let connection = output.connection(with: .video) { + 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/LivePathMapView.swift b/PathRecorder/MapComponents/LivePathMapView.swift index e0ae040..252d236 100644 --- a/PathRecorder/MapComponents/LivePathMapView.swift +++ b/PathRecorder/MapComponents/LivePathMapView.swift @@ -146,7 +146,7 @@ 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 location = locationManager.currentLocation { let filename = "photo_\(UUID().uuidString).jpg" let photo = PathPhoto( coordinate: location.coordinate, From 1e80c5a74b01a1c84aefdd230b4487d15493b307 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 29 Jul 2025 19:11:11 -0400 Subject: [PATCH 05/42] when editing an existing path, we should immediately start recording again --- PathRecorder/LocationManager.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/PathRecorder/LocationManager.swift b/PathRecorder/LocationManager.swift index 8941b03..766b5b0 100644 --- a/PathRecorder/LocationManager.swift +++ b/PathRecorder/LocationManager.swift @@ -438,11 +438,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") } From 645a346b6b95e9c460afa678e2a6bef585dd1293 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Wed, 30 Jul 2025 10:15:34 -0400 Subject: [PATCH 06/42] organize map components between live and static views --- PathRecorder/MapComponents/Double+Extensions.swift | 5 ----- PathRecorder/MapComponents/{ => LiveMap}/CameraView.swift | 0 .../MapComponents/{ => LiveMap}/LiveMapViewController.swift | 0 .../{ => LiveMap}/LiveMapViewControllerRepresentable.swift | 0 .../MapComponents/{ => LiveMap}/LivePathMapView.swift | 0 .../MapComponents/{ => StaticMap}/MapWithPolylines.swift | 0 .../MapComponents/{ => StaticMap}/PathEditingSheet.swift | 0 PathRecorder/MapComponents/{ => StaticMap}/PathMapView.swift | 0 PathRecorder/MapComponents/{ => StaticMap}/PathSegment.swift | 0 .../{ => StaticMap}/PhotoAssociationConfirmationSheet.swift | 0 .../MapComponents/{ => StaticMap}/PhotoLibraryPicker.swift | 0 .../MapComponents/{ => StaticMap}/PhotoPagerView.swift | 0 12 files changed, 5 deletions(-) delete mode 100644 PathRecorder/MapComponents/Double+Extensions.swift rename PathRecorder/MapComponents/{ => LiveMap}/CameraView.swift (100%) rename PathRecorder/MapComponents/{ => LiveMap}/LiveMapViewController.swift (100%) rename PathRecorder/MapComponents/{ => LiveMap}/LiveMapViewControllerRepresentable.swift (100%) rename PathRecorder/MapComponents/{ => LiveMap}/LivePathMapView.swift (100%) rename PathRecorder/MapComponents/{ => StaticMap}/MapWithPolylines.swift (100%) rename PathRecorder/MapComponents/{ => StaticMap}/PathEditingSheet.swift (100%) rename PathRecorder/MapComponents/{ => StaticMap}/PathMapView.swift (100%) rename PathRecorder/MapComponents/{ => StaticMap}/PathSegment.swift (100%) rename PathRecorder/MapComponents/{ => StaticMap}/PhotoAssociationConfirmationSheet.swift (100%) rename PathRecorder/MapComponents/{ => StaticMap}/PhotoLibraryPicker.swift (100%) rename PathRecorder/MapComponents/{ => StaticMap}/PhotoPagerView.swift (100%) 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/CameraView.swift b/PathRecorder/MapComponents/LiveMap/CameraView.swift similarity index 100% rename from PathRecorder/MapComponents/CameraView.swift rename to PathRecorder/MapComponents/LiveMap/CameraView.swift diff --git a/PathRecorder/MapComponents/LiveMapViewController.swift b/PathRecorder/MapComponents/LiveMap/LiveMapViewController.swift similarity index 100% rename from PathRecorder/MapComponents/LiveMapViewController.swift rename to PathRecorder/MapComponents/LiveMap/LiveMapViewController.swift 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 100% rename from PathRecorder/MapComponents/LivePathMapView.swift rename to PathRecorder/MapComponents/LiveMap/LivePathMapView.swift diff --git a/PathRecorder/MapComponents/MapWithPolylines.swift b/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift similarity index 100% rename from PathRecorder/MapComponents/MapWithPolylines.swift rename to PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift 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 100% rename from PathRecorder/MapComponents/PathMapView.swift rename to PathRecorder/MapComponents/StaticMap/PathMapView.swift diff --git a/PathRecorder/MapComponents/PathSegment.swift b/PathRecorder/MapComponents/StaticMap/PathSegment.swift similarity index 100% rename from PathRecorder/MapComponents/PathSegment.swift rename to PathRecorder/MapComponents/StaticMap/PathSegment.swift 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/PhotoLibraryPicker.swift b/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift similarity index 100% rename from PathRecorder/MapComponents/PhotoLibraryPicker.swift rename to PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift diff --git a/PathRecorder/MapComponents/PhotoPagerView.swift b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift similarity index 100% rename from PathRecorder/MapComponents/PhotoPagerView.swift rename to PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift From 1a67c56f03c3fd12080d393e768b313792e91740 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Wed, 30 Jul 2025 10:50:27 -0400 Subject: [PATCH 07/42] improve captured photo view --- .../MapComponents/LiveMap/CameraView.swift | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/PathRecorder/MapComponents/LiveMap/CameraView.swift b/PathRecorder/MapComponents/LiveMap/CameraView.swift index b5c6cc5..92b7180 100644 --- a/PathRecorder/MapComponents/LiveMap/CameraView.swift +++ b/PathRecorder/MapComponents/LiveMap/CameraView.swift @@ -27,35 +27,13 @@ struct CameraView: View { var body: some View { ZStack { if let image = previewImage { - VStack { - HStack { - Button(action: { - previewImage = nil - cameraService.clearPendingPhoto() // Clear pending photo when retaking - isPresented = false - }) { - Image(systemName: "xmark") - .font(.system(size: 24)) - .foregroundColor(.white) - .padding() - } - Spacer() - } - Spacer() - } - VStack { - Image(uiImage: image) - .resizable() - .scaledToFit() - .ignoresSafeArea() - } VStack { Spacer() HStack { Button("Retake") { previewImage = nil - cameraService.clearPendingPhoto() // Clear pending photo when retaking - cameraService.restartSession() // Properly restart the session + cameraService.clearPendingPhoto() + cameraService.restartSession() } .padding() .foregroundColor(.white) @@ -68,6 +46,12 @@ struct CameraView: View { } .background(Color.black.opacity(0.6)) } + .background( + Image(uiImage: image) + .resizable() + .scaledToFit() + .ignoresSafeArea() + ) } else { CameraPreview( session: cameraService.session, From ed623a6c63a91edc60fbdbb15dd2078d3d6bad9d Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 12 Aug 2025 19:22:32 -0400 Subject: [PATCH 08/42] add an option to display all photos taken in a grid --- .../MapComponents/StaticMap/PathMapView.swift | 12 +++- .../StaticMap/PhotoGridView.swift | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 PathRecorder/MapComponents/StaticMap/PhotoGridView.swift diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index c4a5166..a808f2c 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -54,6 +54,7 @@ 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 @@ -89,7 +90,15 @@ struct PathMapView: View { .navigationTitle(currentPath.name) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { + ToolbarItemGroup(placement: .navigationBarTrailing) { + NavigationLink(destination: PhotoGridView(photos: currentPath.photos), isActive: $showPhotoGrid) { + EmptyView() + } + Button(action: { + showPhotoGrid = true + }) { + Image(systemName: "photo.on.rectangle") + } Button(action: { showEditingSheet = true }) { @@ -97,6 +106,7 @@ struct PathMapView: View { } } } + // Removed sheet for all photos; now uses navigation to PhotoGridView .onAppear { if showRenameSheetOnAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { diff --git a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift new file mode 100644 index 0000000..22fda2a --- /dev/null +++ b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct PhotoGridView: View { + let photos: [PathPhoto] + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(photos) { photo in + if let image = photo.uiImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: gridItemSize, height: gridItemSize) + .clipped() + .cornerRadius(8) + } + } + } + .padding(8) + } + .navigationTitle("All Photos") + .navigationBarTitleDisplayMode(.inline) + // No custom toolbar; use default back button only + } + + private var gridItemSize: CGFloat { + let screenWidth = UIScreen.main.bounds.width + return (screenWidth - 32) / 3 // 3 columns, 8pt spacing, 8pt padding + } +} + +// 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 + } +} From b25c37c2a6c48df3fe6ba6706e6a86e45ccbdb8a Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 12 Aug 2025 20:40:53 -0400 Subject: [PATCH 09/42] simplify delete and dismiss function --- .../MapComponents/StaticMap/PathMapView.swift | 28 ++----------------- .../StaticMap/PhotoPagerView.swift | 20 +++++++++++-- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index a808f2c..3a0b082 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -169,32 +169,8 @@ struct PathMapView: View { 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) - } - } - } - } + pathStorage: pathStorage, + pathId: recordedPath.id ) } else { Text("No photos at this location.") diff --git a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift index f8f7735..7b7ea84 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift @@ -2,13 +2,15 @@ import SwiftUI import Photos struct PhotoPagerView: View { + @Environment(\.dismiss) private var dismiss 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 + @ObservedObject var pathStorage: PathStorage + let pathId: UUID var body: some View { Group { @@ -96,7 +98,7 @@ struct PhotoPagerView: View { Button("Delete", role: .destructive) { if selectedIndex < photos.count { let photoToDelete = photos[selectedIndex] - onDeletePhoto(photoToDelete) + deletePhoto(photoToDelete) } } Button("Cancel", role: .cancel) { } @@ -126,6 +128,20 @@ struct PhotoPagerView: View { } } + private func deletePhoto(_ photo: PathPhoto) { + // Get the current path from storage + if var currentPath = pathStorage.path(for: pathId) { + // Remove photo from the path + currentPath.deletePhoto(photo) + + // Update the stored path + pathStorage.updatePath(currentPath) + + // Close the photo pager sheet + dismiss() + } + } + private func saveImageToPhotos(_ image: UIImage) { let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) switch status { From 32a5933bcfffcf02dfe43562dd2ac326861a666c Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 12 Aug 2025 22:32:28 -0400 Subject: [PATCH 10/42] update photo viewing and management infrastructure --- .../MapComponents/StaticMap/PathMapView.swift | 42 ++-- .../StaticMap/PhotoGridView.swift | 175 ++++++++++++++- .../StaticMap/PhotoPagerView.swift | 200 +++++++++--------- 3 files changed, 290 insertions(+), 127 deletions(-) diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index 3a0b082..396e94e 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -91,7 +91,7 @@ struct PathMapView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - NavigationLink(destination: PhotoGridView(photos: currentPath.photos), isActive: $showPhotoGrid) { + NavigationLink(destination: PhotoGridView(photos: currentPath.photos, pathStorage: pathStorage, pathId: recordedPath.id), isActive: $showPhotoGrid) { EmptyView() } Button(action: { @@ -106,6 +106,30 @@ 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 { @@ -161,22 +185,6 @@ struct PathMapView: View { } } } - .sheet(isPresented: Binding( - get: { selectedPhotos != nil }, - set: { if !$0 { selectedPhotos = nil } } - )) { - if let photos = selectedPhotos { - PhotoPagerView( - photos: photos, - selectedIndex: $selectedPhotoIndex, - pathStorage: pathStorage, - pathId: recordedPath.id - ) - } else { - Text("No photos at this location.") - .padding() - } - } .sheet(isPresented: Binding(get: { !showEditingSheet && showAssociationAlert && associatedCount > 0 }, set: { show in showAssociationAlert = show })) { PhotoAssociationConfirmationSheet( associatedCount: associatedCount, diff --git a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift index 22fda2a..ba9e3bd 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift @@ -1,7 +1,11 @@ import SwiftUI +import Photos +import CoreLocation struct PhotoGridView: View { let photos: [PathPhoto] + @ObservedObject var pathStorage: PathStorage + let pathId: UUID let columns = [ GridItem(.flexible()), GridItem(.flexible()), @@ -9,18 +13,35 @@ struct PhotoGridView: View { ] @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(photos) { photo in + ForEach(Array(photos.enumerated()), id: \.element.id) { index, photo in if let image = photo.uiImage { - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: gridItemSize, height: gridItemSize) - .clipped() - .cornerRadius(8) + NavigationLink( + destination: PhotoPagerView( + photos: photos, + selectedIndex: $selectedPhotoIndex, + pathStorage: pathStorage, + pathId: pathId + ) + ) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: gridItemSize, height: gridItemSize) + .clipped() + .cornerRadius(8) + } + .simultaneousGesture( + TapGesture().onEnded { + selectedPhotoIndex = index + } + ) } } } @@ -28,13 +49,151 @@ struct PhotoGridView: View { } .navigationTitle("All Photos") .navigationBarTitleDisplayMode(.inline) - // No custom toolbar; use default back button only + .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 and location + assetRequest.creationDate = photo.timestamp + assetRequest.location = CLLocation(latitude: photo.coordinate.latitude, longitude: photo.coordinate.longitude) + + 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 diff --git a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift index 7b7ea84..1e96425 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift @@ -1,14 +1,16 @@ import SwiftUI import Photos +import CoreLocation +import ImageIO +import UniformTypeIdentifiers struct PhotoPagerView: View { @Environment(\.dismiss) private var dismiss - let photos: [PathPhoto] // Replace with your actual model type + @State var photos: [PathPhoto] @Binding var selectedIndex: Int @State private var showShareSheet = false @State private var imageToShare: ShareImage? @State private var showDeleteAlert = false - @State private var showPhotoLibraryAlert = false @ObservedObject var pathStorage: PathStorage let pathId: UUID @@ -18,79 +20,68 @@ struct PhotoPagerView: View { 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") - } + 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() + } else { + Text("Photo unavailable") } - .frame(maxHeight: .infinity) - .tag(idx) } + .frame(maxHeight: .infinity) + .tag(idx) } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) - .frame(maxHeight: .infinity) } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) .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) + } + .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 } - .padding() + }) { + Image(systemName: "square.and.arrow.up") + .foregroundColor(.blue) + } + + Button(action: { + showDeleteAlert = true + }) { + Image(systemName: "trash") + .foregroundColor(.blue) } } } @@ -116,16 +107,6 @@ struct PhotoPagerView: View { } } } - .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 deletePhoto(_ photo: PathPhoto) { @@ -133,35 +114,50 @@ struct PhotoPagerView: View { if var currentPath = pathStorage.path(for: pathId) { // Remove photo from the path currentPath.deletePhoto(photo) - + // Remove photo from local photos array + photos.removeAll { $0.id == photo.id } // Update the stored path pathStorage.updatePath(currentPath) - - // Close the photo pager sheet + } + + // 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 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 - } + 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 + let metadata: [String: Any] = [ + kCGImagePropertyExifDictionary as String: [ + kCGImagePropertyExifDateTimeOriginal as String: ISO8601DateFormatter().string(from: photo.timestamp), + kCGImagePropertyExifDateTimeDigitized as String: ISO8601DateFormatter().string(from: photo.timestamp) + ], + kCGImagePropertyGPSDictionary as String: [ + kCGImagePropertyGPSLatitude as String: abs(photo.coordinate.latitude), + kCGImagePropertyGPSLatitudeRef as String: photo.coordinate.latitude >= 0 ? "N" : "S", + kCGImagePropertyGPSLongitude as String: abs(photo.coordinate.longitude), + kCGImagePropertyGPSLongitudeRef as String: photo.coordinate.longitude >= 0 ? "E" : "W", + kCGImagePropertyGPSTimeStamp 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 From 9c2c6feac35932fffda8a73ccf49c0d8286fa654 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 12 Aug 2025 23:20:01 -0400 Subject: [PATCH 11/42] upgrade app version to 1.2 --- PathRecorder.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 39cf37e..fe44686 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -588,7 +588,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -687,7 +687,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -715,7 +715,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From 913b5f36fdff352f1c46215396aaaf859543dafa Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 12 Aug 2025 23:34:33 -0400 Subject: [PATCH 12/42] always navigate to path after stopping recording, conditionally show rename sheet if its a new recording --- PathRecorder/ContentView.swift | 4 ++-- PathRecorder/LocationManager.swift | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index a183515..50110a2 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -89,11 +89,11 @@ struct ContentView: View { showRecordingSheet = true } } - .onReceive(locationManager.$pathNeedingRename) { path in + .onReceive(locationManager.$pathToNavigateTo) { path in if let path = path { selectedPathForRename = path navigationPath.append(path) - showRenameSheet = true + showRenameSheet = locationManager.shouldShowRenameSheet } } .fullScreenCover(isPresented: Binding( diff --git a/PathRecorder/LocationManager.swift b/PathRecorder/LocationManager.swift index 766b5b0..8baeac5 100644 --- a/PathRecorder/LocationManager.swift +++ b/PathRecorder/LocationManager.swift @@ -22,7 +22,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 shouldShowRenameSheet = false // Control whether rename sheet appears // Properties for improved distance calculation private var lastProcessedTime: Date? @@ -464,11 +465,10 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { 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 + self.shouldShowRenameSheet = (self.editingPathName == nil) } } From b363a13af2e5eaef29d03322ade6e920c6e2a5b6 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 12 Aug 2025 23:54:07 -0400 Subject: [PATCH 13/42] conditionally hide photo viewer if there are no photos associated with a path --- .../MapComponents/StaticMap/PathMapView.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index 396e94e..a06ebfc 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -91,13 +91,15 @@ struct PathMapView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - NavigationLink(destination: PhotoGridView(photos: currentPath.photos, pathStorage: pathStorage, pathId: recordedPath.id), isActive: $showPhotoGrid) { - EmptyView() - } - Button(action: { - showPhotoGrid = true - }) { - Image(systemName: "photo.on.rectangle") + 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 From 20b6a49bcfcef413f0460540af3604da5b1bc2c9 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Wed, 13 Aug 2025 15:26:53 -0400 Subject: [PATCH 14/42] improve robustnuss of delete functionality --- .../StaticMap/PhotoGridView.swift | 18 +++++++++++++++++- .../StaticMap/PhotoPagerView.swift | 14 +++++--------- PathRecorder/RecordedPath.swift | 16 ++++++++++------ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift index ba9e3bd..cc8b9a7 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift @@ -3,9 +3,15 @@ import Photos import CoreLocation struct PhotoGridView: View { - let photos: [PathPhoto] + @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()), @@ -29,6 +35,16 @@ struct PhotoGridView: View { 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() diff --git a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift index 1e96425..814524c 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift @@ -110,15 +110,11 @@ struct PhotoPagerView: View { } private func deletePhoto(_ photo: PathPhoto) { - // Get the current path from storage - if var currentPath = pathStorage.path(for: pathId) { - // Remove photo from the path - currentPath.deletePhoto(photo) - // Remove photo from local photos array - photos.removeAll { $0.id == photo.id } - // Update the stored path - pathStorage.updatePath(currentPath) - } + // 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 { diff --git a/PathRecorder/RecordedPath.swift b/PathRecorder/RecordedPath.swift index 92455b3..d0e269e 100644 --- a/PathRecorder/RecordedPath.swift +++ b/PathRecorder/RecordedPath.swift @@ -37,12 +37,6 @@ struct RecordedPath: Identifiable, Codable, Hashable { 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 { @@ -93,6 +87,16 @@ class PathStorage: ObservableObject { saveToUserDefaults() } } + + func deletePhoto(from pathId: UUID, photo: PathPhoto) { + if let index = recordedPaths.firstIndex(where: { $0.id == pathId }) { + recordedPaths[index].photos.removeAll { $0.id == photo.id } + // Delete image file from disk + let url = PathPhoto.imagesDirectory.appendingPathComponent(photo.imageFilename) + try? FileManager.default.removeItem(at: url) + saveToUserDefaults() + } + } private func saveToUserDefaults() { if let encoded = try? JSONEncoder().encode(recordedPaths) { From e13c244ea64d95050fc2b712f38af5e364b403f5 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Thu, 21 Aug 2025 12:17:35 -0400 Subject: [PATCH 15/42] add ability to sort based on path date, pace, time, or distance --- PathRecorder/ContentView.swift | 88 +++++++++++++++++-- .../LiveMap/LivePathMapView.swift | 1 - 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index 50110a2..c30eb2a 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -11,6 +11,19 @@ import CoreLocation import Shared // Import the module if needed struct ContentView: View { + // 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" + } + } @StateObject private var locationManager = LocationManager() @StateObject private var pathStorage = PathStorage() @StateObject private var settings = Settings() @@ -21,17 +34,51 @@ struct ContentView: View { @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: { @@ -51,7 +98,7 @@ struct ContentView: View { } } .listStyle(.plain) - + Button(action: { if locationManager.authorizationStatus == .authorizedAlways || locationManager.authorizationStatus == .authorizedWhenInUse { locationManager.startRecording() @@ -127,7 +174,7 @@ struct ContentView: View { SettingsView(settings: settings) } .navigationDestination(for: RecordedPath.self) { path in - let view = PathMapView( + PathMapView( recordedPath: path, locationManager: locationManager, pathStorage: pathStorage, @@ -136,12 +183,39 @@ struct ContentView: View { 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 @@ -225,4 +299,4 @@ struct RecordedPathRow: View { Text("To record your path, please allow location access in Settings.") } } -} \ No newline at end of file +} diff --git a/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift index 252d236..3c3cd2c 100644 --- a/PathRecorder/MapComponents/LiveMap/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 From e151553a8b03928dfb55926b087c946aa48af468 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Thu, 21 Aug 2025 12:20:51 -0400 Subject: [PATCH 16/42] increase version number from 1.2 -> 1.3 --- PathRecorder.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index fe44686..4ff1397 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -588,7 +588,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -687,7 +687,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -715,7 +715,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From 4f8234e425a74b946f21a9405806ab7ff564e8ca Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 24 Aug 2025 20:57:05 -0400 Subject: [PATCH 17/42] show rate app alert after 3 recordings --- PathRecorder/ContentView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index c30eb2a..e3c6430 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -9,8 +9,10 @@ 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 { @@ -135,6 +137,14 @@ struct ContentView: View { 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.$pathToNavigateTo) { path in if let path = path { From 61c645801f5e2cce7059d4e55bbc71c2c5c14089 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 24 Aug 2025 21:29:14 -0400 Subject: [PATCH 18/42] fix but related to showing setName sheet not appearing consistently on new recordings, and incorrectly appearing for resumed recordings --- PathRecorder/ContentView.swift | 2 +- PathRecorder/LocationManager.swift | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index e3c6430..e7895ec 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -150,7 +150,7 @@ struct ContentView: View { if let path = path { selectedPathForRename = path navigationPath.append(path) - showRenameSheet = locationManager.shouldShowRenameSheet + showRenameSheet = locationManager.editingPathName == nil } } .fullScreenCover(isPresented: Binding( diff --git a/PathRecorder/LocationManager.swift b/PathRecorder/LocationManager.swift index 8baeac5..123689f 100644 --- a/PathRecorder/LocationManager.swift +++ b/PathRecorder/LocationManager.swift @@ -23,7 +23,6 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var editingPathId: UUID? = nil @Published var editingPathName: String? = nil @Published var pathToNavigateTo: RecordedPath? = nil // Track path to navigate to after recording - @Published var shouldShowRenameSheet = false // Control whether rename sheet appears // Properties for improved distance calculation private var lastProcessedTime: Date? @@ -138,6 +137,8 @@ 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() @@ -156,8 +157,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 } } @@ -468,7 +467,6 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { // Always navigate to the path, but only show rename sheet if name is nil DispatchQueue.main.async { self.pathToNavigateTo = recordedPath - self.shouldShowRenameSheet = (self.editingPathName == nil) } } From b46ea8145da5543aeabfa51d534ed67991c57d3b Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 24 Aug 2025 21:30:59 -0400 Subject: [PATCH 19/42] increment app version from 1.3 -> 1.4 --- PathRecorder.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 4ff1397..251c145 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -588,7 +588,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -687,7 +687,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -715,7 +715,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From e2f91469f6e84861ecdc1dbe77093c54637007d8 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 7 Sep 2025 21:02:28 -0400 Subject: [PATCH 20/42] display path info in static map view --- PathRecorder/ContentView.swift | 1 + .../MapComponents/StaticMap/PathMapView.swift | 137 +++++++++++++++--- 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index e7895ec..abe2059 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -188,6 +188,7 @@ struct ContentView: View { recordedPath: path, locationManager: locationManager, pathStorage: pathStorage, + settings: settings, showRenameSheetOnAppear: showRenameSheet, onModifyPath: { showRecordingSheet = true diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index a06ebfc..f3f6814 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -1,5 +1,6 @@ import SwiftUI import MapKit +import Shared /// Displays a map with polylines and GPS point annotations for a recorded path. struct PathMapView: View { @@ -7,6 +8,7 @@ struct PathMapView: View { @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,10 +16,12 @@ 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 }) @@ -60,34 +64,117 @@ struct PathMapView: View { @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, 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 - } + handlePhotoTap(tappedPhoto) } ) - .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(_ tappedPhoto: PathPhoto) { + // 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 + } + } + + var body: some View { + let currentPath = pathStorage.path(for: recordedPath.id) ?? recordedPath + ZStack(alignment: .bottom) { + mapView(for: currentPath) + bottomInfoSheet(for: currentPath) + } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { @@ -214,4 +301,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) + } } From e002b3dafd17043c641345e6c50775b5b07cd057 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Wed, 24 Sep 2025 22:37:51 -0400 Subject: [PATCH 21/42] migrate to v1.5 --- PathRecorder.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 251c145..818bf84 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -588,7 +588,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -687,7 +687,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -715,7 +715,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.5; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From 1fcf10ddad43e01733a17442e8a2f422476f0805 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sat, 14 Mar 2026 12:38:42 -0400 Subject: [PATCH 22/42] Move model files into PathRecorder/Models (git mv) --- .DS_Store | Bin 0 -> 6148 bytes PathRecorder.xcodeproj/project.pbxproj | 72 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 69 +++++++++++++++++ .../LiveMap/LivePathMapView.swift | 2 +- PathRecorder/{ => Models}/Item.swift | 0 PathRecorder/{ => Models}/PathPhoto.swift | 0 .../StaticMap => Models}/PathSegment.swift | 0 PathRecorder/{ => Models}/RecordedPath.swift | 0 PathRecorder/Supabase.swift | 14 ++++ 9 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 .DS_Store create mode 100644 PathRecorder.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename PathRecorder/{ => Models}/Item.swift (100%) rename PathRecorder/{ => Models}/PathPhoto.swift (100%) rename PathRecorder/{MapComponents/StaticMap => Models}/PathSegment.swift (100%) rename PathRecorder/{ => Models}/RecordedPath.swift (100%) create mode 100644 PathRecorder/Supabase.swift diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a665c80f7571686c1b9a2a5cb77ebc48bc9fa9e7 GIT binary patch literal 6148 zcmeHK!AiqG5PjQLi0Gjw@t7}Adh!}eyo>z-O;Rk9vY{%7r+k2)-C#rQQeny;eE~G;%HkCPVl5fuUPzsrT4h2Z%32T&l^2)|82Qz z{@mwzQTu{<$@}Ze^Xb|0eD^)7Z|~8}*3a>w$1!6J7z4(@-)BIsbxLv&(5x|F3>X6| z2IPK7se)O=HlSV|G&%wh2Xw1&EwhB=q!F`-Z9u-ExR6Q=sVeZA!hSibz}=aFxP^wqnLgD?X;G(4WeJm_=*@(nGNy0j0r=G4P`dd;;%1PT>Fm literal 0 HcmV?d00001 diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 818bf84..e5dbaec 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -73,16 +78,28 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, + Supabase.swift, ); target = 6141C8D22DECACB90034946C /* PathRecorder */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ +/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + B9AD17792F65C51F00DB89CE /* Exceptions for "PathRecorder" folder in "Copy Bundle Resources" phase from "PathRecorder" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + buildPhase = 6141C8D12DECACB90034946C /* Resources */; + membershipExceptions = ( + Supabase.swift, + ); + }; +/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 6141C8D52DECACB90034946C /* PathRecorder */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( B9AAE7DD2E34815900CD6869 /* Exceptions for "PathRecorder" folder in "PathRecorder" target */, + B9AD17792F65C51F00DB89CE /* Exceptions for "PathRecorder" folder in "Copy Bundle Resources" phase from "PathRecorder" target */, ); path = PathRecorder; sourceTree = ""; @@ -112,6 +129,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 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 +220,11 @@ name = PathRecorder; packageProductDependencies = ( B93D3C412E07157000B158C8 /* Shared */, + B9AD176D2F65C4AF00DB89CE /* Auth */, + B9AD176F2F65C4AF00DB89CE /* Functions */, + B9AD17712F65C4AF00DB89CE /* PostgREST */, + B9AD17732F65C4AF00DB89CE /* Realtime */, + B9AD17752F65C4AF00DB89CE /* Storage */, ); productName = PathRecorder; productReference = 6141C8D32DECACB90034946C /* PathRecorder.app */; @@ -309,6 +336,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( B93D3C402E07152A00B158C8 /* XCLocalSwiftPackageReference "Shared" */, + B9AD176C2F65C4AF00DB89CE /* XCRemoteSwiftPackageReference "supabase-swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6141C8D42DECACB90034946C /* Products */; @@ -551,7 +579,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -588,7 +616,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -687,7 +715,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -715,7 +743,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.5; + MARKETING_VERSION = 1.6; PRODUCT_BUNDLE_IDENTIFIER = slugmuffin.PathRecorder.PathRecorderWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -782,6 +810,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 +830,31 @@ 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; + }; /* 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/MapComponents/LiveMap/LivePathMapView.swift b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift index 3c3cd2c..598e95f 100644 --- a/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift +++ b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift @@ -77,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) diff --git a/PathRecorder/Item.swift b/PathRecorder/Models/Item.swift similarity index 100% rename from PathRecorder/Item.swift rename to PathRecorder/Models/Item.swift diff --git a/PathRecorder/PathPhoto.swift b/PathRecorder/Models/PathPhoto.swift similarity index 100% rename from PathRecorder/PathPhoto.swift rename to PathRecorder/Models/PathPhoto.swift diff --git a/PathRecorder/MapComponents/StaticMap/PathSegment.swift b/PathRecorder/Models/PathSegment.swift similarity index 100% rename from PathRecorder/MapComponents/StaticMap/PathSegment.swift rename to PathRecorder/Models/PathSegment.swift diff --git a/PathRecorder/RecordedPath.swift b/PathRecorder/Models/RecordedPath.swift similarity index 100% rename from PathRecorder/RecordedPath.swift rename to PathRecorder/Models/RecordedPath.swift diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift new file mode 100644 index 0000000..7e04678 --- /dev/null +++ b/PathRecorder/Supabase.swift @@ -0,0 +1,14 @@ +// +// Supabase.swift +// PathRecorder +// +// Created by Aparna Natarajan on 3/14/26. +// + + +import Supabase + +let supabase = SupabaseClient( + supabaseURL: URL(string: "https://hsbnabtalqugbwspdhnq.supabase.co")!, + supabaseKey: "sb_publishable_plix2vRBUgoocyW2QacrVA_tqPYIO-M" +) From db0723672ecc05c33ef9f7c9896cb67bb7fb1098 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sat, 14 Mar 2026 12:41:33 -0400 Subject: [PATCH 23/42] Extract PathStorage to Services/PathStorage.swift (separate storage logic from models) --- PathRecorder/Models/RecordedPath.swift | 52 ----------------------- PathRecorder/Services/PathStorage.swift | 56 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 52 deletions(-) create mode 100644 PathRecorder/Services/PathStorage.swift diff --git a/PathRecorder/Models/RecordedPath.swift b/PathRecorder/Models/RecordedPath.swift index d0e269e..ed1b238 100644 --- a/PathRecorder/Models/RecordedPath.swift +++ b/PathRecorder/Models/RecordedPath.swift @@ -59,55 +59,3 @@ struct GPSLocation: Identifiable, Codable, Equatable { } } -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() - } - } - - func deletePhoto(from pathId: UUID, photo: PathPhoto) { - if let index = recordedPaths.firstIndex(where: { $0.id == pathId }) { - recordedPaths[index].photos.removeAll { $0.id == photo.id } - // Delete image file from disk - let url = PathPhoto.imagesDirectory.appendingPathComponent(photo.imageFilename) - try? FileManager.default.removeItem(at: url) - 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/Services/PathStorage.swift b/PathRecorder/Services/PathStorage.swift new file mode 100644 index 0000000..f8210cf --- /dev/null +++ b/PathRecorder/Services/PathStorage.swift @@ -0,0 +1,56 @@ +import Foundation +import Combine + +final 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() + } + } + + func deletePhoto(from pathId: UUID, photo: PathPhoto) { + if let index = recordedPaths.firstIndex(where: { $0.id == pathId }) { + recordedPaths[index].photos.removeAll { $0.id == photo.id } + // Delete image file from disk + let url = PathPhoto.imagesDirectory.appendingPathComponent(photo.imageFilename) + try? FileManager.default.removeItem(at: url) + 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 + } + } +} From e3a680da550b13c64a7d10a969a973a5b7b10c1c Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 15:15:57 -0400 Subject: [PATCH 24/42] Model: update path photo/segment model and remove Item schema --- PathRecorder/Models/Item.swift | 18 -------- PathRecorder/Models/PathPhoto.swift | 46 ++++++------------ PathRecorder/Models/PathSegment.swift | 36 ++++++++++++--- PathRecorder/Models/RecordedPath.swift | 64 +++++++++++++++++++------- PathRecorder/PathRecorderApp.swift | 8 +++- 5 files changed, 98 insertions(+), 74 deletions(-) delete mode 100644 PathRecorder/Models/Item.swift diff --git a/PathRecorder/Models/Item.swift b/PathRecorder/Models/Item.swift deleted file mode 100644 index 601986c..0000000 --- a/PathRecorder/Models/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/Models/PathPhoto.swift b/PathRecorder/Models/PathPhoto.swift index 69cfa0e..893fdcc 100644 --- a/PathRecorder/Models/PathPhoto.swift +++ b/PathRecorder/Models/PathPhoto.swift @@ -11,63 +11,45 @@ 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 + let imageFilename: String + let locationId: UUID - init(coordinate: CLLocationCoordinate2D, timestamp: Date, image: UIImage, imageFilename: String) { + init(timestamp: Date, image: UIImage, imageFilename: String, locationId: UUID) { self.id = UUID() - self.coordinate = coordinate self.timestamp = timestamp self.imageFilename = imageFilename - // Save image to disk when creating + 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) } - 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 + lhs.imageFilename == rhs.imageFilename && + lhs.locationId == rhs.locationId } func hash(into hasher: inout Hasher) { hasher.combine(id) - hasher.combine(coordinate.latitude) - hasher.combine(coordinate.longitude) hasher.combine(timestamp) hasher.combine(imageFilename) + hasher.combine(locationId) } // Directory for storing images diff --git a/PathRecorder/Models/PathSegment.swift b/PathRecorder/Models/PathSegment.swift index 2f1532c..0ccad5e 100644 --- a/PathRecorder/Models/PathSegment.swift +++ b/PathRecorder/Models/PathSegment.swift @@ -1,10 +1,34 @@ import MapKit +import Foundation -/// Represents a segment of a path, used for drawing polylines. -struct PathSegment: Identifiable { +/// Represents a continuous segment of a path (between pause/resume events) +struct PathSegment: Identifiable, Codable { let id: UUID - let coordinates: [CLLocationCoordinate2D] - var polyline: MKPolyline { - MKPolyline(coordinates: coordinates, count: coordinates.count) + let locations: [GPSLocation] + + init(locations: [GPSLocation]) { + self.id = UUID() + self.locations = locations } -} \ No newline at end of file + + 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 index ed1b238..fb809d0 100644 --- a/PathRecorder/Models/RecordedPath.swift +++ b/PathRecorder/Models/RecordedPath.swift @@ -4,26 +4,61 @@ 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 segments: [PathSegment] var name: String + var photos: [PathPhoto] - init(startTime: Date, totalDuration: TimeInterval, totalDistance: Double, locations: [GPSLocation], photos: [PathPhoto] = [], name: String? = nil) { + init(segments: [PathSegment], name: String? = nil, photos: [PathPhoto] = []) { self.id = UUID() - self.startTime = startTime - self.totalDuration = totalDuration - self.totalDistance = totalDistance - self.locations = locations + 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))" } } + + /// 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 + 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 @@ -32,11 +67,6 @@ struct RecordedPath: Identifiable, Codable, Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } - - mutating func editName(_ newName: String) { - self.name = newName - } - } struct GPSLocation: Identifiable, Codable, Equatable { @@ -44,9 +74,9 @@ struct GPSLocation: Identifiable, Codable, Equatable { let latitude: Double let longitude: Double let timestamp: Date - let segmentId: UUID // Track which recording segment this belongs to + let segmentId: UUID // Tracks which segment this location belongs to - init(latitude: Double, longitude: Double, timestamp: Date, segmentId: UUID = UUID()) { + init(latitude: Double, longitude: Double, timestamp: Date, segmentId: UUID) { self.id = UUID() self.latitude = latitude self.longitude = longitude diff --git a/PathRecorder/PathRecorderApp.swift b/PathRecorder/PathRecorderApp.swift index c2eb737..25105ab 100644 --- a/PathRecorder/PathRecorderApp.swift +++ b/PathRecorder/PathRecorderApp.swift @@ -13,9 +13,15 @@ import UIKit @main struct PathRecorderApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate + @StateObject private var authManager = AuthManager() + + 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) From c2836ad44860bf7d9e0f8b6d88942cabb7a895a3 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 15:16:04 -0400 Subject: [PATCH 25/42] App: apply pause/resume segment logic and camera capture safety --- PathRecorder/LocationManager.swift | 51 ++++++++++++++----- .../MapComponents/LiveMap/CameraView.swift | 20 ++++++-- .../LiveMap/LivePathMapView.swift | 1 - .../StaticMap/PhotoLibraryPicker.swift | 28 +++++----- 4 files changed, 67 insertions(+), 33 deletions(-) diff --git a/PathRecorder/LocationManager.swift b/PathRecorder/LocationManager.swift index 123689f..7fe92dd 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] = [] @@ -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 @@ -452,15 +471,19 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { pathStorage.deletePath(id: editingPathId!) } - // Create new path - let recordedPath = RecordedPath( - startTime: startTime, - totalDuration: elapsedTime, - totalDistance: totalDistance, - locations: locations, - photos: capturedPhotos, - name: editingPathName - ) + // 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 with segments and preserve captured photos + let recordedPath = RecordedPath(segments: segments, name: editingPathName, photos: capturedPhotos) pathStorage.savePath(recordedPath) capturedPhotos.removeAll() diff --git a/PathRecorder/MapComponents/LiveMap/CameraView.swift b/PathRecorder/MapComponents/LiveMap/CameraView.swift index 92b7180..e2a1ffb 100644 --- a/PathRecorder/MapComponents/LiveMap/CameraView.swift +++ b/PathRecorder/MapComponents/LiveMap/CameraView.swift @@ -171,6 +171,13 @@ struct CameraView: View { .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 @@ -301,6 +308,7 @@ class CameraService: NSObject, ObservableObject { @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 { @@ -443,11 +451,15 @@ class CameraService: NSObject, ObservableObject { settings.flashMode = flashMode.avFlashMode } - // Set the orientation for the photo - if let connection = output.connection(with: .video) { - connection.videoOrientation = currentVideoOrientation() + 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) } diff --git a/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift index 598e95f..6c3a924 100644 --- a/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift +++ b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift @@ -148,7 +148,6 @@ struct LivePathMapView: View { if let location = locationManager.currentLocation { let filename = "photo_\(UUID().uuidString).jpg" let photo = PathPhoto( - coordinate: location.coordinate, timestamp: Date(), image: image, imageFilename: filename diff --git a/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift b/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift index 790cf1d..a9cf18d 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift @@ -50,23 +50,23 @@ struct PhotoLibraryPicker: View { let creationDate: Date? = asset?.creationDate if let creationDate = creationDate { 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 } } From 531d0a10d8834be6a0bdd8efa863004bd81dcb72 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 15:17:05 -0400 Subject: [PATCH 26/42] Display: draw separate path segments and fix photo annotations for new model --- .../LiveMap/LiveMapViewController.swift | 13 +++-- .../LiveMap/LivePathMapView.swift | 5 +- .../MapComponents/MapRenderingHelpers.swift | 36 ++++++++---- .../StaticMap/MapWithPolylines.swift | 58 ++++++------------- .../MapComponents/StaticMap/PathMapView.swift | 50 ++++++---------- .../StaticMap/PhotoGridView.swift | 3 +- .../StaticMap/PhotoLibraryPicker.swift | 1 + .../StaticMap/PhotoPagerView.swift | 12 +--- PathRecorder/Settings.swift | 21 ------- 9 files changed, 75 insertions(+), 124 deletions(-) diff --git a/PathRecorder/MapComponents/LiveMap/LiveMapViewController.swift b/PathRecorder/MapComponents/LiveMap/LiveMapViewController.swift index 73793ea..44609b8 100644 --- a/PathRecorder/MapComponents/LiveMap/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) } } diff --git a/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift index 6c3a924..b07edee 100644 --- a/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift +++ b/PathRecorder/MapComponents/LiveMap/LivePathMapView.swift @@ -145,12 +145,13 @@ struct LivePathMapView: View { CameraView(isPresented: $showCamera, onImageCaptured: { image in capturedImage = image // Save photo to current path - if let location = locationManager.currentLocation { + if let locationId = locationManager.recordPhotoLocation() { let filename = "photo_\(UUID().uuidString).jpg" let photo = PathPhoto( 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 ad39c64..1ab322f 100644 --- a/PathRecorder/MapComponents/MapRenderingHelpers.swift +++ b/PathRecorder/MapComponents/MapRenderingHelpers.swift @@ -3,12 +3,8 @@ import MapKit import SwiftUI struct MapRenderingHelpers { - static func mapUIColor() -> UIColor { - if let hex = UserDefaults.standard.string(forKey: "mapColor"), - let color = Color.fromHexString(hex) { - return UIColor(color) - } - return UIColor.blue + static func defaultStrokeColor() -> UIColor { + return .systemBlue } static func photoAnnotationImage(preview: UIImage?) -> UIImage? { @@ -20,7 +16,7 @@ struct MapRenderingHelpers { guard let ctx = UIGraphicsGetCurrentContext() else { return nil } // Draw bubble let bubblePath = UIBezierPath(roundedRect: bubbleRect, cornerRadius: 12) - let annotationColor = mapUIColor() + 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() @@ -61,10 +57,28 @@ struct MapRenderingHelpers { 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 = mapUIColor() + renderer.strokeColor = segmentColor(for: polyline.title) renderer.lineWidth = polylineWidth renderer.lineCap = .round renderer.lineJoin = .round @@ -78,12 +92,12 @@ struct MapRenderingHelpers { UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 0) guard let ctx = UIGraphicsGetCurrentContext() else { return nil } // Draw glow - let glowColor = mapUIColor().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 dot - let dotColor = mapUIColor().cgColor + 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() @@ -97,7 +111,7 @@ struct MapRenderingHelpers { UIGraphicsBeginImageContextWithOptions(CGSize(width: size, height: size), false, 0) guard let ctx = UIGraphicsGetCurrentContext() else { return nil } // Draw solid dot - let dotColor = mapUIColor().cgColor + 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() diff --git a/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift b/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift index ee59086..6ddcd64 100644 --- a/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift +++ b/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift @@ -28,51 +28,34 @@ 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 { + 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]] = [] 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 - }) { - clusters[idx].append(photo) - } else { - clusters.append([photo]) - } - } - // 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)) + guard let coord = coordinate(for: photo, in: locations) else { continue } + mapView.addAnnotation(PhotoAnnotation(photos: [photo], coordinate: coord)) } } + 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 { Coordinator(self, onPhotoTapped: onPhotoTapped) } @@ -96,13 +79,11 @@ struct MapWithPolylines: UIViewRepresentable { } else { annotationView?.annotation = annotation } - // Use helper for annotation marker image with preview let preview = photoAnnotation.photos.first?.image annotationView?.image = MapRenderingHelpers.photoAnnotationImage(preview: preview) annotationView?.canShowCallout = false annotationView?.centerOffset = CGPoint(x: 0, y: 0) annotationView?.isUserInteractionEnabled = true - // Ensure photo annotation is always on top annotationView?.layer.zPosition = 1 return annotationView } else { @@ -115,18 +96,15 @@ struct MapWithPolylines: UIViewRepresentable { } annotationView?.image = MapRenderingHelpers.cachedBlueDotImage() annotationView?.centerOffset = CGPoint(x: 0, y: 0) - annotationView?.isUserInteractionEnabled = false // Don't block touches + annotationView?.isUserInteractionEnabled = false annotationView?.layer.zPosition = 0 return annotationView } } 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) - } + if let photoAnnotation = annotationView.annotation as? PhotoAnnotation, + let firstPhoto = photoAnnotation.photos.first { + onPhotoTapped(firstPhoto) } } } diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index f3f6814..67325d3 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -23,17 +23,10 @@ struct PathMapView: View { 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 @@ -69,7 +62,7 @@ struct PathMapView: View { MapWithPolylines( region: region, locations: currentPath.locations, - pathSegments: pathSegments, + pathSegments: currentPath.segments, photos: currentPath.photos, onPhotoTapped: { tappedPhoto in handlePhotoTap(tappedPhoto) @@ -77,7 +70,7 @@ struct PathMapView: View { ) .id(currentPath.photos.count) } - + private func bottomInfoSheet(for currentPath: RecordedPath) -> some View { VStack(spacing: 0) { Spacer() @@ -91,7 +84,7 @@ struct PathMapView: View { .padding(.bottom, 20) } } - + private func pathInfoContent(for currentPath: RecordedPath) -> some View { VStack(alignment: .center, spacing: 8) { // Title line @@ -99,7 +92,7 @@ struct PathMapView: View { .font(.headline) .padding(.horizontal, 16) .padding(.top, 16) - + // Metrics line pathMetricsRow(for: currentPath) .padding(.horizontal, 16) @@ -107,7 +100,7 @@ struct PathMapView: View { } .frame(maxWidth: nil, alignment: .center) } - + private func pathMetricsRow(for currentPath: RecordedPath) -> some View { HStack(spacing: 12) { // Distance @@ -116,16 +109,16 @@ struct PathMapView: View { color: .green, text: settings.formatDistance(currentPath.totalDistance) ) - - + + // Total time metricItem( icon: "clock", color: .orange, text: formatTime(currentPath.totalDuration) ) - - + + // Pace metricItem( icon: "timer", @@ -138,7 +131,7 @@ struct PathMapView: View { ) } } - + private func metricItem(icon: String, color: Color, text: String) -> some View { HStack(spacing: 4) { Image(systemName: icon) @@ -148,21 +141,12 @@ struct PathMapView: View { .font(.subheadline) } } - + // MARK: - Helper Methods private func handlePhotoTap(_ tappedPhoto: PathPhoto) { - // 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 }) { + selectedPhotos = latestPath.photos + if let idx = selectedPhotos?.firstIndex(where: { $0.id == tappedPhoto.id }) { selectedPhotoIndex = idx } else { selectedPhotoIndex = 0 @@ -301,7 +285,7 @@ struct PathMapView: View { } } } - + // Helper function for formatting time private func formatTime(_ timeInterval: TimeInterval) -> String { let hours = Int(timeInterval) / 3600 diff --git a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift index cc8b9a7..dba79d6 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoGridView.swift @@ -145,9 +145,8 @@ struct PhotoGridView: View { if let image = photo.uiImage { let assetRequest = PHAssetChangeRequest.creationRequestForAsset(from: image) - // Set original creation date and location + // Set original creation date assetRequest.creationDate = photo.timestamp - assetRequest.location = CLLocation(latitude: photo.coordinate.latitude, longitude: photo.coordinate.longitude) if let assetPlaceholder = assetRequest.placeholderForCreatedAsset { assetPlaceholders.append(assetPlaceholder) diff --git a/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift b/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift index a9cf18d..406cfb5 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoLibraryPicker.swift @@ -49,6 +49,7 @@ 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 = segment.locations guard let first = segmentLocations.first, let last = segmentLocations.last else { continue } diff --git a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift index 814524c..5f66fa4 100644 --- a/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift +++ b/PathRecorder/MapComponents/StaticMap/PhotoPagerView.swift @@ -27,9 +27,6 @@ struct PhotoPagerView: View { 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() @@ -134,18 +131,11 @@ struct PhotoPagerView: View { // Create image destination guard let imageDestination = CGImageDestinationCreateWithURL(fileURL as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else { return false } - // Create metadata dictionary + // 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) - ], - kCGImagePropertyGPSDictionary as String: [ - kCGImagePropertyGPSLatitude as String: abs(photo.coordinate.latitude), - kCGImagePropertyGPSLatitudeRef as String: photo.coordinate.latitude >= 0 ? "N" : "S", - kCGImagePropertyGPSLongitude as String: abs(photo.coordinate.longitude), - kCGImagePropertyGPSLongitudeRef as String: photo.coordinate.longitude >= 0 ? "E" : "W", - kCGImagePropertyGPSTimeStamp as String: ISO8601DateFormatter().string(from: photo.timestamp) ] ] diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index 48ba0a7..0272e69 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -40,17 +40,6 @@ class Settings: ObservableObject { } } - @Published var mapColor: Color { - didSet { - // Store as hex string - UserDefaults.standard.set(mapColor.toHexString(), forKey: "mapColor") - } - } - - var mapColorUIColor: UIColor { - UIColor(mapColor) - } - init() { if let savedUnit = UserDefaults.standard.string(forKey: "distanceUnit"), let unit = DistanceUnit(rawValue: savedUnit) { @@ -58,13 +47,6 @@ class Settings: ObservableObject { } else { self.distanceUnit = .kilometers } - - if let savedColorHex = UserDefaults.standard.string(forKey: "mapColor"), - let color = Color.fromHexString(savedColorHex) { - self.mapColor = color - } else { - self.mapColor = .blue - } } func convertDistance(_ meters: Double) -> Double { @@ -92,9 +74,6 @@ struct SettingsView: View { } .pickerStyle(SegmentedPickerStyle()) } - Section(header: Text("Map Path Color")) { - ColorPicker("Path Color", selection: $settings.mapColor, supportsOpacity: false) - } } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) From 44e44ea4f9e6e0226475bcde3d3e2bf967af4f66 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 16:02:30 -0400 Subject: [PATCH 27/42] Data migration: injectable UserDefaults and tests; ignore fixture.json --- .gitignore | 3 + PathRecorder/Services/DataMigration.swift | 158 ++++++++++++++++++++ PathRecorderTests/DataMigrationTests.swift | 162 +++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 PathRecorder/Services/DataMigration.swift create mode 100644 PathRecorderTests/DataMigrationTests.swift 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/Services/DataMigration.swift b/PathRecorder/Services/DataMigration.swift new file mode 100644 index 0000000..e6bed34 --- /dev/null +++ b/PathRecorder/Services/DataMigration.swift @@ -0,0 +1,158 @@ +import Foundation +import CoreLocation + +class DataMigration { + static let shared = DataMigration() + + private let userDefaults: UserDefaults + private let migratedV1Key = "DataMigrationV1Completed" + private let migratedV2Key = "DataMigrationV2Completed" + + 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) + } + } + + // 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 + // Keep locationId only if it still references a real location + 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: - 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/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]? +} From e381044d9e526741deee7c44cf9bc13c806a554e Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 17:01:07 -0400 Subject: [PATCH 28/42] Display: stack photo pins by screen proximity and sort by timestamp --- .../StaticMap/MapWithPolylines.swift | 75 ++++++++++++++++--- .../MapComponents/StaticMap/PathMapView.swift | 34 ++++----- 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift b/PathRecorder/MapComponents/StaticMap/MapWithPolylines.swift index 6ddcd64..3a94916 100644 --- a/PathRecorder/MapComponents/StaticMap/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() @@ -34,6 +39,7 @@ struct MapWithPolylines: UIViewRepresentable { polyline.title = "segment_\(index)" mapView.addOverlay(polyline) } + // Add GPS point annotations for segment start/end if let startCoord = segment.coordinates.first, let endCoord = segment.coordinates.last { let startAnnotation = MKPointAnnotation() @@ -45,10 +51,53 @@ struct MapWithPolylines: UIViewRepresentable { mapView.addAnnotation(endAnnotation) } } + + 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 { - guard let coord = coordinate(for: photo, in: locations) else { continue } - mapView.addAnnotation(PhotoAnnotation(photos: [photo], coordinate: coord)) + 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 + }) { + photoGroups[matchingIndex].append(photo) + groupCoordinates[matchingIndex].append(coordinate) + } else { + photoGroups.append([photo]) + groupCoordinates.append([coordinate]) + groupScreenPoints.append(screenPoint) + } } + + 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? { @@ -62,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 } @@ -79,11 +128,13 @@ struct MapWithPolylines: UIViewRepresentable { } else { annotationView?.annotation = annotation } + // Use helper for annotation marker image with preview let preview = photoAnnotation.photos.first?.image annotationView?.image = MapRenderingHelpers.photoAnnotationImage(preview: preview) annotationView?.canShowCallout = false annotationView?.centerOffset = CGPoint(x: 0, y: 0) annotationView?.isUserInteractionEnabled = true + // Ensure photo annotation is always on top annotationView?.layer.zPosition = 1 return annotationView } else { @@ -96,16 +147,22 @@ struct MapWithPolylines: UIViewRepresentable { } annotationView?.image = MapRenderingHelpers.cachedBlueDotImage() annotationView?.centerOffset = CGPoint(x: 0, y: 0) - annotationView?.isUserInteractionEnabled = false + annotationView?.isUserInteractionEnabled = false // Don't block touches annotationView?.layer.zPosition = 0 return annotationView } } func mapView(_ mapView: MKMapView, didSelect annotationView: MKAnnotationView) { - if let photoAnnotation = annotationView.annotation as? PhotoAnnotation, - let firstPhoto = photoAnnotation.photos.first { - onPhotoTapped(firstPhoto) + if let photoAnnotation = annotationView.annotation as? PhotoAnnotation { + print("Photo annotation tapped at coordinate: \(photoAnnotation.coordinate.latitude), \(photoAnnotation.coordinate.longitude)") + 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/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index 67325d3..bb4393e 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -64,13 +64,13 @@ struct PathMapView: View { locations: currentPath.locations, pathSegments: currentPath.segments, photos: currentPath.photos, - onPhotoTapped: { tappedPhoto in - handlePhotoTap(tappedPhoto) + onPhotoTapped: { tappedPhotos, selectedPhoto in + handlePhotoTap(tappedPhotos, selectedPhoto: selectedPhoto) } ) .id(currentPath.photos.count) } - + private func bottomInfoSheet(for currentPath: RecordedPath) -> some View { VStack(spacing: 0) { Spacer() @@ -84,7 +84,7 @@ struct PathMapView: View { .padding(.bottom, 20) } } - + private func pathInfoContent(for currentPath: RecordedPath) -> some View { VStack(alignment: .center, spacing: 8) { // Title line @@ -92,7 +92,7 @@ struct PathMapView: View { .font(.headline) .padding(.horizontal, 16) .padding(.top, 16) - + // Metrics line pathMetricsRow(for: currentPath) .padding(.horizontal, 16) @@ -100,7 +100,7 @@ struct PathMapView: View { } .frame(maxWidth: nil, alignment: .center) } - + private func pathMetricsRow(for currentPath: RecordedPath) -> some View { HStack(spacing: 12) { // Distance @@ -109,16 +109,16 @@ struct PathMapView: View { color: .green, text: settings.formatDistance(currentPath.totalDistance) ) - - + + // Total time metricItem( icon: "clock", color: .orange, text: formatTime(currentPath.totalDuration) ) - - + + // Pace metricItem( icon: "timer", @@ -131,7 +131,7 @@ struct PathMapView: View { ) } } - + private func metricItem(icon: String, color: Color, text: String) -> some View { HStack(spacing: 4) { Image(systemName: icon) @@ -141,12 +141,12 @@ struct PathMapView: View { .font(.subheadline) } } - + // MARK: - Helper Methods - private func handlePhotoTap(_ tappedPhoto: PathPhoto) { - let latestPath = pathStorage.path(for: recordedPath.id) ?? recordedPath - selectedPhotos = latestPath.photos - if let idx = selectedPhotos?.firstIndex(where: { $0.id == tappedPhoto.id }) { + 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 @@ -285,7 +285,7 @@ struct PathMapView: View { } } } - + // Helper function for formatting time private func formatTime(_ timeInterval: TimeInterval) -> String { let hours = Int(timeInterval) / 3600 From 4fa66d170a1802c2ff870e8b9e9899f26b0f93b5 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sat, 14 Mar 2026 13:10:49 -0400 Subject: [PATCH 29/42] Add JSON export and Settings Export JSON button --- PathRecorder/ContentView.swift | 2 +- PathRecorder/Services/PathStorage.swift | 15 +++++++++ PathRecorder/Settings.swift | 44 ++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index abe2059..29a6dfe 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -181,7 +181,7 @@ struct ContentView: View { } } .sheet(isPresented: $showSettingsSheet) { - SettingsView(settings: settings) + SettingsView(settings: settings, pathStorage: pathStorage) } .navigationDestination(for: RecordedPath.self) { path in PathMapView( diff --git a/PathRecorder/Services/PathStorage.swift b/PathRecorder/Services/PathStorage.swift index f8210cf..2896a59 100644 --- a/PathRecorder/Services/PathStorage.swift +++ b/PathRecorder/Services/PathStorage.swift @@ -47,6 +47,21 @@ final class PathStorage: ObservableObject { } } + /// 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) { diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index 0272e69..c4c0f3e 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -1,10 +1,11 @@ import Foundation import SwiftUI +import UIKit enum DistanceUnit: String, CaseIterable, Codable { case kilometers = "km" case miles = "mi" - + var displayName: String { switch self { case .kilometers: @@ -13,7 +14,7 @@ enum DistanceUnit: String, CaseIterable, Codable { return "Miles" } } - + var conversionFactor: Double { switch self { case .kilometers: @@ -22,7 +23,7 @@ enum DistanceUnit: String, CaseIterable, Codable { return 0.621371 // Convert from meters to miles } } - + var unitLabel: String { switch self { case .kilometers: @@ -48,7 +49,7 @@ class Settings: ObservableObject { self.distanceUnit = .kilometers } } - + func convertDistance(_ meters: Double) -> Double { return meters / 1000 * distanceUnit.conversionFactor } @@ -61,7 +62,10 @@ class Settings: ObservableObject { struct SettingsView: View { @ObservedObject var settings: Settings + @ObservedObject var pathStorage: PathStorage @Environment(\.dismiss) private var dismiss + @State private var exportURL: URL? = nil + @State private var showingShare = false var body: some View { NavigationView { @@ -74,6 +78,19 @@ struct SettingsView: View { } .pickerStyle(SegmentedPickerStyle()) } + Section(header: Text("Export")) { + Button(action: { + if let url = pathStorage.exportJSONToTemporaryFile() { + exportURL = url + showingShare = true + } + }) { + HStack { + Image(systemName: "square.and.arrow.up") + Text("Export JSON") + } + } + } } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) @@ -85,6 +102,24 @@ struct SettingsView: View { } } } + .sheet(isPresented: $showingShare) { + if let url = exportURL { + ActivityView(activityItems: [url]) + } else { + EmptyView() + } + } + } + + // Activity view wrapper for sharing the exported file + struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } } @@ -112,4 +147,3 @@ extension Color { return Color(red: r, green: g, blue: b) } } - From 28ef8f70a1482ba62dea21428f84ca4a880d52eb Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sat, 14 Mar 2026 14:17:38 -0400 Subject: [PATCH 30/42] Add optional phone/OTP auth in Settings, no app-level auth gate --- PathRecorder.xcodeproj/project.pbxproj | 9 ++- PathRecorder/PathRecorderApp.swift | 1 + PathRecorder/Settings.swift | 103 ++++++++++++++++++++++++ PathRecorder/Supabase.swift | 104 +++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 1 deletion(-) diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index e5dbaec..92fedb6 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 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 */ @@ -78,7 +79,6 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, - Supabase.swift, ); target = 6141C8D22DECACB90034946C /* PathRecorder */; }; @@ -129,6 +129,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B9AD18862F65D8AE00DB89CE /* Supabase in Frameworks */, B9AD17762F65C4AF00DB89CE /* Storage in Frameworks */, B9AD17722F65C4AF00DB89CE /* PostgREST in Frameworks */, B9AD17702F65C4AF00DB89CE /* Functions in Frameworks */, @@ -225,6 +226,7 @@ B9AD17712F65C4AF00DB89CE /* PostgREST */, B9AD17732F65C4AF00DB89CE /* Realtime */, B9AD17752F65C4AF00DB89CE /* Storage */, + B9AD18852F65D8AE00DB89CE /* Supabase */, ); productName = PathRecorder; productReference = 6141C8D32DECACB90034946C /* PathRecorder.app */; @@ -855,6 +857,11 @@ 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/PathRecorderApp.swift b/PathRecorder/PathRecorderApp.swift index 25105ab..fd29447 100644 --- a/PathRecorder/PathRecorderApp.swift +++ b/PathRecorder/PathRecorderApp.swift @@ -35,6 +35,7 @@ struct PathRecorderApp: App { var body: some Scene { WindowGroup { ContentView() + .environmentObject(authManager) } .modelContainer(sharedModelContainer) } diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index c4c0f3e..29fbe00 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -63,9 +63,19 @@ class Settings: ObservableObject { struct SettingsView: View { @ObservedObject var settings: Settings @ObservedObject var pathStorage: PathStorage + @EnvironmentObject private var authManager: AuthManager @Environment(\.dismiss) private var dismiss @State private var exportURL: URL? = nil @State private var showingShare = false + // Sign-out + @State private var isSigningOut = false + // Inline sign-in OTP flow + @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 var body: some View { NavigationView { @@ -91,6 +101,57 @@ struct SettingsView: View { } } } + Section(header: Text("Account")) { + if authManager.isAuthenticated { + HStack { + Text("Phone") + Spacer() + Text(authManager.displayPhone(for: authManager.currentUser)) + .foregroundColor(.secondary) + } + Button(role: .destructive) { + Task { await signOut() } + } label: { + if isSigningOut { + HStack { ProgressView(); Text("Signing out...") } + } else { + Text("Sign Out") + } + } + .disabled(isSigningOut) + } else { + TextField("+15551234567", text: $authPhone) + .keyboardType(.phonePad) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + Button(isSendingOTP ? "Sending..." : "Send Code") { + Task { await sendOTP() } + } + .disabled(isSendingOTP || authPhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if didRequestOTP { + TextField("6-digit code", text: $authOTP) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + Button(isVerifyingOTP ? "Verifying..." : "Verify Code") { + Task { await verifyOTP() } + } + .disabled(isVerifyingOTP || authOTP.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button("Resend Code") { + Task { await sendOTP() } + } + .disabled(isSendingOTP) + } + + Text("Enter your phone number to sign in or create an account.") + .font(.footnote) + .foregroundColor(.secondary) + } + } } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) @@ -109,6 +170,48 @@ struct SettingsView: View { EmptyView() } } + .alert("Auth Error", isPresented: .constant(authErrorMessage != nil)) { + Button("OK") { + authErrorMessage = nil + } + } message: { + Text(authErrorMessage ?? "Unknown error") + } + } + + 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: authPhone) + didRequestOTP = true + } catch { + authErrorMessage = error.localizedDescription + } + } + + private func verifyOTP() async { + isVerifyingOTP = true + authErrorMessage = nil + defer { isVerifyingOTP = false } + do { + try await authManager.verifyOTP(phone: authPhone, token: authOTP) + authOTP = "" + didRequestOTP = false + } catch { + authErrorMessage = error.localizedDescription + } } // Activity view wrapper for sharing the exported file diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift index 7e04678..bb1495e 100644 --- a/PathRecorder/Supabase.swift +++ b/PathRecorder/Supabase.swift @@ -7,8 +7,112 @@ 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 + + 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: "") + } +} + +enum AuthFlowError: LocalizedError { + case invalidPhone + case invalidOTP + + var errorDescription: String? { + switch self { + case .invalidPhone: + return "Enter a valid phone number in E.164 format (example: +15551234567)." + case .invalidOTP: + return "Enter the OTP code sent to your phone." + } + } +} + + From 93b66212321868e9b2144a1f2d6702a017edc0a6 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 18:48:09 -0400 Subject: [PATCH 31/42] Backup: add Supabase schema migrations and storage bucket setup Co-Authored-By: Claude Sonnet 4.6 --- .../20260621000000_initial_schema.sql | 103 ++++++++++++++++++ .../20260621000002_storage_setup.sql | 16 +++ 2 files changed, 119 insertions(+) create mode 100644 supabase/migrations/20260621000000_initial_schema.sql create mode 100644 supabase/migrations/20260621000002_storage_setup.sql 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] + ); From 07ceb3c71dbd7cb2d1e66754717256a1d5c72a62 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 18:48:28 -0400 Subject: [PATCH 32/42] Backup: upload photos to Storage and upsert relational tables Replaces the old blob-based backups table with a relational write: paths, path_segments, gps_locations upserted per-row; photos uploaded as JPEGs to the path-photos Storage bucket and upserted to path_photos. Adds import Supabase for FileOptions/StorageFileApi access. Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder/Settings.swift | 145 ++++++++++++++++++++++++++++-------- 1 file changed, 115 insertions(+), 30 deletions(-) diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index 29fbe00..ee70f49 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import UIKit +import Supabase enum DistanceUnit: String, CaseIterable, Codable { case kilometers = "km" @@ -65,10 +66,10 @@ struct SettingsView: View { @ObservedObject var pathStorage: PathStorage @EnvironmentObject private var authManager: AuthManager @Environment(\.dismiss) private var dismiss - @State private var exportURL: URL? = nil - @State private var showingShare = false // Sign-out @State private var isSigningOut = false + @State private var isUploadingBackup = false + @State private var backupSuccessMessage: String? = nil // Inline sign-in OTP flow @State private var authPhone = "" @State private var authOTP = "" @@ -88,19 +89,6 @@ struct SettingsView: View { } .pickerStyle(SegmentedPickerStyle()) } - Section(header: Text("Export")) { - Button(action: { - if let url = pathStorage.exportJSONToTemporaryFile() { - exportURL = url - showingShare = true - } - }) { - HStack { - Image(systemName: "square.and.arrow.up") - Text("Export JSON") - } - } - } Section(header: Text("Account")) { if authManager.isAuthenticated { HStack { @@ -109,6 +97,20 @@ struct SettingsView: View { Text(authManager.displayPhone(for: authManager.currentUser)) .foregroundColor(.secondary) } + Button { + Task { await uploadBackup() } + } label: { + if isUploadingBackup { + HStack { ProgressView(); Text("Backing up...") } + } else { + HStack { + Image(systemName: "icloud.and.arrow.up") + Text("Backup to Cloud") + } + } + } + .disabled(isUploadingBackup) + Button(role: .destructive) { Task { await signOut() } } label: { @@ -163,12 +165,10 @@ struct SettingsView: View { } } } - .sheet(isPresented: $showingShare) { - if let url = exportURL { - ActivityView(activityItems: [url]) - } else { - EmptyView() - } + .alert("Backup Saved", isPresented: .constant(backupSuccessMessage != nil)) { + Button("OK") { backupSuccessMessage = nil } + } message: { + Text(backupSuccessMessage ?? "") } .alert("Auth Error", isPresented: .constant(authErrorMessage != nil)) { Button("OK") { @@ -179,6 +179,99 @@ struct SettingsView: View { } } + private func uploadBackup() async { + guard let userId = authManager.currentUser?.id else { + authErrorMessage = "Not signed in." + return + } + isUploadingBackup = true + defer { isUploadingBackup = false } + do { + struct PathRow: Encodable { + let id: UUID + let user_id: UUID + let name: String + let created_at: Date + } + struct SegmentRow: Encodable { + let id: UUID + let path_id: UUID + } + struct LocationRow: Encodable { + let id: UUID + let segment_id: UUID + let latitude: Double + let longitude: Double + let timestamp: Date + } + struct PhotoRow: Encodable { + let id: UUID + let location_id: UUID + let timestamp: Date + let storage_path: String + } + + var pathRows: [PathRow] = [] + var segmentRows: [SegmentRow] = [] + var locationRows: [LocationRow] = [] + var photoRows: [PhotoRow] = [] + + for path in pathStorage.recordedPaths { + pathRows.append(PathRow( + id: path.id, + user_id: userId, + name: path.name, + created_at: path.startTime + )) + + 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 + )) + } + } + + for photo in path.photos { + let storagePath = "\(userId.uuidString.lowercased())/\(photo.id.uuidString.lowercased()).jpg" + guard let image = photo.image, + let jpegData = image.jpegData(compressionQuality: 0.9) else { continue } + try await supabase.storage + .from("path-photos") + .upload(storagePath, data: jpegData, options: FileOptions(contentType: "image/jpeg", upsert: true)) + photoRows.append(PhotoRow( + id: photo.id, + location_id: photo.locationId, + timestamp: photo.timestamp, + storage_path: storagePath + )) + } + } + + if !pathRows.isEmpty { + try await supabase.from("paths").upsert(pathRows, 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() + } + + backupSuccessMessage = "Your data has been backed up to the cloud." + } catch { + authErrorMessage = error.localizedDescription + } + } + private func signOut() async { isSigningOut = true defer { isSigningOut = false } @@ -214,19 +307,11 @@ struct SettingsView: View { } } - // Activity view wrapper for sharing the exported file - struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} - } } // MARK: - Color <-> Hex helpers + extension Color { func toHexString() -> String { let uiColor = UIColor(self) From 434f7be6238bc689769087449cdf244d06051d5c Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 19:41:45 -0400 Subject: [PATCH 33/42] Settings: show backup progress bar and estimated time remaining Tracks per-photo upload progress on AuthManager so state survives sheet dismiss/reopen. Displays a linear ProgressView with percentage and estimated seconds remaining. Sign Out is disabled during backup. Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder/Services/DataMigration.swift | 1 - PathRecorder/Settings.swift | 49 +++++++++++++++++++---- PathRecorder/Supabase.swift | 3 ++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/PathRecorder/Services/DataMigration.swift b/PathRecorder/Services/DataMigration.swift index e6bed34..f2486d9 100644 --- a/PathRecorder/Services/DataMigration.swift +++ b/PathRecorder/Services/DataMigration.swift @@ -92,7 +92,6 @@ class DataMigration { for i in paths.indices { let allLocations = paths[i].segments.flatMap { $0.locations } paths[i].photos = paths[i].photos.compactMap { photo in - // Keep locationId only if it still references a real location let validId = photo.locationId.flatMap { lid in allLocations.contains(where: { $0.id == lid }) ? lid : nil } diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index ee70f49..c7bdfb8 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -68,7 +68,6 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss // Sign-out @State private var isSigningOut = false - @State private var isUploadingBackup = false @State private var backupSuccessMessage: String? = nil // Inline sign-in OTP flow @State private var authPhone = "" @@ -100,8 +99,20 @@ struct SettingsView: View { Button { Task { await uploadBackup() } } label: { - if isUploadingBackup { - HStack { ProgressView(); Text("Backing up...") } + if authManager.isUploadingBackup { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Backing up... \(Int(authManager.backupProgress * 100))%") + .font(.subheadline) + Spacer() + if let remaining = estimatedTimeRemaining { + Text(remaining) + .font(.caption) + .foregroundColor(.secondary) + } + } + ProgressView(value: authManager.backupProgress) + } } else { HStack { Image(systemName: "icloud.and.arrow.up") @@ -109,7 +120,7 @@ struct SettingsView: View { } } } - .disabled(isUploadingBackup) + .disabled(authManager.isUploadingBackup) Button(role: .destructive) { Task { await signOut() } @@ -120,7 +131,7 @@ struct SettingsView: View { Text("Sign Out") } } - .disabled(isSigningOut) + .disabled(isSigningOut || authManager.isUploadingBackup) } else { TextField("+15551234567", text: $authPhone) .keyboardType(.phonePad) @@ -179,13 +190,29 @@ struct SettingsView: View { } } + private var estimatedTimeRemaining: String? { + guard let start = authManager.backupStartTime, + authManager.backupProgress > 0.05 else { return nil } + let elapsed = Date().timeIntervalSince(start) + let total = elapsed / authManager.backupProgress + let remaining = total - elapsed + guard remaining > 1 else { return nil } + return "~\(Int(remaining.rounded()))s remaining" + } + private func uploadBackup() async { guard let userId = authManager.currentUser?.id else { authErrorMessage = "Not signed in." return } - isUploadingBackup = true - defer { isUploadingBackup = false } + authManager.isUploadingBackup = true + authManager.backupProgress = 0.0 + authManager.backupStartTime = Date() + defer { + authManager.isUploadingBackup = false + authManager.backupProgress = 0.0 + authManager.backupStartTime = nil + } do { struct PathRow: Encodable { let id: UUID @@ -206,6 +233,7 @@ struct SettingsView: View { } struct PhotoRow: Encodable { let id: UUID + let user_id: UUID let location_id: UUID let timestamp: Date let storage_path: String @@ -216,6 +244,8 @@ struct SettingsView: View { var locationRows: [LocationRow] = [] var photoRows: [PhotoRow] = [] + let totalPhotos = pathStorage.recordedPaths.reduce(0) { $0 + $1.photos.count } + var uploadedPhotos = 0 for path in pathStorage.recordedPaths { pathRows.append(PathRow( id: path.id, @@ -244,8 +274,13 @@ struct SettingsView: View { try await supabase.storage .from("path-photos") .upload(storagePath, data: jpegData, options: FileOptions(contentType: "image/jpeg", upsert: true)) + uploadedPhotos += 1 + if totalPhotos > 0 { + authManager.backupProgress = Double(uploadedPhotos) / Double(totalPhotos) + } photoRows.append(PhotoRow( id: photo.id, + user_id: userId, location_id: photo.locationId, timestamp: photo.timestamp, storage_path: storagePath diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift index bb1495e..22ae99d 100644 --- a/PathRecorder/Supabase.swift +++ b/PathRecorder/Supabase.swift @@ -18,6 +18,9 @@ let supabase = SupabaseClient( final class AuthManager: ObservableObject { @Published var currentUser: User? @Published var isLoadingSession = true + @Published var isUploadingBackup = false + @Published var backupProgress: Double = 0.0 + @Published var backupStartTime: Date? = nil private var authListenerTask: Task? From a28b087bacbc22f6088dba7f85e936c45b20ea4f Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 20:01:42 -0400 Subject: [PATCH 34/42] Backup: sync-aware backup button, cloud restore on login, dirty tracking - Backup to Cloud only shows when local paths are unsynced or dirty - On login, restores paths from server not present locally (downloads GPS data and photos from Storage) - Editing a path preserves its UUID so the server record is updated in place via upsert instead of creating an orphaned duplicate - PathStorage.savePath upserts by ID to avoid duplicates on edit - Tracks dirty path IDs separately for edited paths already on server - Deleting a path also removes it from Supabase and Storage - Backup progress and unsynced state survive sheet dismiss/reopen - Sign Out is disabled during backup Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder/ContentView.swift | 20 +++++ PathRecorder/LocationManager.swift | 17 ++-- PathRecorder/Models/PathSegment.swift | 5 ++ PathRecorder/Models/RecordedPath.swift | 15 ++++ PathRecorder/Services/PathStorage.swift | 6 +- PathRecorder/Settings.swift | 65 +++++++++----- PathRecorder/Supabase.swift | 113 ++++++++++++++++++++++++ 7 files changed, 210 insertions(+), 31 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index 29a6dfe..ebdcd07 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { return sortAscending ? "Fastest first" : "Slowest first" } } + @EnvironmentObject private var authManager: AuthManager @StateObject private var locationManager = LocationManager() @StateObject private var pathStorage = PathStorage() @StateObject private var settings = Settings() @@ -88,7 +89,9 @@ struct ContentView: View { locationManager.loadPathForEditing(path, pathStorage: pathStorage) }, onDelete: { + let photoIds = path.photos.map { $0.id } pathStorage.deletePath(id: path.id) + Task { await authManager.deleteFromCloud(pathId: path.id, photoIds: photoIds) } }, formatTime: formatTime, onSelect: { @@ -146,6 +149,23 @@ struct ContentView: View { UserDefaults.standard.set(true, forKey: rateAlertKey) } } + .onChange(of: authManager.currentUser?.id) { _, userId in + if userId != nil { + Task { await authManager.syncOnLogin(pathStorage: pathStorage) } + } 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 + } .onReceive(locationManager.$pathToNavigateTo) { path in if let path = path { selectedPathForRename = path diff --git a/PathRecorder/LocationManager.swift b/PathRecorder/LocationManager.swift index 7fe92dd..5bc58a3 100644 --- a/PathRecorder/LocationManager.swift +++ b/PathRecorder/LocationManager.swift @@ -38,6 +38,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var editingPathId: UUID? = nil @Published var editingPathName: String? = nil @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? @@ -466,11 +467,6 @@ 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 @@ -482,8 +478,15 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { return PathSegment(locations: sortedLocations) } - // Create new path with segments and preserve captured photos - let recordedPath = RecordedPath(segments: segments, name: editingPathName, photos: capturedPhotos) + 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() diff --git a/PathRecorder/Models/PathSegment.swift b/PathRecorder/Models/PathSegment.swift index 0ccad5e..3e8092c 100644 --- a/PathRecorder/Models/PathSegment.swift +++ b/PathRecorder/Models/PathSegment.swift @@ -10,6 +10,11 @@ struct PathSegment: Identifiable, Codable { self.id = UUID() self.locations = locations } + + init(id: UUID, locations: [GPSLocation]) { + self.id = id + self.locations = locations + } var startTime: Date { locations.first?.timestamp ?? Date() diff --git a/PathRecorder/Models/RecordedPath.swift b/PathRecorder/Models/RecordedPath.swift index fb809d0..572d30a 100644 --- a/PathRecorder/Models/RecordedPath.swift +++ b/PathRecorder/Models/RecordedPath.swift @@ -19,6 +19,13 @@ struct RecordedPath: Identifiable, Codable, Hashable { 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 { @@ -83,6 +90,14 @@ struct GPSLocation: Identifiable, Codable, Equatable { 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/Services/PathStorage.swift b/PathRecorder/Services/PathStorage.swift index 2896a59..c3862eb 100644 --- a/PathRecorder/Services/PathStorage.swift +++ b/PathRecorder/Services/PathStorage.swift @@ -15,7 +15,11 @@ final class PathStorage: ObservableObject { } func savePath(_ path: RecordedPath) { - recordedPaths.append(path) + if let index = recordedPaths.firstIndex(where: { $0.id == path.id }) { + recordedPaths[index] = path + } else { + recordedPaths.append(path) + } saveToUserDefaults() } diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index c7bdfb8..f8fa69d 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -96,31 +96,33 @@ struct SettingsView: View { Text(authManager.displayPhone(for: authManager.currentUser)) .foregroundColor(.secondary) } - Button { - Task { await uploadBackup() } - } label: { - if authManager.isUploadingBackup { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Backing up... \(Int(authManager.backupProgress * 100))%") - .font(.subheadline) - Spacer() - if let remaining = estimatedTimeRemaining { - Text(remaining) - .font(.caption) - .foregroundColor(.secondary) + if authManager.isUploadingBackup || authManager.hasUnsyncedPaths { + Button { + Task { await uploadBackup() } + } label: { + if authManager.isUploadingBackup { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Backing up... \(Int(authManager.backupProgress * 100))%") + .font(.subheadline) + Spacer() + if let remaining = estimatedTimeRemaining { + Text(remaining) + .font(.caption) + .foregroundColor(.secondary) + } } + ProgressView(value: authManager.backupProgress) + } + } else { + HStack { + Image(systemName: "icloud.and.arrow.up") + Text("Backup to Cloud") } - ProgressView(value: authManager.backupProgress) - } - } else { - HStack { - Image(systemName: "icloud.and.arrow.up") - Text("Backup to Cloud") } } + .disabled(authManager.isUploadingBackup) } - .disabled(authManager.isUploadingBackup) Button(role: .destructive) { Task { await signOut() } @@ -244,9 +246,13 @@ struct SettingsView: View { var locationRows: [LocationRow] = [] var photoRows: [PhotoRow] = [] - let totalPhotos = pathStorage.recordedPaths.reduce(0) { $0 + $1.photos.count } + let pathsToUpload = authManager.unsyncedPathIds.union(authManager.dirtyPathIds) + let pathsToBackup = pathStorage.recordedPaths.filter { pathsToUpload.contains($0.id) } + let totalPhotos = pathsToBackup.reduce(0) { $0 + $1.photos.count } var uploadedPhotos = 0 - for path in pathStorage.recordedPaths { + print("[Backup] \(pathsToBackup.count) unsynced paths, \(totalPhotos) photos total") + for path in pathsToBackup { + print("[Backup] path '\(path.name)' — segments: \(path.segments.count), photos: \(path.photos.count)") pathRows.append(PathRow( id: path.id, user_id: userId, @@ -270,7 +276,11 @@ struct SettingsView: View { for photo in path.photos { let storagePath = "\(userId.uuidString.lowercased())/\(photo.id.uuidString.lowercased()).jpg" guard let image = photo.image, - let jpegData = image.jpegData(compressionQuality: 0.9) else { continue } + let jpegData = image.jpegData(compressionQuality: 0.9) else { + print("[Backup] ⚠️ skipping photo \(photo.id) — image missing from disk") + continue + } + print("[Backup] uploading \(storagePath) (\(jpegData.count) bytes)") try await supabase.storage .from("path-photos") .upload(storagePath, data: jpegData, options: FileOptions(contentType: "image/jpeg", upsert: true)) @@ -278,6 +288,7 @@ struct SettingsView: View { if totalPhotos > 0 { authManager.backupProgress = Double(uploadedPhotos) / Double(totalPhotos) } + print("[Backup] ✓ uploaded (\(uploadedPhotos)/\(totalPhotos))") photoRows.append(PhotoRow( id: photo.id, user_id: userId, @@ -288,21 +299,29 @@ struct SettingsView: View { } } + print("[Backup] upserting \(pathRows.count) paths, \(segmentRows.count) segments, \(locationRows.count) locations, \(photoRows.count) photos") if !pathRows.isEmpty { try await supabase.from("paths").upsert(pathRows, onConflict: "id").execute() + print("[Backup] ✓ paths") } if !segmentRows.isEmpty { try await supabase.from("path_segments").upsert(segmentRows, onConflict: "id").execute() + print("[Backup] ✓ segments") } if !locationRows.isEmpty { try await supabase.from("gps_locations").upsert(locationRows, onConflict: "id").execute() + print("[Backup] ✓ locations") } if !photoRows.isEmpty { try await supabase.from("path_photos").upsert(photoRows, onConflict: "id").execute() + print("[Backup] ✓ photos") } + authManager.dirtyPathIds.subtract(pathsToUpload) backupSuccessMessage = "Your data has been backed up to the cloud." + await authManager.refreshSyncStatus(localPaths: pathStorage.recordedPaths) } catch { + print("[Backup] ❌ \(error)") authErrorMessage = error.localizedDescription } } diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift index 22ae99d..9646b64 100644 --- a/PathRecorder/Supabase.swift +++ b/PathRecorder/Supabase.swift @@ -21,6 +21,9 @@ final class AuthManager: ObservableObject { @Published var isUploadingBackup = false @Published var backupProgress: Double = 0.0 @Published var backupStartTime: Date? = nil + @Published var unsyncedPathIds: Set = [] + @Published var dirtyPathIds: Set = [] + var hasUnsyncedPaths: Bool { !unsyncedPathIds.isEmpty || !dirtyPathIds.isEmpty } private var authListenerTask: Task? @@ -102,6 +105,116 @@ final class AuthManager: ObservableObject { .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() + } + + // MARK: - Cloud Sync + + func syncOnLogin(pathStorage: PathStorage) 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 { + await restorePaths(ids: toRestore, pathStorage: pathStorage) + } + + 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) } + } + + private func restorePaths(ids: [UUID], pathStorage: PathStorage) async { + struct ServerPath: Decodable { let id: UUID; let name: String } + struct ServerSegment: Decodable { let id: UUID; let path_id: UUID } + struct ServerLocation: Decodable { + let id: UUID; let segment_id: UUID + let latitude: Double; let longitude: Double; let timestamp: Date + } + struct ServerPhoto: Decodable { + let id: UUID; let location_id: UUID; let timestamp: Date; let storage_path: String + } + + let idStrings = ids.map { $0.uuidString.lowercased() } + + guard let paths: [ServerPath] = try? await supabase + .from("paths").select("id, name").in("id", values: idStrings) + .execute().value else { return } + + guard let segments: [ServerSegment] = try? await supabase + .from("path_segments").select("id, path_id").in("path_id", values: idStrings) + .execute().value else { return } + + let segIds = segments.map { $0.id.uuidString.lowercased() } + guard !segIds.isEmpty, + let locations: [ServerLocation] = try? await supabase + .from("gps_locations").select("id, segment_id, latitude, longitude, timestamp") + .in("segment_id", values: segIds).execute().value else { return } + + let locIds = locations.map { $0.id.uuidString.lowercased() } + let photos: [ServerPhoto] = locIds.isEmpty ? [] : + ((try? await supabase.from("path_photos") + .select("id, location_id, timestamp, storage_path") + .in("location_id", values: locIds).execute().value) ?? []) + + for photo in photos { + let filename = "\(photo.id.uuidString.lowercased()).jpg" + let url = PathPhoto.imagesDirectory.appendingPathComponent(filename) + guard !FileManager.default.fileExists(atPath: url.path), + let data = try? await supabase.storage + .from("path-photos").download(path: photo.storage_path) else { continue } + try? data.write(to: url) + } + + for path in paths { + let pathSegs = segments.filter { $0.path_id == path.id } + let rebuilt = pathSegs.map { seg -> PathSegment in + let locs = locations + .filter { $0.segment_id == seg.id } + .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 segLocIds = Set(locations.filter { pathSegs.map { $0.id }.contains($0.segment_id) }.map { $0.id }) + let pathPhotos = photos.filter { segLocIds.contains($0.location_id) }.map { + PathPhoto(id: $0.id, timestamp: $0.timestamp, + imageFilename: "\($0.id.uuidString.lowercased()).jpg", + locationId: $0.location_id) + } + let recordedPath = RecordedPath(id: path.id, segments: rebuilt, name: path.name, photos: pathPhotos) + await MainActor.run { pathStorage.savePath(recordedPath) } + } + } } enum AuthFlowError: LocalizedError { From d766d775c7862d231cd765feb5f95a43e34bf05d Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 20:04:24 -0400 Subject: [PATCH 35/42] Fix crash in totalDistance when restored segment has 0 locations Range 0..<(count - 1) panics when count == 0. Guard against segments with fewer than 2 locations before entering the distance loop. Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder/Models/RecordedPath.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/PathRecorder/Models/RecordedPath.swift b/PathRecorder/Models/RecordedPath.swift index 572d30a..3ca3583 100644 --- a/PathRecorder/Models/RecordedPath.swift +++ b/PathRecorder/Models/RecordedPath.swift @@ -41,6 +41,7 @@ struct RecordedPath: Identifiable, Codable, Hashable { 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) From 5b2490f3d83e506d9e7bb53736067563b7e12c41 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 20:58:32 -0400 Subject: [PATCH 36/42] Restore: fix silent failures with nested select + batch + progress bar - Replace 4 separate .in() queries with one nested PostgREST select, eliminating URL-length failures that silently dropped segments, locations, and photos - Batch path IDs into chunks of 30 to stay under PostgREST URL limits - Add isRestoringFromCloud / restoreProgress on AuthManager - Show restore progress bar in Settings while cloud restore is running - Replace try? with do/catch and print all restore errors Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder/Settings.swift | 10 +++ PathRecorder/Supabase.swift | 135 +++++++++++++++++++++--------------- 2 files changed, 91 insertions(+), 54 deletions(-) diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index f8fa69d..4ebdca8 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -96,6 +96,16 @@ struct SettingsView: View { Text(authManager.displayPhone(for: authManager.currentUser)) .foregroundColor(.secondary) } + if authManager.isRestoringFromCloud { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Restoring from cloud... \(Int(authManager.restoreProgress * 100))%") + .font(.subheadline) + Spacer() + } + ProgressView(value: authManager.restoreProgress) + } + } if authManager.isUploadingBackup || authManager.hasUnsyncedPaths { Button { Task { await uploadBackup() } diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift index 9646b64..9840a5b 100644 --- a/PathRecorder/Supabase.swift +++ b/PathRecorder/Supabase.swift @@ -21,6 +21,8 @@ final class AuthManager: ObservableObject { @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 unsyncedPathIds: Set = [] @Published var dirtyPathIds: Set = [] var hasUnsyncedPaths: Bool { !unsyncedPathIds.isEmpty || !dirtyPathIds.isEmpty } @@ -131,7 +133,12 @@ final class AuthManager: ObservableObject { let toRestore = Array(serverIds.subtracting(localIds)) if !toRestore.isEmpty { + print("[Restore] \(toRestore.count) paths to restore from cloud") + isRestoringFromCloud = true + restoreProgress = 0.0 await restorePaths(ids: toRestore, pathStorage: pathStorage) + isRestoringFromCloud = false + restoreProgress = 0.0 } let updatedLocalIds = Set(pathStorage.recordedPaths.map { $0.id }) @@ -153,66 +160,86 @@ final class AuthManager: ObservableObject { } private func restorePaths(ids: [UUID], pathStorage: PathStorage) async { - struct ServerPath: Decodable { let id: UUID; let name: String } - struct ServerSegment: Decodable { let id: UUID; let path_id: UUID } + 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 segment_id: UUID - let latitude: Double; let longitude: Double; let timestamp: Date + let id: UUID; let latitude: Double; let longitude: Double + let timestamp: Date; let path_photos: [ServerPhoto] } - struct ServerPhoto: Decodable { - let id: UUID; let location_id: UUID; let timestamp: Date; let storage_path: String + struct ServerSegment: Decodable { + let id: UUID; let gps_locations: [ServerLocation] + } + struct ServerPath: Decodable { + let id: UUID; let name: String; let path_segments: [ServerSegment] } - let idStrings = ids.map { $0.uuidString.lowercased() } - - guard let paths: [ServerPath] = try? await supabase - .from("paths").select("id, name").in("id", values: idStrings) - .execute().value else { return } - - guard let segments: [ServerSegment] = try? await supabase - .from("path_segments").select("id, path_id").in("path_id", values: idStrings) - .execute().value else { return } + // 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 = locations - .filter { $0.segment_id == seg.id } - .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 segLocIds = Set(locations.filter { pathSegs.map { $0.id }.contains($0.segment_id) }.map { $0.id }) - let pathPhotos = photos.filter { segLocIds.contains($0.location_id) }.map { - PathPhoto(id: $0.id, timestamp: $0.timestamp, - imageFilename: "\($0.id.uuidString.lowercased()).jpg", - locationId: $0.location_id) + for chunk in chunks { + let idStrings = chunk.map { $0.uuidString.lowercased() } + let paths: [ServerPath] + do { + paths = try await supabase + .from("paths") + .select("id, name, path_segments(id, gps_locations(id, latitude, longitude, timestamp, path_photos(id, timestamp, storage_path)))") + .in("id", values: idStrings) + .execute().value + } catch { + print("[Restore] ❌ chunk fetch failed: \(error)") + continue + } + print("[Restore] fetched \(paths.count) paths") + + for path in paths { + let allLocations = path.path_segments.flatMap { $0.gps_locations } + let allPhotos = allLocations.flatMap { $0.path_photos } + + for photo in allPhotos { + let filename = "\(photo.id.uuidString.lowercased()).jpg" + let url = PathPhoto.imagesDirectory.appendingPathComponent(filename) + guard !FileManager.default.fileExists(atPath: url.path) else { continue } + do { + let data = try await supabase.storage + .from("path-photos").download(path: photo.storage_path) + try? data.write(to: url) + } catch { + print("[Restore] ⚠️ photo download failed: \(error)") + } + } + + let segments = path.path_segments.map { seg -> 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 + } } - let recordedPath = RecordedPath(id: path.id, segments: rebuilt, name: path.name, photos: pathPhotos) - await MainActor.run { pathStorage.savePath(recordedPath) } } } } From 208804f38cb816c13b967e1c1852b60d60a0b80a Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 22:18:25 -0400 Subject: [PATCH 37/42] Settings: add country code picker for phone auth, fix OTP flow UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Country code selector (50 countries, default US) with searchable sheet - fullPhone strips non-digit chars before combining with dial code (E.164) - OTP state resets when phone number or country changes - Send Code disabled until valid phone length (6–14 local digits) - Verify Code replaces Resend Code once all 6 OTP digits are entered - Backup time remaining formatted as y/d/h/m/s - Fix Supabase.swift mistakenly included in Copy Bundle Resources phase Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder.xcodeproj/project.pbxproj | 8 - PathRecorder/Settings.swift | 201 ++++++++++++++++++++++--- PathRecorder/Supabase.swift | 2 +- 3 files changed, 179 insertions(+), 32 deletions(-) diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 92fedb6..0a99559 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -85,13 +85,6 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ - B9AD17792F65C51F00DB89CE /* Exceptions for "PathRecorder" folder in "Copy Bundle Resources" phase from "PathRecorder" target */ = { - isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; - buildPhase = 6141C8D12DECACB90034946C /* Resources */; - membershipExceptions = ( - Supabase.swift, - ); - }; /* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -99,7 +92,6 @@ isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( B9AAE7DD2E34815900CD6869 /* Exceptions for "PathRecorder" folder in "PathRecorder" target */, - B9AD17792F65C51F00DB89CE /* Exceptions for "PathRecorder" folder in "Copy Bundle Resources" phase from "PathRecorder" target */, ); path = PathRecorder; sourceTree = ""; diff --git a/PathRecorder/Settings.swift b/PathRecorder/Settings.swift index 4ebdca8..1ad7449 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -3,6 +3,114 @@ 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" @@ -70,6 +178,8 @@ struct SettingsView: View { @State private var isSigningOut = false @State private var backupSuccessMessage: String? = nil // 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 @@ -77,17 +187,18 @@ struct SettingsView: View { @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("Distance Units")) { - Picker("Distance Unit", selection: $settings.distanceUnit) { - ForEach(DistanceUnit.allCases, id: \.self) { unit in - Text(unit.displayName).tag(unit) - } - } - .pickerStyle(SegmentedPickerStyle()) - } Section(header: Text("Account")) { if authManager.isAuthenticated { HStack { @@ -145,38 +256,69 @@ struct SettingsView: View { } .disabled(isSigningOut || authManager.isUploadingBackup) } else { - TextField("+15551234567", text: $authPhone) - .keyboardType(.phonePad) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) + 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) - Button(isSendingOTP ? "Sending..." : "Send Code") { - Task { await sendOTP() } + TextField("Phone number", text: $authPhone) + .keyboardType(.phonePad) + .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 = "" } } - .disabled(isSendingOTP || authPhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) 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 || authOTP.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - Button("Resend Code") { + .disabled(isVerifyingOTP) + } else { + Button(isSendingOTP ? "Sending..." : didRequestOTP ? "Resend Code" : "Send Code") { Task { await sendOTP() } } - .disabled(isSendingOTP) + .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) @@ -209,7 +351,19 @@ struct SettingsView: View { let total = elapsed / authManager.backupProgress let remaining = total - elapsed guard remaining > 1 else { return nil } - return "~\(Int(remaining.rounded()))s remaining" + 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 uploadBackup() async { @@ -351,7 +505,7 @@ struct SettingsView: View { authErrorMessage = nil defer { isSendingOTP = false } do { - try await authManager.requestOTP(phone: authPhone) + try await authManager.requestOTP(phone: fullPhone) didRequestOTP = true } catch { authErrorMessage = error.localizedDescription @@ -363,8 +517,9 @@ struct SettingsView: View { authErrorMessage = nil defer { isVerifyingOTP = false } do { - try await authManager.verifyOTP(phone: authPhone, token: authOTP) + try await authManager.verifyOTP(phone: fullPhone, token: authOTP) authOTP = "" + authPhone = "" didRequestOTP = false } catch { authErrorMessage = error.localizedDescription diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift index 9840a5b..8c36efd 100644 --- a/PathRecorder/Supabase.swift +++ b/PathRecorder/Supabase.swift @@ -251,7 +251,7 @@ enum AuthFlowError: LocalizedError { var errorDescription: String? { switch self { case .invalidPhone: - return "Enter a valid phone number in E.164 format (example: +15551234567)." + return "Enter a valid phone number." case .invalidOTP: return "Enter the OTP code sent to your phone." } From e1af6f787a2178c147c3d14321c9418f3105d59c Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Sun, 21 Jun 2026 22:40:54 -0400 Subject: [PATCH 38/42] Recording: navigate app during recording, per-path backup transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recording view is now a navigation push (not fullScreenCover) so the rest of the app remains navigable while a recording is active - Start Recording button becomes a pulsing red/orange indicator during recording; tapping it returns to the recording view - Phone auth: number pad (digits only), Send Code requires valid length - Backup: each path is now its own transaction — partial failures leave completed paths saved and retry only failed ones - BackupRestoreService: extracted from Settings/AuthManager with resume checkpoint (UserDefaults), BGProcessingTask background registration, and per-photo upload tracking Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder.xcodeproj/project.pbxproj | 3 - PathRecorder/AppDelegate.swift | 1 + PathRecorder/ContentView.swift | 76 ++-- PathRecorder/Info.plist | 5 + PathRecorder/PathRecorderApp.swift | 2 + PathRecorder/RecordingView.swift | 151 ++++---- .../Services/BackupRestoreService.swift | 328 ++++++++++++++++++ PathRecorder/Settings.swift | 174 ++-------- PathRecorder/Supabase.swift | 101 +----- 9 files changed, 487 insertions(+), 354 deletions(-) create mode 100644 PathRecorder/Services/BackupRestoreService.swift diff --git a/PathRecorder.xcodeproj/project.pbxproj b/PathRecorder.xcodeproj/project.pbxproj index 0a99559..3da2d8d 100644 --- a/PathRecorder.xcodeproj/project.pbxproj +++ b/PathRecorder.xcodeproj/project.pbxproj @@ -84,9 +84,6 @@ }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ -/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ -/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ - /* Begin PBXFileSystemSynchronizedRootGroup section */ 6141C8D52DECACB90034946C /* PathRecorder */ = { isa = PBXFileSystemSynchronizedRootGroup; 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 ebdcd07..aa11442 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -27,10 +27,12 @@ struct ContentView: View { } } @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 @@ -104,42 +106,56 @@ 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 { @@ -151,7 +167,7 @@ struct ContentView: View { } .onChange(of: authManager.currentUser?.id) { _, userId in if userId != nil { - Task { await authManager.syncOnLogin(pathStorage: pathStorage) } + Task { await authManager.syncOnLogin(pathStorage: pathStorage, backupService: backupService) } } else { authManager.unsyncedPathIds = [] authManager.dirtyPathIds = [] @@ -173,14 +189,7 @@ struct ContentView: View { showRenameSheet = locationManager.editingPathName == nil } } - .fullScreenCover(isPresented: Binding( - get: { showRecordingSheet }, - set: { newValue in - if !newValue { - showRecordingSheet = false - } - }) - ) { + .navigationDestination(isPresented: $showRecordingSheet) { RecordingView( locationManager: locationManager, pathStorage: pathStorage, @@ -202,6 +211,7 @@ struct ContentView: View { } .sheet(isPresented: $showSettingsSheet) { SettingsView(settings: settings, pathStorage: pathStorage) + .environmentObject(backupService) } .navigationDestination(for: RecordedPath.self) { path in PathMapView( 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/PathRecorderApp.swift b/PathRecorder/PathRecorderApp.swift index fd29447..c6ce1d9 100644 --- a/PathRecorder/PathRecorderApp.swift +++ b/PathRecorder/PathRecorderApp.swift @@ -14,6 +14,7 @@ import UIKit 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 @@ -36,6 +37,7 @@ struct PathRecorderApp: App { WindowGroup { ContentView() .environmentObject(authManager) + .environmentObject(backupService) } .modelContainer(sharedModelContainer) } diff --git a/PathRecorder/RecordingView.swift b/PathRecorder/RecordingView.swift index 734abc8..6a86d8d 100644 --- a/PathRecorder/RecordingView.swift +++ b/PathRecorder/RecordingView.swift @@ -13,108 +13,101 @@ struct RecordingView: View { @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: .center, 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) { - HStack(spacing: 10) { - Image(systemName: "figure.walk") - .foregroundColor(.green) - .font(.subheadline) - Text(settings.formatDistance(locationManager.totalDistance)) - } - if locationManager.elapsedTime > 0 { - HStack(spacing: 10) { - Image(systemName: "alarm") - .foregroundColor(.orange) - .font(.subheadline) - Text(formatTime(locationManager.elapsedTime)) - } - } + Image(systemName: "figure.walk") + .foregroundColor(.green) + .font(.subheadline) + Text(settings.formatDistance(locationManager.totalDistance)) } - if !locationManager.isPaused { + if locationManager.elapsedTime > 0 { HStack(spacing: 10) { - Image(systemName: "timer") - .foregroundColor(.blue) + Image(systemName: "alarm") + .foregroundColor(.orange) .font(.subheadline) - Text("Pace: " + computePace(distanceMeters: locationManager.totalDistance, elapsedSeconds: locationManager.elapsedTime, unit: settings.distanceUnit.rawValue)) + 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 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/Settings.swift b/PathRecorder/Settings.swift index 1ad7449..443c9b8 100644 --- a/PathRecorder/Settings.swift +++ b/PathRecorder/Settings.swift @@ -173,10 +173,10 @@ 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 - @State private var backupSuccessMessage: String? = nil // Inline sign-in OTP flow @State private var selectedCountry: CountryDialCode = .us @State private var showCountryPicker = false @@ -207,24 +207,33 @@ struct SettingsView: View { Text(authManager.displayPhone(for: authManager.currentUser)) .foregroundColor(.secondary) } - if authManager.isRestoringFromCloud { + if backupService.isRestoringFromCloud { VStack(alignment: .leading, spacing: 4) { HStack { - Text("Restoring from cloud... \(Int(authManager.restoreProgress * 100))%") + Text("Restoring from cloud... \(Int(backupService.restoreProgress * 100))%") .font(.subheadline) Spacer() } - ProgressView(value: authManager.restoreProgress) + ProgressView(value: backupService.restoreProgress) } } - if authManager.isUploadingBackup || authManager.hasUnsyncedPaths { + if backupService.isUploadingBackup || authManager.hasUnsyncedPaths { Button { - Task { await uploadBackup() } + if let userId = authManager.currentUser?.id { + Task { + backupService.startBackup( + pathIds: authManager.unsyncedPathIds.union(authManager.dirtyPathIds), + allPaths: pathStorage.recordedPaths, + userId: userId, + authManager: authManager + ) + } + } } label: { - if authManager.isUploadingBackup { + if backupService.isUploadingBackup { VStack(alignment: .leading, spacing: 4) { HStack { - Text("Backing up... \(Int(authManager.backupProgress * 100))%") + Text("Backing up... \(Int(backupService.backupProgress * 100))%") .font(.subheadline) Spacer() if let remaining = estimatedTimeRemaining { @@ -233,7 +242,7 @@ struct SettingsView: View { .foregroundColor(.secondary) } } - ProgressView(value: authManager.backupProgress) + ProgressView(value: backupService.backupProgress) } } else { HStack { @@ -242,7 +251,7 @@ struct SettingsView: View { } } } - .disabled(authManager.isUploadingBackup) + .disabled(backupService.isUploadingBackup) } Button(role: .destructive) { @@ -254,7 +263,7 @@ struct SettingsView: View { Text("Sign Out") } } - .disabled(isSigningOut || authManager.isUploadingBackup) + .disabled(isSigningOut || backupService.isUploadingBackup) } else { HStack(spacing: 0) { Button { @@ -273,7 +282,7 @@ struct SettingsView: View { .buttonStyle(.plain) TextField("Phone number", text: $authPhone) - .keyboardType(.phonePad) + .keyboardType(.numberPad) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .onChange(of: authPhone) { _ in @@ -330,25 +339,26 @@ struct SettingsView: View { } } } - .alert("Backup Saved", isPresented: .constant(backupSuccessMessage != nil)) { - Button("OK") { backupSuccessMessage = nil } + .alert("Backup Saved", isPresented: .constant(backupService.backupSuccessMessage != nil)) { + Button("OK") { backupService.backupSuccessMessage = nil } } message: { - Text(backupSuccessMessage ?? "") + Text(backupService.backupSuccessMessage ?? "") } - .alert("Auth Error", isPresented: .constant(authErrorMessage != nil)) { + .alert("Auth Error", isPresented: .constant(authErrorMessage != nil || backupService.lastBackupError != nil)) { Button("OK") { authErrorMessage = nil + backupService.lastBackupError = nil } } message: { - Text(authErrorMessage ?? "Unknown error") + Text(authErrorMessage ?? backupService.lastBackupError ?? "Unknown error") } } private var estimatedTimeRemaining: String? { - guard let start = authManager.backupStartTime, - authManager.backupProgress > 0.05 else { return nil } + guard let start = backupService.backupStartTime, + backupService.backupProgress > 0.05 else { return nil } let elapsed = Date().timeIntervalSince(start) - let total = elapsed / authManager.backupProgress + let total = elapsed / backupService.backupProgress let remaining = total - elapsed guard remaining > 1 else { return nil } let secs = Int(remaining.rounded()) @@ -366,130 +376,6 @@ struct SettingsView: View { return "~\(parts.joined(separator: " ")) left" } - private func uploadBackup() async { - guard let userId = authManager.currentUser?.id else { - authErrorMessage = "Not signed in." - return - } - authManager.isUploadingBackup = true - authManager.backupProgress = 0.0 - authManager.backupStartTime = Date() - defer { - authManager.isUploadingBackup = false - authManager.backupProgress = 0.0 - authManager.backupStartTime = nil - } - do { - struct PathRow: Encodable { - let id: UUID - let user_id: UUID - let name: String - let created_at: Date - } - struct SegmentRow: Encodable { - let id: UUID - let path_id: UUID - } - struct LocationRow: Encodable { - let id: UUID - let segment_id: UUID - let latitude: Double - let longitude: Double - let timestamp: Date - } - struct PhotoRow: Encodable { - let id: UUID - let user_id: UUID - let location_id: UUID - let timestamp: Date - let storage_path: String - } - - var pathRows: [PathRow] = [] - var segmentRows: [SegmentRow] = [] - var locationRows: [LocationRow] = [] - var photoRows: [PhotoRow] = [] - - let pathsToUpload = authManager.unsyncedPathIds.union(authManager.dirtyPathIds) - let pathsToBackup = pathStorage.recordedPaths.filter { pathsToUpload.contains($0.id) } - let totalPhotos = pathsToBackup.reduce(0) { $0 + $1.photos.count } - var uploadedPhotos = 0 - print("[Backup] \(pathsToBackup.count) unsynced paths, \(totalPhotos) photos total") - for path in pathsToBackup { - print("[Backup] path '\(path.name)' — segments: \(path.segments.count), photos: \(path.photos.count)") - pathRows.append(PathRow( - id: path.id, - user_id: userId, - name: path.name, - created_at: path.startTime - )) - - 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 - )) - } - } - - for photo in path.photos { - let storagePath = "\(userId.uuidString.lowercased())/\(photo.id.uuidString.lowercased()).jpg" - guard let image = photo.image, - let jpegData = image.jpegData(compressionQuality: 0.9) else { - print("[Backup] ⚠️ skipping photo \(photo.id) — image missing from disk") - continue - } - print("[Backup] uploading \(storagePath) (\(jpegData.count) bytes)") - try await supabase.storage - .from("path-photos") - .upload(storagePath, data: jpegData, options: FileOptions(contentType: "image/jpeg", upsert: true)) - uploadedPhotos += 1 - if totalPhotos > 0 { - authManager.backupProgress = Double(uploadedPhotos) / Double(totalPhotos) - } - print("[Backup] ✓ uploaded (\(uploadedPhotos)/\(totalPhotos))") - photoRows.append(PhotoRow( - id: photo.id, - user_id: userId, - location_id: photo.locationId, - timestamp: photo.timestamp, - storage_path: storagePath - )) - } - } - - print("[Backup] upserting \(pathRows.count) paths, \(segmentRows.count) segments, \(locationRows.count) locations, \(photoRows.count) photos") - if !pathRows.isEmpty { - try await supabase.from("paths").upsert(pathRows, onConflict: "id").execute() - print("[Backup] ✓ paths") - } - if !segmentRows.isEmpty { - try await supabase.from("path_segments").upsert(segmentRows, onConflict: "id").execute() - print("[Backup] ✓ segments") - } - if !locationRows.isEmpty { - try await supabase.from("gps_locations").upsert(locationRows, onConflict: "id").execute() - print("[Backup] ✓ locations") - } - if !photoRows.isEmpty { - try await supabase.from("path_photos").upsert(photoRows, onConflict: "id").execute() - print("[Backup] ✓ photos") - } - - authManager.dirtyPathIds.subtract(pathsToUpload) - backupSuccessMessage = "Your data has been backed up to the cloud." - await authManager.refreshSyncStatus(localPaths: pathStorage.recordedPaths) - } catch { - print("[Backup] ❌ \(error)") - authErrorMessage = error.localizedDescription - } - } - private func signOut() async { isSigningOut = true defer { isSigningOut = false } diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift index 8c36efd..e21e5da 100644 --- a/PathRecorder/Supabase.swift +++ b/PathRecorder/Supabase.swift @@ -18,11 +18,6 @@ let supabase = SupabaseClient( final class AuthManager: ObservableObject { @Published var currentUser: User? @Published var isLoadingSession = true - @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 unsyncedPathIds: Set = [] @Published var dirtyPathIds: Set = [] var hasUnsyncedPaths: Bool { !unsyncedPathIds.isEmpty || !dirtyPathIds.isEmpty } @@ -121,7 +116,7 @@ final class AuthManager: ObservableObject { // MARK: - Cloud Sync - func syncOnLogin(pathStorage: PathStorage) async { + 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 @@ -134,11 +129,11 @@ final class AuthManager: ObservableObject { let toRestore = Array(serverIds.subtracting(localIds)) if !toRestore.isEmpty { print("[Restore] \(toRestore.count) paths to restore from cloud") - isRestoringFromCloud = true - restoreProgress = 0.0 - await restorePaths(ids: toRestore, pathStorage: pathStorage) - isRestoringFromCloud = false - restoreProgress = 0.0 + 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 }) @@ -158,90 +153,6 @@ final class AuthManager: ObservableObject { let localIds = Set(localPaths.map { $0.id }) await MainActor.run { unsyncedPathIds = localIds.subtracting(serverIds) } } - - private func restorePaths(ids: [UUID], pathStorage: PathStorage) 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 - } - } - } - } } enum AuthFlowError: LocalizedError { From 5d092e282defa1ce3f157907b8066f813decd139 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Mon, 22 Jun 2026 15:27:12 -0400 Subject: [PATCH 39/42] Fix inflated duration by deferring segment-start anchor to first real GPS fix markSegment() at startRecording() used currentLocation which held a stale cached timestamp from the previous session, making totalDuration (now computed from GPS timestamps rather than stored from the timer) appear as many hours. Remove the call so the first real didUpdateLocations callback anchors the segment. Add V3 migration to drop stale start anchors from existing saved paths. Co-Authored-By: Claude Sonnet 4.6 --- PathRecorder/LocationManager.swift | 1 - PathRecorder/Services/DataMigration.swift | 37 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/PathRecorder/LocationManager.swift b/PathRecorder/LocationManager.swift index 5bc58a3..0ff13f2 100644 --- a/PathRecorder/LocationManager.swift +++ b/PathRecorder/LocationManager.swift @@ -156,7 +156,6 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { 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() diff --git a/PathRecorder/Services/DataMigration.swift b/PathRecorder/Services/DataMigration.swift index f2486d9..175b8ee 100644 --- a/PathRecorder/Services/DataMigration.swift +++ b/PathRecorder/Services/DataMigration.swift @@ -7,6 +7,7 @@ class DataMigration { private let userDefaults: UserDefaults private let migratedV1Key = "DataMigrationV1Completed" private let migratedV2Key = "DataMigrationV2Completed" + private let migratedV3Key = "DataMigrationV3Completed" init(userDefaults: UserDefaults = .standard) { self.userDefaults = userDefaults @@ -21,6 +22,10 @@ class DataMigration { migrateV2() userDefaults.set(true, forKey: migratedV2Key) } + if !userDefaults.bool(forKey: migratedV3Key) { + migrateV3() + userDefaults.set(true, forKey: migratedV3Key) + } } // MARK: - V1: flat locations → segment-based format @@ -112,6 +117,38 @@ class DataMigration { 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) From 5e3732c24617eeb43c1c4e091825fe4c9526f5e9 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 4 Aug 2026 20:54:32 -0400 Subject: [PATCH 40/42] Fix blank screen on resume by deferring push past dismiss() pop Modify Path popped the current PathMapView and pushed RecordingView in the same transaction, racing a NavigationPath pop against an isPresented push and leaving the live map blank until manually backing out and re-entering. Co-Authored-By: Claude Sonnet 5 --- PathRecorder/MapComponents/StaticMap/PathMapView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index bb4393e..fdea23e 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -241,7 +241,12 @@ 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) From fd512220749c9cbad12215ae2cc3e8c95a8e08d5 Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 4 Aug 2026 22:25:28 -0400 Subject: [PATCH 41/42] Backup: mark renamed/edited paths dirty so cloud sync catches them Rename and photo add/delete previously went through PathStorage.updatePath/ deletePhoto without ever flagging the path as needing a re-upload, so the "Backup to Cloud" button stayed hidden after those edits. Track the last updated path id and surface it through the same dirtyPathIds flow already used for resumed recordings, with a content-equality guard so a no-op edit doesn't trigger a spurious upload. --- PathRecorder/ContentView.swift | 5 +++++ PathRecorder/Services/PathStorage.swift | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index aa11442..b6d227a 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -182,6 +182,11 @@ struct ContentView: View { 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 diff --git a/PathRecorder/Services/PathStorage.swift b/PathRecorder/Services/PathStorage.swift index c3862eb..23e3414 100644 --- a/PathRecorder/Services/PathStorage.swift +++ b/PathRecorder/Services/PathStorage.swift @@ -7,6 +7,7 @@ final class PathStorage: ObservableObject { } @Published var recordedPaths: [RecordedPath] = [] + @Published var lastUpdatedPathId: UUID? = nil private let userDefaults = UserDefaults.standard private let key = "RecordedPaths" @@ -30,18 +31,24 @@ final class PathStorage: ObservableObject { 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 } } From 3dc6f63f36bb7c8656ab650a58d3e78ede02bdee Mon Sep 17 00:00:00 2001 From: cranberrymuffin Date: Tue, 4 Aug 2026 22:25:36 -0400 Subject: [PATCH 42/42] Delete: unify local+cloud path delete between home list and map detail Deleting a path from the map detail sheet only removed it locally, leaving the row (and its segments/locations/photos) on Supabase forever while the home list's delete already cleaned up both. Extract the local+cloud delete into AuthManager.deletePath(_:pathStorage:) and route both call sites through it. --- PathRecorder/ContentView.swift | 4 +--- PathRecorder/MapComponents/StaticMap/PathMapView.swift | 3 ++- PathRecorder/Supabase.swift | 7 +++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/PathRecorder/ContentView.swift b/PathRecorder/ContentView.swift index b6d227a..4ac587f 100644 --- a/PathRecorder/ContentView.swift +++ b/PathRecorder/ContentView.swift @@ -91,9 +91,7 @@ struct ContentView: View { locationManager.loadPathForEditing(path, pathStorage: pathStorage) }, onDelete: { - let photoIds = path.photos.map { $0.id } - pathStorage.deletePath(id: path.id) - Task { await authManager.deleteFromCloud(pathId: path.id, photoIds: photoIds) } + authManager.deletePath(path, pathStorage: pathStorage) }, formatTime: formatTime, onSelect: { diff --git a/PathRecorder/MapComponents/StaticMap/PathMapView.swift b/PathRecorder/MapComponents/StaticMap/PathMapView.swift index fdea23e..5ec1ceb 100644 --- a/PathRecorder/MapComponents/StaticMap/PathMapView.swift +++ b/PathRecorder/MapComponents/StaticMap/PathMapView.swift @@ -5,6 +5,7 @@ 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 @@ -249,7 +250,7 @@ struct PathMapView: View { } }, onDeletePath: { - pathStorage.deletePath(id: recordedPath.id) + authManager.deletePath(recordedPath, pathStorage: pathStorage) showEditingSheet = false dismiss() } diff --git a/PathRecorder/Supabase.swift b/PathRecorder/Supabase.swift index e21e5da..ab8e48e 100644 --- a/PathRecorder/Supabase.swift +++ b/PathRecorder/Supabase.swift @@ -114,6 +114,13 @@ final class AuthManager: ObservableObject { 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 {