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
56
BanYaroGo-Info.plist
Normal file
56
BanYaroGo-Info.plist
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Ban Yaro Go</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Wir brauchen deinen Standort, um deine Gassi-Tour als Karte aufzuzeichnen.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>location</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 77;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -11,11 +11,7 @@
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
AA0000000000000000000006 /* BanYaroGo */ = {
|
AA0000000000000000000006 /* BanYaroGo */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BanYaroGo; sourceTree = "<group>"; };
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
path = BanYaroGo;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -244,14 +240,10 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Ban Yaro Go";
|
INFOPLIST_FILE = "BanYaroGo-Info.plist";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|
@ -273,14 +265,10 @@
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Ban Yaro Go";
|
INFOPLIST_FILE = "BanYaroGo-Info.plist";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ final class APIClient {
|
||||||
}()
|
}()
|
||||||
private let encoder: JSONEncoder = {
|
private let encoder: JSONEncoder = {
|
||||||
let e = JSONEncoder()
|
let e = JSONEncoder()
|
||||||
|
e.keyEncodingStrategy = .convertToSnakeCase
|
||||||
return e
|
return e
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,12 @@ struct RouteDetail: Decodable, Identifiable {
|
||||||
let userName: String?
|
let userName: String?
|
||||||
let dogIds: [Int]?
|
let dogIds: [Int]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RouteCreateBody: Encodable {
|
||||||
|
let name: String
|
||||||
|
let gpsTrack: [GPSPoint]
|
||||||
|
let distanzKm: Double
|
||||||
|
let dauerMin: Int
|
||||||
|
let dogIds: [Int]
|
||||||
|
let isPublic: Bool
|
||||||
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 721 KiB After Width: | Height: | Size: 1.1 MiB |
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
172
BanYaroGo/Views/FinishWalkSheet.swift
Normal file
172
BanYaroGo/Views/FinishWalkSheet.swift
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FinishWalkSheet: View {
|
||||||
|
let points: [GPSPoint]
|
||||||
|
let durationSeconds: Int
|
||||||
|
let distanceMeters: Double
|
||||||
|
let onDiscard: () -> Void
|
||||||
|
let onSaved: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var selectedDogIds: Set<Int> = []
|
||||||
|
@State private var dogs: [Dog] = []
|
||||||
|
@State private var isLoadingDogs = false
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
points: [GPSPoint],
|
||||||
|
durationSeconds: Int,
|
||||||
|
distanceMeters: Double,
|
||||||
|
onDiscard: @escaping () -> Void,
|
||||||
|
onSaved: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.points = points
|
||||||
|
self.durationSeconds = durationSeconds
|
||||||
|
self.distanceMeters = distanceMeters
|
||||||
|
self.onDiscard = onDiscard
|
||||||
|
self.onSaved = onSaved
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "de_DE")
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
_name = State(initialValue: "Gassi am \(formatter.string(from: .now))")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Stats") {
|
||||||
|
LabeledContent("Distanz", value: String(format: "%.2f km", distanceMeters / 1000))
|
||||||
|
LabeledContent("Dauer", value: durationLabel)
|
||||||
|
LabeledContent("Punkte", value: "\(points.count)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Name") {
|
||||||
|
TextField("Name der Tour", text: $name)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Hunde") {
|
||||||
|
if isLoadingDogs && dogs.isEmpty {
|
||||||
|
HStack { ProgressView(); Text("Lade Hunde…") }
|
||||||
|
} else if dogs.isEmpty {
|
||||||
|
Text("Keine Hunde gefunden. Wird gespeichert ohne Hund-Zuordnung.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(dogs) { dog in
|
||||||
|
dogRow(dog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Tour speichern")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Verwerfen", role: .destructive) {
|
||||||
|
onDiscard()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
if isSaving {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Button("Speichern") {
|
||||||
|
Task { await save() }
|
||||||
|
}
|
||||||
|
.disabled(canSave == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await loadDogs() }
|
||||||
|
.interactiveDismissDisabled(isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dogRow(_ dog: Dog) -> some View {
|
||||||
|
let selected = selectedDogIds.contains(dog.id)
|
||||||
|
return HStack {
|
||||||
|
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(selected ? Color.accentColor : .secondary)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(dog.name)
|
||||||
|
if let rasse = dog.rasse, !rasse.isEmpty {
|
||||||
|
Text(rasse).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
if selected {
|
||||||
|
selectedDogIds.remove(dog.id)
|
||||||
|
} else {
|
||||||
|
selectedDogIds.insert(dog.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var durationLabel: String {
|
||||||
|
let mins = durationSeconds / 60
|
||||||
|
let secs = durationSeconds % 60
|
||||||
|
if mins >= 60 {
|
||||||
|
return "\(mins / 60) h \(mins % 60) min"
|
||||||
|
}
|
||||||
|
return "\(mins) min \(secs) s"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSave: Bool {
|
||||||
|
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
&& points.count >= 2
|
||||||
|
&& !isSaving
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDogs() async {
|
||||||
|
isLoadingDogs = true
|
||||||
|
defer { isLoadingDogs = false }
|
||||||
|
do {
|
||||||
|
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
|
||||||
|
self.dogs = fetched
|
||||||
|
// If there's exactly one dog, pre-select it — saves a tap.
|
||||||
|
if fetched.count == 1 {
|
||||||
|
selectedDogIds = [fetched[0].id]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back gracefully — user can still save without dogs.
|
||||||
|
print("FinishWalkSheet loadDogs failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
isSaving = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isSaving = false }
|
||||||
|
let body = RouteCreateBody(
|
||||||
|
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
gpsTrack: points,
|
||||||
|
distanzKm: distanceMeters / 1000,
|
||||||
|
dauerMin: max(1, durationSeconds / 60),
|
||||||
|
dogIds: Array(selectedDogIds),
|
||||||
|
isPublic: false
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
let _: RouteDetail = try await APIClient.shared.post("/api/routes", body: body)
|
||||||
|
onSaved()
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,9 @@ struct MainTabView: View {
|
||||||
RoutesListView()
|
RoutesListView()
|
||||||
.tabItem { Label("Touren", systemImage: "map.fill") }
|
.tabItem { Label("Touren", systemImage: "map.fill") }
|
||||||
|
|
||||||
|
TrackingView()
|
||||||
|
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
|
||||||
|
|
||||||
DogsListView()
|
DogsListView()
|
||||||
.tabItem { Label("Hunde", systemImage: "pawprint.fill") }
|
.tabItem { Label("Hunde", systemImage: "pawprint.fill") }
|
||||||
|
|
||||||
|
|
|
||||||
176
BanYaroGo/Views/TrackingView.swift
Normal file
176
BanYaroGo/Views/TrackingView.swift
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import SwiftUI
|
||||||
|
import MapKit
|
||||||
|
|
||||||
|
struct TrackingView: View {
|
||||||
|
@State private var tracker = LocationTracker()
|
||||||
|
@State private var now: Date = .now
|
||||||
|
@State private var showFinishSheet = false
|
||||||
|
|
||||||
|
private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if tracker.isTracking {
|
||||||
|
activeTracking
|
||||||
|
} else {
|
||||||
|
startScreen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Aufnehmen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
.onReceive(ticker) { now = $0 }
|
||||||
|
.sheet(isPresented: $showFinishSheet) {
|
||||||
|
FinishWalkSheet(
|
||||||
|
points: tracker.points,
|
||||||
|
durationSeconds: durationSeconds,
|
||||||
|
distanceMeters: tracker.totalDistanceMeters,
|
||||||
|
onDiscard: { resetTracker() },
|
||||||
|
onSaved: { resetTracker() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Active tracking
|
||||||
|
|
||||||
|
private var activeTracking: some View {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
Map {
|
||||||
|
UserAnnotation()
|
||||||
|
if tracker.points.count >= 2 {
|
||||||
|
MapPolyline(coordinates: tracker.points.map {
|
||||||
|
CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon)
|
||||||
|
})
|
||||||
|
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineJoin: .round))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
|
||||||
|
.mapControlVisibility(.hidden)
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
statsCard
|
||||||
|
Spacer()
|
||||||
|
stopButton
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statsCard: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
stat(value: String(format: "%.2f", tracker.totalDistanceMeters / 1000), unit: "km", label: "Distanz")
|
||||||
|
divider
|
||||||
|
stat(value: formatDuration(durationSeconds), unit: "", label: "Dauer")
|
||||||
|
divider
|
||||||
|
stat(value: "\(tracker.points.count)", unit: "", label: "Punkte")
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var divider: some View {
|
||||||
|
Divider().frame(height: 36)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stat(value: String, unit: String, label: String) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 2) {
|
||||||
|
Text(value).font(.title2.bold().monospacedDigit())
|
||||||
|
if !unit.isEmpty {
|
||||||
|
Text(unit).font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stopButton: some View {
|
||||||
|
Button {
|
||||||
|
tracker.stop()
|
||||||
|
showFinishSheet = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "stop.circle.fill")
|
||||||
|
Text("Aufnahme stoppen").bold()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 56)
|
||||||
|
}
|
||||||
|
.background(.red, in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Start screen
|
||||||
|
|
||||||
|
private var startScreen: some View {
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "figure.walk.circle.fill")
|
||||||
|
.font(.system(size: 96))
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("Bereit für die nächste Gassi?")
|
||||||
|
.font(.title3.bold())
|
||||||
|
Text("Tippe auf Start, um deine Tour aufzuzeichnen — auch wenn dein iPhone gesperrt ist.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tracker.permissionDenied {
|
||||||
|
permissionWarning
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
tracker.startOrRequest()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
Text("Aufnahme starten").bold()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 56)
|
||||||
|
}
|
||||||
|
.background(Color.accentColor, in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var permissionWarning: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Label("Standortzugriff fehlt", systemImage: "location.slash.fill")
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
Text("Bitte erlaube den Standortzugriff in den iOS-Einstellungen unter Datenschutz → Ortungsdienste → Ban Yaro Go.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private var durationSeconds: Int {
|
||||||
|
guard let startedAt = tracker.startedAt else { return 0 }
|
||||||
|
return Int(now.timeIntervalSince(startedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDuration(_ seconds: Int) -> String {
|
||||||
|
let h = seconds / 3600
|
||||||
|
let m = (seconds % 3600) / 60
|
||||||
|
let s = seconds % 60
|
||||||
|
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
|
||||||
|
return String(format: "%d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetTracker() {
|
||||||
|
// Fresh tracker for the next walk; old `points` arrays are released with it.
|
||||||
|
tracker = LocationTracker()
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
icon/icon.png
Normal file
BIN
icon/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Loading…
Add table
Add a link
Reference in a new issue