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.
This commit is contained in:
rene 2026-05-30 11:19:53 +02:00
parent 30e0fbe7ec
commit c01e3d6be7
26 changed files with 978 additions and 28 deletions

View file

@ -1,15 +1,18 @@
import SwiftUI
import SwiftData
import PhotosUI
struct FinishWalkSheet: View {
let points: [GPSPoint]
let durationSeconds: Int
let distanceMeters: Double
let initialPhotos: [Data]
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> = []
@ -17,7 +20,7 @@ struct FinishWalkSheet: View {
@State private var isLoadingDogs = false
@State private var photoSelection: [PhotosPickerItem] = []
@State private var photoData: [Data] = []
@State private var photoData: [CapturedPhoto] = []
@State private var loadingPhotos = false
@State private var saveState: SaveState = .idle
@ -35,7 +38,7 @@ struct FinishWalkSheet: View {
points: [GPSPoint],
durationSeconds: Int,
distanceMeters: Double,
initialPhotos: [Data] = [],
initialPhotos: [CapturedPhoto] = [],
onDiscard: @escaping () -> Void,
onSaved: @escaping () -> Void
) {
@ -56,6 +59,14 @@ struct FinishWalkSheet: View {
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
}
@ -168,13 +179,23 @@ struct FinishWalkSheet: View {
private var photoStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(photoData.enumerated()), id: \.offset) { _, data in
if let img = UIImage(data: data) {
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)
}
}
}
}
}
@ -236,10 +257,10 @@ struct FinishWalkSheet: View {
private func loadPhotos(from items: [PhotosPickerItem]) async {
loadingPhotos = true
defer { loadingPhotos = false }
var loaded: [Data] = []
var loaded: [CapturedPhoto] = initialPhotos // keep camera-captured ones
for item in items {
if let data = try? await item.loadTransferable(type: Data.self) {
loaded.append(data)
loaded.append(CapturedPhoto(data: data, location: nil))
}
}
photoData = loaded
@ -268,15 +289,27 @@ struct FinishWalkSheet: View {
}
if !photoData.isEmpty {
for (index, raw) in photoData.enumerated() {
for (index, photo) in photoData.enumerated() {
saveState = .uploadingPhotos(done: index, total: photoData.count)
let resized = ImageResize.resizedJPEG(from: raw)
let resized = ImageResize.resizedJPEG(from: photo.data)
do {
try await APIClient.shared.uploadFile(
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
@ -284,9 +317,22 @@ struct FinishWalkSheet: View {
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()
}