banyaro-ios/BanYaroGo/Views/FinishWalkSheet.swift
rene c01e3d6be7 Phase 3.6: B+C+D komplett + HealthKit Sync
D.10 401-Handling: APIError.unauthorized, NotificationCenter-Bridge,
  AuthSession.logout() bei 401 → User landet wieder im Login

D.12 PWA-Deep-Links: Settings-Section mit Forum/Hunde/Walks/Settings
  öffnet Safari per https://banyaro.app/#fragment

B.4 Auto-Pause: 2-min-Inaktivität → isAutoPaused, automatischer Resume bei
  nächstem GPS-Update. Settings-Toggle, im UI eigenes Badge "Auto-Pause"
  (grau vs. Pause orange).

C.7 Edit/Delete: RouteUpdateBody + APIClient.patch + APIClient.delete,
  EditRouteSheet (Name/Beschreibung/Public), Menu in Toolbar (nur eigene
  Touren), Alert für Delete.

C.9 Statistik-Tab: neuer Tab "Statistik" zwischen Hunde und Mehr. Filtert
  /api/routes auf meine Touren, rechnet Woche/Monat/Allzeit (Distanz, Dauer,
  Touren), Längste Tour, aktuelle Streak (Tage in Folge).

B.5 Walk-Review: Map-Header an die Spitze des FinishWalkSheet-Forms.

B.6 Geo-Fotos: CapturedPhoto (Data + GPSPoint?), PhotoLocation @Model in
  SwiftData. Kamera während Walk taggt mit tracker.points.last. Nach Upload:
  foto_url aus Response → PhotoLocation persistiert. MiniRouteMap rendert
  Annotations mit Tap-Callback, PhotoViewerSheet zeigt Foto fullscreen.

C.8 Share PNG+GPX: RouteShareImage (MKMapSnapshotter + Polyline overlay +
  SwiftUI ShareCard via ImageRenderer), GPXExporter (Tempfile mit XML),
  ShareSheet (UIActivityViewController-Wrapper), Menu in Route-Toolbar.

D.11 Icon-Varianten: AppIcon-Dark (0.45 Brightness), AppIcon-Tinted
  (Grayscale + Kontrastverstärkung), Contents.json mit appearance entries.

A.2 HealthKit: BanYaroGo.entitlements (com.apple.developer.healthkit),
  NSHealthShare/UpdateUsageDescription. WalkHealthSync.shared mit
  HKWorkoutBuilder (.walking) + HKWorkoutRouteBuilder, Timestamps gleichmäßig
  über Walk-Dauer verteilt. Settings-Toggle mit Permission-Request.
2026-05-30 11:19:53 +02:00

339 lines
12 KiB
Swift

