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
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue