banyaro-ios/BanYaroGo/Tracking/LocationTracker.swift
rene c01e3d6be7 Phase 3.6: B+C+D komplett + HealthKit Sync
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.
2026-05-30 11:19:53 +02:00

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)")
}
}