banyaro-ios/BanYaroGo/Views/FinishWalkSheet.swift
rene a2646a18ef 1.1: Offline-Cache + Outbox für Touren/Tagebuch, WeatherKit-Fix, Aufräumen
App-Review-Fix (Guideline 2.1 WeatherKit):
- OneShotLocation: deterministisches async resolve() mit 10s-Timeout statt
  onChange-Lauschen; WetterView lädt bei fehlendem Standort einen Berlin-Fallback
  → kein ewiges Hängen bei "Hole Standort…", WeatherKit ist immer sichtbar.

Offline-Lesen (SwiftData):
- CachedRoute/CachedDiaryEntry/CachedImage + CachedAsyncImage: Touren, Tagebuch
  und Fotos werden cache-first geladen und sind offline verfügbar.
- Cache wird bei Logout/401 geleert (RootView), kein Durchschimmern fremder User.

Offline-Speichern (Outbox):
- PendingRoute/PendingRoutePhoto: Tour inkl. unterwegs hinzugefügter Fotos wird
  offline lokal gesichert und automatisch hochgeladen (Touren-Tab + App-Start).
- Touren-Liste zeigt offline gesicherte Touren mit "wird hochgeladen"-Badge.

FinishWalkSheet:
- Dismiss-Schutz: Speichern-Dialog lässt sich nicht mehr wegwischen — eine
  aufgezeichnete Tour geht nicht mehr durch Runterwischen verloren.

Wetter:
- Ortslabel (Reverse-Geocoding; Fallback "Berlin · Näherung").
- Saubere Offline-Meldung statt rohem networkError.

Aufräumen:
- Doppeltes "Gassi-Treffen" im Mehr-Tab entfernt.
- Veraltete Phase-1/2-Texte neu getextet.
- Tote DogsListView gelöscht (Hund-Wechsel läuft über den Heim-Picker).
2026-06-02 19:37:30 +02:00

359 lines
13 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?
@State private var savedOffline = false
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) }
}
// Immer blockieren: eine aufgezeichnete Tour darf nicht durch
// versehentliches Runterwischen verloren gehen nur Speichern"
// oder Verwerfen" beenden das Sheet.
.interactiveDismissDisabled(true)
.alert("Offline gespeichert", isPresented: $savedOffline) {
Button("OK") { onSaved(); dismiss() }
} message: {
Text("Keine Internetverbindung. Die Tour ist lokal gesichert und wird automatisch hochgeladen, sobald du wieder online bist.")
}
}
}
@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 {
// Transportfehler (offline) Tour inkl. Fotos lokal in die Outbox,
// wird automatisch hochgeladen, sobald wieder Netz da ist.
if error is URLError {
OfflineCache.savePendingRoute(body: body, photos: photoData, in: modelContext)
await syncHealthIfEnabled()
saveState = .idle
savedOffline = true
return
}
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)
}
await syncHealthIfEnabled()
onSaved()
dismiss()
}
private func syncHealthIfEnabled() async {
guard healthKitSyncEnabled, points.count >= 2 else { return }
let endedAt = Date.now
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
await WalkHealthSync.shared.saveWalk(
points: points,
startedAt: startedAt,
endedAt: endedAt,
distanceMeters: distanceMeters
)
}
}