import SwiftUI
import SwiftData
import PhotosUI
struct FinishWalkSheet: View {
let points: [GPSPoint]
let durationSeconds: Int
let distanceMeters: Double
let initialPhotos: [CapturedPhoto]
let onDiscard: () -> Void
let onSaved: () -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@AppStorage("healthKitSyncEnabled") private var healthKitSyncEnabled = false
@State private var name: String
@State private var selectedDogIds: Set<Int> = []
@State private var dogs: [Dog] = []
@State private var isLoadingDogs = false
@State private var photoSelection: [PhotosPickerItem] = []
@State private var photoData: [CapturedPhoto] = []
@State private var loadingPhotos = false
@State private var saveState: SaveState = .idle
@State private var errorMessage: String?
private enum SaveState: Equatable {
case idle
case savingRoute
case uploadingPhotos(done: Int, total: Int)
}
private let shortDistanceThreshold: Double = 50 // meters
init(
points: [GPSPoint],
durationSeconds: Int,
distanceMeters: Double,
initialPhotos: [CapturedPhoto] = [],
onDiscard: @escaping () -> Void,
onSaved: @escaping () -> Void
) {
self.points = points
self.durationSeconds = durationSeconds
self.distanceMeters = distanceMeters
self.initialPhotos = initialPhotos
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))")
_photoData = State(initialValue: initialPhotos)
}
var body: some View {
NavigationStack {
Form {
if points.count >= 2 {
Section {
MiniRouteMap(track: points, lineWidth: 4)
.frame(height: 220)
.listRowInsets(EdgeInsets())
}
}
if distanceMeters < shortDistanceThreshold {
shortDistanceWarning
}
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)
}
}
}
Section {
PhotosPicker(
selection: $photoSelection,
maxSelectionCount: 10,
matching: .images
) {
Label(
photoData.isEmpty ? "Fotos hinzufügen" : "Fotos ändern",
systemImage: "photo.badge.plus"
)
}
if loadingPhotos {
HStack { ProgressView(); Text("Lade Fotos…").font(.caption).foregroundStyle(.secondary) }
}
if !photoData.isEmpty {
photoStrip
}
} header: {
Text(photoData.isEmpty ? "Fotos" : "Fotos (\(photoData.count))")
}
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()
}
.disabled(saveState != .idle)
}
ToolbarItem(placement: .confirmationAction) {
saveToolbarItem
}
}
.task { await loadDogs() }
.onChange(of: photoSelection) { _, newItems in
Task { await loadPhotos(from: newItems) }
}
.interactiveDismissDisabled(saveState != .idle)
}
}
@ViewBuilder
private var saveToolbarItem: some View {
switch saveState {
case .idle:
Button("Speichern") {
Task { await save() }
}
.disabled(canSave == false)
case .savingRoute:
ProgressView()
case .uploadingPhotos(let done, let total):
Text("\(done)/\(total)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
private var shortDistanceWarning: some View {
Section {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Sehr kurze Tour (\(Int(distanceMeters)) m). Du kannst trotzdem speichern.")
.font(.footnote)
}
}
}
private var photoStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(photoData.enumerated()), id: \.offset) { _, photo in
if let img = UIImage(data: photo.data) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(alignment: .topTrailing) {
if photo.location != nil {
Image(systemName: "location.fill")
.font(.caption2)
.foregroundStyle(.white)
.padding(4)
.background(Color.accentColor, in: Circle())
.padding(4)
}
}
}
}
}
}
}
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
&& saveState == .idle
}
private func loadDogs() async {
isLoadingDogs = true
defer { isLoadingDogs = false }
do {
let fetched: [Dog] = try await APIClient.shared.get("/api/dogs")
self.dogs = fetched
if fetched.count == 1 {
selectedDogIds = [fetched[0].id]
}
} catch {
print("FinishWalkSheet loadDogs failed: \(error)")
}
}
private func loadPhotos(from items: [PhotosPickerItem]) async {
loadingPhotos = true
defer { loadingPhotos = false }
var loaded: [CapturedPhoto] = initialPhotos // keep camera-captured ones
for item in items {
if let data = try? await item.loadTransferable(type: Data.self) {
loaded.append(CapturedPhoto(data: data, location: nil))
}
}
photoData = loaded
}
private func save() async {
errorMessage = nil
saveState = .savingRoute
let body = RouteCreateBody(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
gpsTrack: points,
distanzKm: distanceMeters / 1000,
dauerMin: max(1, durationSeconds / 60),
dogIds: Array(selectedDogIds),
isPublic: false
)
let route: RouteDetail
do {
route = try await APIClient.shared.post("/api/routes", body: body)
} catch {
errorMessage = error.localizedDescription
saveState = .idle
return
}
if !photoData.isEmpty {
for (index, photo) in photoData.enumerated() {
saveState = .uploadingPhotos(done: index, total: photoData.count)
let resized = ImageResize.resizedJPEG(from: photo.data)
do {
let responseData = try await APIClient.shared.uploadFile(
"/api/routes/\(route.id)/photo",
filename: "photo_\(index + 1).jpg",
data: resized
)
// Persist GPS location of this photo if we have one
if let coord = photo.location,
let obj = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any],
let fotoUrl = obj["foto_url"] as? String {
let loc = PhotoLocation(
routeId: route.id,
photoUrl: fotoUrl,
lat: coord.lat,
lon: coord.lon
)
modelContext.insert(loc)
}
} catch {
errorMessage = "Tour ist gespeichert, aber Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
saveState = .idle
onSaved()
return
}
}
try? modelContext.save()
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
}
// Apple Health sync (only if user opted in)
if healthKitSyncEnabled, points.count >= 2 {
let endedAt = Date.now
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
await WalkHealthSync.shared.saveWalk(
points: points,
startedAt: startedAt,
endedAt: endedAt,
distanceMeters: distanceMeters
)
}
onSaved()
dismiss()
}
}