D.10 401-Handling: APIError.unauthorized, NotificationCenter-Bridge, AuthSession.logout() bei 401 → User landet wieder im Login D.12 PWA-Deep-Links: Settings-Section mit Forum/Hunde/Walks/Settings öffnet Safari per https://banyaro.app/#fragment B.4 Auto-Pause: 2-min-Inaktivität → isAutoPaused, automatischer Resume bei nächstem GPS-Update. Settings-Toggle, im UI eigenes Badge "Auto-Pause" (grau vs. Pause orange). C.7 Edit/Delete: RouteUpdateBody + APIClient.patch + APIClient.delete, EditRouteSheet (Name/Beschreibung/Public), Menu in Toolbar (nur eigene Touren), Alert für Delete. C.9 Statistik-Tab: neuer Tab "Statistik" zwischen Hunde und Mehr. Filtert /api/routes auf meine Touren, rechnet Woche/Monat/Allzeit (Distanz, Dauer, Touren), Längste Tour, aktuelle Streak (Tage in Folge). B.5 Walk-Review: Map-Header an die Spitze des FinishWalkSheet-Forms. B.6 Geo-Fotos: CapturedPhoto (Data + GPSPoint?), PhotoLocation @Model in SwiftData. Kamera während Walk taggt mit tracker.points.last. Nach Upload: foto_url aus Response → PhotoLocation persistiert. MiniRouteMap rendert Annotations mit Tap-Callback, PhotoViewerSheet zeigt Foto fullscreen. C.8 Share PNG+GPX: RouteShareImage (MKMapSnapshotter + Polyline overlay + SwiftUI ShareCard via ImageRenderer), GPXExporter (Tempfile mit XML), ShareSheet (UIActivityViewController-Wrapper), Menu in Route-Toolbar. D.11 Icon-Varianten: AppIcon-Dark (0.45 Brightness), AppIcon-Tinted (Grayscale + Kontrastverstärkung), Contents.json mit appearance entries. A.2 HealthKit: BanYaroGo.entitlements (com.apple.developer.healthkit), NSHealthShare/UpdateUsageDescription. WalkHealthSync.shared mit HKWorkoutBuilder (.walking) + HKWorkoutRouteBuilder, Timestamps gleichmäßig über Walk-Dauer verteilt. Settings-Toggle mit Permission-Request.
223 lines
7.2 KiB
Swift
223 lines
7.2 KiB
Swift
import Foundation
|
|
import Observation
|
|
import CoreLocation
|
|
|
|
/// Wraps CLLocationManager for live Gassi-Tracking with background updates.
|
|
@Observable
|
|
@MainActor
|
|
final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
|
private let manager = CLLocationManager()
|
|
|
|
var points: [GPSPoint] = []
|
|
var isTracking: Bool = false
|
|
var isPaused: Bool = false
|
|
var isAutoPaused: Bool = false
|
|
var startedAt: Date?
|
|
var pausedAt: Date?
|
|
var accumulatedPausedSeconds: Int = 0
|
|
var accumulatedAutoPausedSeconds: Int = 0
|
|
var authorizationStatus: CLAuthorizationStatus
|
|
var permissionDenied: Bool = false
|
|
|
|
private var pendingStart: Bool = false
|
|
private var lastPointAt: Date?
|
|
private var autoPauseStartedAt: Date?
|
|
|
|
override init() {
|
|
self.authorizationStatus = manager.authorizationStatus
|
|
super.init()
|
|
manager.delegate = self
|
|
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
|
|
manager.distanceFilter = 5
|
|
manager.activityType = .fitness
|
|
manager.pausesLocationUpdatesAutomatically = false
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func startOrRequest() {
|
|
permissionDenied = false
|
|
switch authorizationStatus {
|
|
case .notDetermined:
|
|
pendingStart = true
|
|
manager.requestWhenInUseAuthorization()
|
|
case .denied, .restricted:
|
|
permissionDenied = true
|
|
case .authorizedWhenInUse, .authorizedAlways:
|
|
beginFresh()
|
|
@unknown default:
|
|
permissionDenied = true
|
|
}
|
|
}
|
|
|
|
/// Restore an in-progress walk that was interrupted by an app restart.
|
|
/// Treats the elapsed offline time as pause time so the active-tracking
|
|
/// counter doesn't jump.
|
|
func restore(
|
|
startedAt: Date,
|
|
points: [GPSPoint],
|
|
accumulatedPausedSeconds: Int,
|
|
lastUpdate: Date,
|
|
wasPaused: Bool
|
|
) {
|
|
self.startedAt = startedAt
|
|
self.points = points
|
|
// The offline gap counts as paused time.
|
|
let offlineGap = max(0, Int(Date.now.timeIntervalSince(lastUpdate)))
|
|
self.accumulatedPausedSeconds = accumulatedPausedSeconds + offlineGap
|
|
self.isTracking = true
|
|
|
|
if wasPaused {
|
|
self.isPaused = true
|
|
self.pausedAt = nil // the previous pause is rolled into accumulated
|
|
} else {
|
|
self.isPaused = false
|
|
beginUpdates()
|
|
}
|
|
}
|
|
|
|
func pause() {
|
|
guard isTracking, !isPaused else { return }
|
|
isPaused = true
|
|
pausedAt = .now
|
|
manager.stopUpdatingLocation()
|
|
}
|
|
|
|
func resume() {
|
|
guard isPaused else { return }
|
|
if let pausedAt {
|
|
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
|
|
}
|
|
pausedAt = nil
|
|
isPaused = false
|
|
beginUpdates()
|
|
}
|
|
|
|
func stop() {
|
|
if isPaused, let pausedAt {
|
|
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
|
|
}
|
|
endAutoPauseIfNeeded()
|
|
isPaused = false
|
|
pausedAt = nil
|
|
manager.stopUpdatingLocation()
|
|
manager.allowsBackgroundLocationUpdates = false
|
|
isTracking = false
|
|
}
|
|
|
|
// MARK: - Derived state
|
|
|
|
/// Active tracking time in seconds, with both manual and auto pauses removed.
|
|
var effectiveElapsedSeconds: Int {
|
|
guard let startedAt else { return 0 }
|
|
let total = Int(Date.now.timeIntervalSince(startedAt))
|
|
var paused = accumulatedPausedSeconds + accumulatedAutoPausedSeconds
|
|
if isPaused, let pausedAt {
|
|
paused += Int(Date.now.timeIntervalSince(pausedAt))
|
|
}
|
|
if isAutoPaused, let autoPauseStartedAt {
|
|
paused += Int(Date.now.timeIntervalSince(autoPauseStartedAt))
|
|
}
|
|
return max(0, total - paused)
|
|
}
|
|
|
|
/// Called from a timer in TrackingView. If no new GPS point arrived in the
|
|
/// last 2 minutes, auto-pause the duration counter without stopping GPS —
|
|
/// so we can detect the resume from the next location update.
|
|
func checkAutoPause() {
|
|
guard isTracking, !isPaused, !isAutoPaused else { return }
|
|
let enabled = UserDefaults.standard.object(forKey: "autoPauseEnabled") as? Bool ?? true
|
|
guard enabled else { return }
|
|
guard let last = lastPointAt else { return }
|
|
if Date.now.timeIntervalSince(last) > 120 {
|
|
isAutoPaused = true
|
|
autoPauseStartedAt = .now
|
|
}
|
|
}
|
|
|
|
private func endAutoPauseIfNeeded() {
|
|
guard isAutoPaused else { return }
|
|
if let started = autoPauseStartedAt {
|
|
accumulatedAutoPausedSeconds += Int(Date.now.timeIntervalSince(started))
|
|
}
|
|
isAutoPaused = false
|
|
autoPauseStartedAt = nil
|
|
}
|
|
|
|
var totalDistanceMeters: Double {
|
|
guard points.count >= 2 else { return 0 }
|
|
var total: Double = 0
|
|
for i in 1..<points.count {
|
|
let a = CLLocation(latitude: points[i - 1].lat, longitude: points[i - 1].lon)
|
|
let b = CLLocation(latitude: points[i].lat, longitude: points[i].lon)
|
|
total += b.distance(from: a)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
private func beginFresh() {
|
|
points = []
|
|
startedAt = .now
|
|
pausedAt = nil
|
|
accumulatedPausedSeconds = 0
|
|
isPaused = false
|
|
isTracking = true
|
|
beginUpdates()
|
|
if authorizationStatus == .authorizedWhenInUse {
|
|
manager.requestAlwaysAuthorization()
|
|
}
|
|
}
|
|
|
|
private func beginUpdates() {
|
|
manager.allowsBackgroundLocationUpdates = true
|
|
manager.showsBackgroundLocationIndicator = true
|
|
manager.startUpdatingLocation()
|
|
}
|
|
|
|
// MARK: - CLLocationManagerDelegate
|
|
|
|
nonisolated func locationManager(
|
|
_ manager: CLLocationManager,
|
|
didUpdateLocations locations: [CLLocation]
|
|
) {
|
|
let newPoints = locations
|
|
.filter { $0.horizontalAccuracy >= 0 && $0.horizontalAccuracy < 30 }
|
|
.map { GPSPoint(lat: $0.coordinate.latitude, lon: $0.coordinate.longitude, alt: $0.altitude) }
|
|
guard !newPoints.isEmpty else { return }
|
|
Task { @MainActor in
|
|
guard self.isTracking, !self.isPaused else { return }
|
|
|
|
// If we were auto-paused, the very arrival of a new GPS update
|
|
// means movement resumed.
|
|
self.endAutoPauseIfNeeded()
|
|
|
|
self.points.append(contentsOf: newPoints)
|
|
self.lastPointAt = .now
|
|
}
|
|
}
|
|
|
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
let status = manager.authorizationStatus
|
|
Task { @MainActor in
|
|
self.authorizationStatus = status
|
|
switch status {
|
|
case .denied, .restricted:
|
|
self.pendingStart = false
|
|
self.permissionDenied = true
|
|
case .authorizedWhenInUse, .authorizedAlways:
|
|
if self.pendingStart {
|
|
self.pendingStart = false
|
|
self.beginFresh()
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
print("LocationTracker error: \(error)")
|
|
}
|
|
}
|