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:
parent
30e0fbe7ec
commit
c01e3d6be7
26 changed files with 978 additions and 28 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue