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:
rene 2026-05-30 10:08:02 +02:00
parent 5bac31109d
commit 0b95e3e6d1
10 changed files with 552 additions and 20 deletions

View 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
}
}
}

View file

@ -8,6 +8,9 @@ struct MainTabView: View {
RoutesListView()
.tabItem { Label("Touren", systemImage: "map.fill") }
TrackingView()
.tabItem { Label("Aufnehmen", systemImage: "figure.walk") }
DogsListView()
.tabItem { Label("Hunde", systemImage: "pawprint.fill") }

View 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()
}
}