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)
This commit is contained in:
parent
5bac31109d
commit
0b95e3e6d1
10 changed files with 552 additions and 20 deletions
127
BanYaroGo/Tracking/LocationTracker.swift
Normal file
127
BanYaroGo/Tracking/LocationTracker.swift
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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)")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue