banyaro-ios/BanYaroGo/Tracking/LocationTracker.swift
rene 0b95e3e6d1 Phase 2: Live-GPS-Tracking + neues Icon
- BanYaroGo-Info.plist (explizit, statt INFOPLIST_KEY_*): UIBackgroundModes
  location, NSLocationWhenInUseUsageDescription,
  NSLocationAlwaysAndWhenInUseUsageDescription
- LocationTracker: CLLocationManager-Wrapper (@Observable @MainActor), Distanz
  via CLLocation.distance, Permission-Handling, Background-Updates
- RouteCreateBody + Encoder mit convertToSnakeCase für POST /api/routes
- TrackingView: Start-Hero-Screen + Live-Karte mit MapPolyline + Stats-Karte
- FinishWalkSheet: Name + Hunde-Multiselect + POST /api/routes
- MainTabView: neuer Aufnehmen-Tab zwischen Touren und Hunde
- AppIcon: neues Hund-mit-GPS-Pin (vom User bereitgestellt, weiße Ränder
  weggeschnitten + Ecken mit Hintergrundfarbe gefüllt)
2026-05-30 10:08:02 +02:00

127 lines
4.3 KiB
Swift

import Foundation
import Observation
import CoreLocation
/// Wraps CLLocationManager for live Gassi-Tracking with background updates.
///
/// Usage:
/// 1. `startOrRequest()` handles permission flow + starts recording when granted.
/// 2. Watch `isTracking`, `points`, `totalDistanceMeters`, `startedAt`.
/// 3. `stop()` to end the recording and inspect the final `points`.
@Observable
@MainActor
final class LocationTracker: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var points: [GPSPoint] = []
var isTracking: Bool = false
var startedAt: Date?
var authorizationStatus: CLAuthorizationStatus
var permissionDenied: Bool = false
/// Set when the user asked to start but we're still waiting for the system
/// permission prompt. The delegate auto-starts as soon as access is granted.
private var pendingStart: Bool = false
override init() {
self.authorizationStatus = manager.authorizationStatus
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.distanceFilter = 5 // meters
manager.activityType = .fitness
manager.pausesLocationUpdatesAutomatically = false
}
// MARK: - Public API
func startOrRequest() {
permissionDenied = false
switch authorizationStatus {
case .notDetermined:
pendingStart = true
manager.requestWhenInUseAuthorization()
case .denied, .restricted:
permissionDenied = true
case .authorizedWhenInUse, .authorizedAlways:
beginTracking()
@unknown default:
permissionDenied = true
}
}
func stop() {
manager.stopUpdatingLocation()
manager.allowsBackgroundLocationUpdates = false
isTracking = false
}
/// Total distance walked so far, in meters.
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 beginTracking() {
points = []
startedAt = .now
manager.allowsBackgroundLocationUpdates = true
manager.showsBackgroundLocationIndicator = true
manager.startUpdatingLocation()
isTracking = true
// If we only have "when in use", silently try to escalate to "always".
// System will show the secondary prompt on its own schedule.
if authorizationStatus == .authorizedWhenInUse {
manager.requestAlwaysAuthorization()
}
}
// 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
self.points.append(contentsOf: newPoints)
}
}
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.beginTracking()
}
default:
break
}
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Transient errors (e.g. no fix yet) are common we just keep listening.
// Log to console so we can see during development.
print("LocationTracker error: \(error)")
}
}