diff --git a/BanYaroGo-Info.plist b/BanYaroGo-Info.plist
index c03d2c8..6dfd178 100644
--- a/BanYaroGo-Info.plist
+++ b/BanYaroGo-Info.plist
@@ -50,6 +50,10 @@
Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.
NSCameraUsageDescription
Für Fotos während deiner Gassi-Tour brauchen wir Zugriff auf die Kamera.
+ NSHealthShareUsageDescription
+ Wir lesen keine Daten aus Apple Health.
+ NSHealthUpdateUsageDescription
+ Auf Wunsch speichern wir deine Gassi-Touren als Spaziergang-Workout mit Route in Apple Health.
UIBackgroundModes
location
diff --git a/BanYaroGo.entitlements b/BanYaroGo.entitlements
new file mode 100644
index 0000000..2ab14a2
--- /dev/null
+++ b/BanYaroGo.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.developer.healthkit
+
+ com.apple.developer.healthkit.access
+
+
+
diff --git a/BanYaroGo.xcodeproj/project.pbxproj b/BanYaroGo.xcodeproj/project.pbxproj
index 497db00..074f142 100644
--- a/BanYaroGo.xcodeproj/project.pbxproj
+++ b/BanYaroGo.xcodeproj/project.pbxproj
@@ -238,6 +238,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
@@ -263,6 +264,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
diff --git a/BanYaroGo/API/APIClient.swift b/BanYaroGo/API/APIClient.swift
index 434ee6a..69942ec 100644
--- a/BanYaroGo/API/APIClient.swift
+++ b/BanYaroGo/API/APIClient.swift
@@ -85,6 +85,10 @@ final class APIClient {
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
+ if http.statusCode == 401 {
+ NotificationCenter.default.post(name: .apiUnauthorized, object: nil)
+ throw APIError.unauthorized
+ }
guard (200..<300).contains(http.statusCode) else {
let detail = Self.parseErrorDetail(from: data)
throw APIError.server(status: http.statusCode, message: detail)
@@ -92,6 +96,45 @@ final class APIClient {
return try decoder.decode(T.self, from: data)
}
+ /// Convenience for DELETE with no response body.
+ func delete(_ path: String) async throws {
+ let url = baseURL.appending(path: path)
+ var req = URLRequest(url: url)
+ req.httpMethod = "DELETE"
+ if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
+ let (data, response) = try await session.data(for: req)
+ guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+ if http.statusCode == 401 {
+ NotificationCenter.default.post(name: .apiUnauthorized, object: nil)
+ throw APIError.unauthorized
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw APIError.server(status: http.statusCode, message: Self.parseErrorDetail(from: data))
+ }
+ }
+
+ /// Convenience for PATCH with JSON body, decoding the response.
+ func patch(_ path: String, body: B) async throws -> T {
+ let data = try encoder.encode(body)
+ let url = baseURL.appending(path: path)
+ var req = URLRequest(url: url)
+ req.httpMethod = "PATCH"
+ req.setValue("application/json", forHTTPHeaderField: "Accept")
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
+ req.httpBody = data
+ let (respData, response) = try await session.data(for: req)
+ guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
+ if http.statusCode == 401 {
+ NotificationCenter.default.post(name: .apiUnauthorized, object: nil)
+ throw APIError.unauthorized
+ }
+ guard (200..<300).contains(http.statusCode) else {
+ throw APIError.server(status: http.statusCode, message: Self.parseErrorDetail(from: respData))
+ }
+ return try decoder.decode(T.self, from: respData)
+ }
+
/// FastAPI returns errors as {"detail": "message"} or {"detail": [{...}]}.
private static func parseErrorDetail(from data: Data) -> String? {
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
diff --git a/BanYaroGo/API/APIError.swift b/BanYaroGo/API/APIError.swift
index e283a75..9a81298 100644
--- a/BanYaroGo/API/APIError.swift
+++ b/BanYaroGo/API/APIError.swift
@@ -2,15 +2,24 @@ import Foundation
enum APIError: LocalizedError {
case invalidResponse
+ case unauthorized
case server(status: Int, message: String?)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Ungültige Server-Antwort."
+ case .unauthorized:
+ return "Bitte erneut anmelden."
case .server(let status, let message):
if let msg = message, !msg.isEmpty { return msg }
return "Fehler vom Server (HTTP \(status))."
}
}
}
+
+extension Notification.Name {
+ /// Posted when any API call returns HTTP 401 — AuthSession listens and
+ /// logs out so the user lands back on the LoginView.
+ static let apiUnauthorized = Notification.Name("BanYaroGo.apiUnauthorized")
+}
diff --git a/BanYaroGo/API/DTOs.swift b/BanYaroGo/API/DTOs.swift
index 99c534d..194e9f3 100644
--- a/BanYaroGo/API/DTOs.swift
+++ b/BanYaroGo/API/DTOs.swift
@@ -88,3 +88,23 @@ struct RouteCreateBody: Encodable {
let dogIds: [Int]
let isPublic: Bool
}
+
+/// Patch body for PATCH /api/routes/{id}. Only non-nil fields are encoded.
+struct RouteUpdateBody: Encodable {
+ var name: String?
+ var beschreibung: String?
+ var isPublic: Bool?
+
+ enum CodingKeys: String, CodingKey {
+ case name
+ case beschreibung
+ case isPublic
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encodeIfPresent(name, forKey: .name)
+ try c.encodeIfPresent(beschreibung, forKey: .beschreibung)
+ try c.encodeIfPresent(isPublic, forKey: .isPublic)
+ }
+}
diff --git a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Dark.png b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Dark.png
new file mode 100644
index 0000000..d77f8e8
Binary files /dev/null and b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Dark.png differ
diff --git a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Tinted.png b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Tinted.png
new file mode 100644
index 0000000..99d04d7
Binary files /dev/null and b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Tinted.png differ
diff --git a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/Contents.json b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/Contents.json
index cefcc87..e02c1e5 100644
--- a/BanYaroGo/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/BanYaroGo/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -5,6 +5,30 @@
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "AppIcon-Dark.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "filename" : "AppIcon-Tinted.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
}
],
"info" : {
diff --git a/BanYaroGo/Auth/AuthSession.swift b/BanYaroGo/Auth/AuthSession.swift
index 8e908bc..6da8365 100644
--- a/BanYaroGo/Auth/AuthSession.swift
+++ b/BanYaroGo/Auth/AuthSession.swift
@@ -18,6 +18,13 @@ final class AuthSession {
token = savedToken
APIClient.shared.token = savedToken
}
+ NotificationCenter.default.addObserver(
+ forName: .apiUnauthorized,
+ object: nil,
+ queue: .main
+ ) { [weak self] _ in
+ Task { @MainActor in self?.logout() }
+ }
}
var isLoggedIn: Bool { token != nil }
diff --git a/BanYaroGo/BanYaroGoApp.swift b/BanYaroGo/BanYaroGoApp.swift
index f4a9338..7642df6 100644
--- a/BanYaroGo/BanYaroGoApp.swift
+++ b/BanYaroGo/BanYaroGoApp.swift
@@ -10,6 +10,6 @@ struct BanYaroGoApp: App {
RootView()
.environment(auth)
}
- .modelContainer(for: ActiveWalk.self)
+ .modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
}
}
diff --git a/BanYaroGo/Support/GPXExporter.swift b/BanYaroGo/Support/GPXExporter.swift
new file mode 100644
index 0000000..215e8b9
--- /dev/null
+++ b/BanYaroGo/Support/GPXExporter.swift
@@ -0,0 +1,47 @@
+import Foundation
+
+enum GPXExporter {
+ /// Writes the route to a temporary `.gpx` file and returns its URL.
+ static func write(detail: RouteDetail) -> URL? {
+ let xml = generate(detail: detail)
+ let safeName = detail.name
+ .replacingOccurrences(of: "/", with: "-")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let filename = safeName.isEmpty ? "tour" : safeName
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("\(filename).gpx")
+ do {
+ try xml.write(to: url, atomically: true, encoding: .utf8)
+ return url
+ } catch {
+ return nil
+ }
+ }
+
+ static func generate(detail: RouteDetail) -> String {
+ let trkpts = detail.gpsTrack.map { p -> String in
+ if let alt = p.alt {
+ return " \(alt)"
+ } else {
+ return " "
+ }
+ }.joined(separator: "\n")
+
+ let safeName = detail.name
+ .replacingOccurrences(of: "&", with: "&")
+ .replacingOccurrences(of: "<", with: "<")
+ .replacingOccurrences(of: ">", with: ">")
+
+ return """
+
+
+
+ \(safeName)
+
+ \(trkpts)
+
+
+
+ """
+ }
+}
diff --git a/BanYaroGo/Support/RouteShareImage.swift b/BanYaroGo/Support/RouteShareImage.swift
new file mode 100644
index 0000000..6d04dae
--- /dev/null
+++ b/BanYaroGo/Support/RouteShareImage.swift
@@ -0,0 +1,112 @@
+import SwiftUI
+import MapKit
+import UIKit
+
+/// Renders a sharable card with map snapshot + stats for a route.
+@MainActor
+enum RouteShareImage {
+ static func render(detail: RouteDetail, size: CGFloat = 1200) async -> UIImage? {
+ let mapSize = CGSize(width: size, height: size * 0.75)
+ guard let mapImage = await mapSnapshot(track: detail.gpsTrack, size: mapSize) else {
+ return nil
+ }
+
+ let card = ShareCard(detail: detail, mapImage: mapImage, width: size)
+ let renderer = ImageRenderer(content: card)
+ renderer.scale = 2
+ return renderer.uiImage
+ }
+
+ private static func mapSnapshot(track: [GPSPoint], size: CGSize) async -> UIImage? {
+ guard track.count >= 2 else { return nil }
+ let coords = track.map { CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }
+
+ let options = MKMapSnapshotter.Options()
+ options.size = size
+ options.pointOfInterestFilter = .excludingAll
+ let lats = track.map(\.lat)
+ let lons = track.map(\.lon)
+ options.region = MKCoordinateRegion(
+ center: CLLocationCoordinate2D(
+ latitude: (lats.min()! + lats.max()!) / 2,
+ longitude: (lons.min()! + lons.max()!) / 2
+ ),
+ span: MKCoordinateSpan(
+ latitudeDelta: max((lats.max()! - lats.min()!) * 1.4, 0.002),
+ longitudeDelta: max((lons.max()! - lons.min()!) * 1.4, 0.002)
+ )
+ )
+
+ do {
+ let snapshot = try await MKMapSnapshotter(options: options).start()
+ let uiRenderer = UIGraphicsImageRenderer(size: size)
+ return uiRenderer.image { ctx in
+ snapshot.image.draw(in: CGRect(origin: .zero, size: size))
+ ctx.cgContext.setLineWidth(6)
+ ctx.cgContext.setStrokeColor(UIColor(red: 0xC4 / 255.0, green: 0x84 / 255.0, blue: 0x3A / 255.0, alpha: 1).cgColor)
+ ctx.cgContext.setLineJoin(.round)
+ ctx.cgContext.setLineCap(.round)
+ var first = true
+ for coord in coords {
+ let p = snapshot.point(for: coord)
+ if first { ctx.cgContext.move(to: p); first = false }
+ else { ctx.cgContext.addLine(to: p) }
+ }
+ ctx.cgContext.strokePath()
+ }
+ } catch {
+ return nil
+ }
+ }
+}
+
+private struct ShareCard: View {
+ let detail: RouteDetail
+ let mapImage: UIImage
+ let width: CGFloat
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Image(uiImage: mapImage)
+ .resizable()
+ .scaledToFit()
+
+ VStack(alignment: .leading, spacing: 16) {
+ Text(detail.name)
+ .font(.system(size: 36, weight: .bold))
+ .lineLimit(2)
+
+ HStack(spacing: 28) {
+ stat(value: String(format: "%.2f km", detail.distanzKm ?? 0), label: "Distanz")
+ stat(value: durationLabel, label: "Dauer")
+ stat(value: "\(detail.gpsTrack.count)", label: "Punkte")
+ }
+
+ HStack {
+ Image(systemName: "pawprint.fill")
+ .foregroundStyle(Color(red: 0xC4 / 255.0, green: 0x84 / 255.0, blue: 0x3A / 255.0))
+ Text("Ban Yaro Go")
+ .font(.headline)
+ Spacer()
+ }
+ }
+ .padding(28)
+ .background(Color.white)
+ }
+ .frame(width: width)
+ .background(Color.white)
+ }
+
+ private func stat(value: String, label: String) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(value).font(.system(size: 28, weight: .semibold))
+ Text(label).font(.system(size: 16)).foregroundStyle(.secondary)
+ }
+ }
+
+ private var durationLabel: String {
+ let mins = detail.dauerMin ?? 0
+ if mins >= 60 { return "\(mins / 60) h \(mins % 60) min" }
+ return "\(mins) min"
+ }
+}
diff --git a/BanYaroGo/Support/WalkHealthSync.swift b/BanYaroGo/Support/WalkHealthSync.swift
new file mode 100644
index 0000000..058a147
--- /dev/null
+++ b/BanYaroGo/Support/WalkHealthSync.swift
@@ -0,0 +1,85 @@
+import Foundation
+import HealthKit
+import CoreLocation
+
+/// Saves a completed walk as an HKWorkout(.walking) with HKWorkoutRoute so it
+/// shows up in Apple Health and counts toward the user's Activity rings.
+@MainActor
+final class WalkHealthSync {
+ static let shared = WalkHealthSync()
+ private let store = HKHealthStore()
+
+ var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
+
+ /// Requests share authorization for workouts + workout routes.
+ @discardableResult
+ func requestAuthorization() async -> Bool {
+ guard isAvailable else { return false }
+ let types: Set = [
+ HKObjectType.workoutType(),
+ HKSeriesType.workoutRoute()
+ ]
+ do {
+ try await store.requestAuthorization(toShare: types, read: [])
+ return true
+ } catch {
+ print("WalkHealthSync auth failed: \(error)")
+ return false
+ }
+ }
+
+ func saveWalk(
+ points: [GPSPoint],
+ startedAt: Date,
+ endedAt: Date,
+ distanceMeters: Double
+ ) async {
+ guard isAvailable, points.count >= 2 else { return }
+
+ let configuration = HKWorkoutConfiguration()
+ configuration.activityType = .walking
+ configuration.locationType = .outdoor
+
+ let builder = HKWorkoutBuilder(
+ healthStore: store,
+ configuration: configuration,
+ device: .local()
+ )
+
+ do {
+ try await builder.beginCollection(at: startedAt)
+
+ let distanceQuantity = HKQuantity(unit: .meter(), doubleValue: distanceMeters)
+ let distanceSample = HKQuantitySample(
+ type: HKQuantityType(.distanceWalkingRunning),
+ quantity: distanceQuantity,
+ start: startedAt,
+ end: endedAt
+ )
+ try await builder.addSamples([distanceSample])
+
+ try await builder.endCollection(at: endedAt)
+ guard let workout = try await builder.finishWorkout() else { return }
+
+ // Distribute timestamps evenly across the recorded period — our
+ // GPSPoint doesn't carry per-point timing.
+ let totalSeconds = endedAt.timeIntervalSince(startedAt)
+ let interval = totalSeconds / Double(max(1, points.count - 1))
+ let locations = points.enumerated().map { i, p in
+ CLLocation(
+ coordinate: CLLocationCoordinate2D(latitude: p.lat, longitude: p.lon),
+ altitude: p.alt ?? 0,
+ horizontalAccuracy: 5,
+ verticalAccuracy: 5,
+ timestamp: startedAt.addingTimeInterval(Double(i) * interval)
+ )
+ }
+
+ let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local())
+ try await routeBuilder.insertRouteData(locations)
+ _ = try await routeBuilder.finishRoute(with: workout, metadata: nil)
+ } catch {
+ print("WalkHealthSync save failed: \(error)")
+ }
+ }
+}
diff --git a/BanYaroGo/Tracking/CapturedPhoto.swift b/BanYaroGo/Tracking/CapturedPhoto.swift
new file mode 100644
index 0000000..9aa25da
--- /dev/null
+++ b/BanYaroGo/Tracking/CapturedPhoto.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+/// A photo captured (or picked) along with the GPS coordinate at which the
+/// user took it. PhotosPicker-sourced images have `location == nil`.
+struct CapturedPhoto {
+ let data: Data
+ let location: GPSPoint?
+}
diff --git a/BanYaroGo/Tracking/LocationTracker.swift b/BanYaroGo/Tracking/LocationTracker.swift
index b9f8407..d5299a9 100644
--- a/BanYaroGo/Tracking/LocationTracker.swift
+++ b/BanYaroGo/Tracking/LocationTracker.swift
@@ -11,13 +11,17 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
var points: [GPSPoint] = []
var isTracking: Bool = false
var isPaused: Bool = false
+ var isAutoPaused: Bool = false
var startedAt: Date?
var pausedAt: Date?
var accumulatedPausedSeconds: Int = 0
+ var accumulatedAutoPausedSeconds: Int = 0
var authorizationStatus: CLAuthorizationStatus
var permissionDenied: Bool = false
private var pendingStart: Bool = false
+ private var lastPointAt: Date?
+ private var autoPauseStartedAt: Date?
override init() {
self.authorizationStatus = manager.authorizationStatus
@@ -93,6 +97,7 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
if isPaused, let pausedAt {
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
}
+ endAutoPauseIfNeeded()
isPaused = false
pausedAt = nil
manager.stopUpdatingLocation()
@@ -102,17 +107,43 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
// MARK: - Derived state
- /// Active tracking time in seconds, with pauses already removed.
+ /// Active tracking time in seconds, with both manual and auto pauses removed.
var effectiveElapsedSeconds: Int {
guard let startedAt else { return 0 }
let total = Int(Date.now.timeIntervalSince(startedAt))
- var paused = accumulatedPausedSeconds
+ var paused = accumulatedPausedSeconds + accumulatedAutoPausedSeconds
if isPaused, let pausedAt {
paused += Int(Date.now.timeIntervalSince(pausedAt))
}
+ if isAutoPaused, let autoPauseStartedAt {
+ paused += Int(Date.now.timeIntervalSince(autoPauseStartedAt))
+ }
return max(0, total - paused)
}
+ /// Called from a timer in TrackingView. If no new GPS point arrived in the
+ /// last 2 minutes, auto-pause the duration counter without stopping GPS —
+ /// so we can detect the resume from the next location update.
+ func checkAutoPause() {
+ guard isTracking, !isPaused, !isAutoPaused else { return }
+ let enabled = UserDefaults.standard.object(forKey: "autoPauseEnabled") as? Bool ?? true
+ guard enabled else { return }
+ guard let last = lastPointAt else { return }
+ if Date.now.timeIntervalSince(last) > 120 {
+ isAutoPaused = true
+ autoPauseStartedAt = .now
+ }
+ }
+
+ private func endAutoPauseIfNeeded() {
+ guard isAutoPaused else { return }
+ if let started = autoPauseStartedAt {
+ accumulatedAutoPausedSeconds += Int(Date.now.timeIntervalSince(started))
+ }
+ isAutoPaused = false
+ autoPauseStartedAt = nil
+ }
+
var totalDistanceMeters: Double {
guard points.count >= 2 else { return 0 }
var total: Double = 0
@@ -157,7 +188,13 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
guard !newPoints.isEmpty else { return }
Task { @MainActor in
guard self.isTracking, !self.isPaused else { return }
+
+ // If we were auto-paused, the very arrival of a new GPS update
+ // means movement resumed.
+ self.endAutoPauseIfNeeded()
+
self.points.append(contentsOf: newPoints)
+ self.lastPointAt = .now
}
}
diff --git a/BanYaroGo/Tracking/PhotoLocation.swift b/BanYaroGo/Tracking/PhotoLocation.swift
new file mode 100644
index 0000000..8dcf097
--- /dev/null
+++ b/BanYaroGo/Tracking/PhotoLocation.swift
@@ -0,0 +1,22 @@
+import Foundation
+import SwiftData
+
+/// Persists the GPS coordinate at which each photo was captured, keyed by
+/// the backend's photo URL path. Lives only on this device — backend has no
+/// concept of photo geolocations.
+@Model
+final class PhotoLocation {
+ var routeId: Int
+ var photoUrl: String
+ var lat: Double
+ var lon: Double
+ var createdAt: Date
+
+ init(routeId: Int, photoUrl: String, lat: Double, lon: Double) {
+ self.routeId = routeId
+ self.photoUrl = photoUrl
+ self.lat = lat
+ self.lon = lon
+ self.createdAt = .now
+ }
+}
diff --git a/BanYaroGo/Views/EditRouteSheet.swift b/BanYaroGo/Views/EditRouteSheet.swift
new file mode 100644
index 0000000..7c15915
--- /dev/null
+++ b/BanYaroGo/Views/EditRouteSheet.swift
@@ -0,0 +1,84 @@
+import SwiftUI
+
+struct EditRouteSheet: View {
+ let routeId: Int
+
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var name: String
+ @State private var beschreibung: String
+ @State private var isPublic: Bool
+ @State private var isSaving = false
+ @State private var errorMessage: String?
+
+ let onSaved: (RouteDetail) -> Void
+
+ init(detail: RouteDetail, onSaved: @escaping (RouteDetail) -> Void) {
+ self.routeId = detail.id
+ self.onSaved = onSaved
+ _name = State(initialValue: detail.name)
+ _beschreibung = State(initialValue: detail.beschreibung ?? "")
+ _isPublic = State(initialValue: false) // backend field; default to private
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Name") {
+ TextField("Name", text: $name)
+ }
+ Section("Beschreibung") {
+ TextField("Beschreibung", text: $beschreibung, axis: .vertical)
+ .lineLimit(3...8)
+ }
+ Section {
+ Toggle("Öffentlich sichtbar", isOn: $isPublic)
+ }
+ if let errorMessage {
+ Section {
+ Text(errorMessage).font(.footnote).foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Tour bearbeiten")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Abbrechen") { dismiss() }
+ .disabled(isSaving)
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isSaving {
+ ProgressView()
+ } else {
+ Button("Sichern") { Task { await save() } }
+ .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ }
+ }
+ .interactiveDismissDisabled(isSaving)
+ }
+ }
+
+ private func save() async {
+ isSaving = true
+ errorMessage = nil
+ defer { isSaving = false }
+ let body = RouteUpdateBody(
+ name: name.trimmingCharacters(in: .whitespaces),
+ beschreibung: beschreibung.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ ? nil : beschreibung.trimmingCharacters(in: .whitespacesAndNewlines),
+ isPublic: isPublic
+ )
+ do {
+ let updated: RouteDetail = try await APIClient.shared.patch(
+ "/api/routes/\(routeId)",
+ body: body
+ )
+ onSaved(updated)
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/BanYaroGo/Views/FinishWalkSheet.swift b/BanYaroGo/Views/FinishWalkSheet.swift
index a67ed3f..2f70660 100644
--- a/BanYaroGo/Views/FinishWalkSheet.swift
+++ b/BanYaroGo/Views/FinishWalkSheet.swift
@@ -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 = []
@@ -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()
}
diff --git a/BanYaroGo/Views/MainTabView.swift b/BanYaroGo/Views/MainTabView.swift
index 3f3bcc8..f8c1e7f 100644
--- a/BanYaroGo/Views/MainTabView.swift
+++ b/BanYaroGo/Views/MainTabView.swift
@@ -14,6 +14,9 @@ struct MainTabView: View {
DogsListView()
.tabItem { Label("Hunde", systemImage: "pawprint.fill") }
+ StatisticsView()
+ .tabItem { Label("Statistik", systemImage: "chart.bar.fill") }
+
SettingsView()
.tabItem { Label("Mehr", systemImage: "person.crop.circle") }
}
diff --git a/BanYaroGo/Views/MiniRouteMap.swift b/BanYaroGo/Views/MiniRouteMap.swift
index 3f07d70..2d6c9e6 100644
--- a/BanYaroGo/Views/MiniRouteMap.swift
+++ b/BanYaroGo/Views/MiniRouteMap.swift
@@ -1,19 +1,37 @@
import SwiftUI
import MapKit
-/// Non-interactive map showing a polyline for a GPS track. Suitable for
-/// list-row previews as well as larger detail headers.
+/// Non-interactive map showing a polyline for a GPS track. Optional photo
+/// annotations can be tapped to fire a callback.
struct MiniRouteMap: View {
let track: [GPSPoint]
var lineWidth: CGFloat = 3
+ var photoLocations: [PhotoLocation] = []
+ var onPhotoTap: ((PhotoLocation) -> Void)? = nil
var body: some View {
Map(initialPosition: .region(region)) {
MapPolyline(coordinates: coordinates)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
+
+ ForEach(photoLocations) { loc in
+ Annotation("", coordinate: CLLocationCoordinate2D(latitude: loc.lat, longitude: loc.lon)) {
+ Button {
+ onPhotoTap?(loc)
+ } label: {
+ Image(systemName: "camera.circle.fill")
+ .font(.title2)
+ .foregroundStyle(.white, Color.accentColor)
+ .background(.white, in: Circle())
+ .shadow(radius: 2)
+ }
+ .buttonStyle(.plain)
+ .disabled(onPhotoTap == nil)
+ }
+ }
}
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
- .allowsHitTesting(false)
+ .allowsHitTesting(onPhotoTap != nil)
}
private var coordinates: [CLLocationCoordinate2D] {
@@ -31,7 +49,6 @@ struct MiniRouteMap: View {
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
- // Padding ~20% beyond bounding box; minimum span so very small tracks stay visible.
let latDelta = max((maxLat - minLat) * 1.4, 0.002)
let lonDelta = max((maxLon - minLon) * 1.4, 0.002)
return MKCoordinateRegion(
diff --git a/BanYaroGo/Views/RouteDetailView.swift b/BanYaroGo/Views/RouteDetailView.swift
index b33b1a2..7d7a1f9 100644
--- a/BanYaroGo/Views/RouteDetailView.swift
+++ b/BanYaroGo/Views/RouteDetailView.swift
@@ -1,27 +1,54 @@
import SwiftUI
+import SwiftData
import PhotosUI
struct RouteDetailView: View {
let routeId: Int
let fallbackName: String
+ @Environment(\.dismiss) private var dismiss
+ @Environment(AuthSession.self) private var auth
+ @Query private var allPhotoLocations: [PhotoLocation]
+
@State private var detail: RouteDetail?
@State private var isLoading = false
@State private var errorMessage: String?
+ @State private var tappedPhotoUrl: String?
@State private var newPhotoSelection: [PhotosPickerItem] = []
@State private var isUploadingPhoto = false
@State private var photoUploadProgress: (done: Int, total: Int) = (0, 0)
@State private var photoErrorMessage: String?
+ @State private var showEditSheet = false
+ @State private var showDeleteAlert = false
+ @State private var isDeleting = false
+
+ @State private var shareItems: [Any]?
+ @State private var isGeneratingShareImage = false
+
+ private var isOwn: Bool {
+ guard let detail, let myId = auth.profile?.id else { return false }
+ return detail.userId == myId
+ }
+
+ private var photoLocations: [PhotoLocation] {
+ allPhotoLocations.filter { $0.routeId == routeId }
+ }
+
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if let detail {
- MiniRouteMap(track: detail.gpsTrack, lineWidth: 4)
- .frame(height: 320)
- .clipShape(RoundedRectangle(cornerRadius: 16))
- .padding(.horizontal)
+ MiniRouteMap(
+ track: detail.gpsTrack,
+ lineWidth: 4,
+ photoLocations: photoLocations,
+ onPhotoTap: { loc in tappedPhotoUrl = loc.photoUrl }
+ )
+ .frame(height: 320)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .padding(.horizontal)
HStack(spacing: 12) {
StatTile(value: formatKm(detail.distanzKm), label: "Distanz", icon: "ruler")
@@ -59,6 +86,85 @@ struct RouteDetailView: View {
guard !items.isEmpty else { return }
Task { await uploadSelected(items: items) }
}
+ .toolbar {
+ if let detail {
+ ToolbarItem(placement: .topBarTrailing) {
+ Menu {
+ Button { Task { await shareAsImage() } } label: {
+ Label("Als Bild teilen", systemImage: "photo")
+ }
+ .disabled(isGeneratingShareImage)
+ Button { shareAsGPX() } label: {
+ Label("Als GPX-Datei teilen", systemImage: "doc.text")
+ }
+ if isOwn {
+ Divider()
+ Button {
+ showEditSheet = true
+ } label: {
+ Label("Bearbeiten", systemImage: "pencil")
+ }
+ Button(role: .destructive) {
+ showDeleteAlert = true
+ } label: {
+ Label("Löschen", systemImage: "trash")
+ }
+ }
+ } label: {
+ if isGeneratingShareImage {
+ ProgressView()
+ } else {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ .disabled(isDeleting)
+ .accessibilityIdentifier("routeDetailMenu_\(detail.id)")
+ }
+ }
+ }
+ .sheet(isPresented: $showEditSheet) {
+ if let detail {
+ EditRouteSheet(detail: detail) { updated in
+ self.detail = updated
+ }
+ }
+ }
+ .alert("Tour wirklich löschen?", isPresented: $showDeleteAlert) {
+ Button("Abbrechen", role: .cancel) {}
+ Button("Löschen", role: .destructive) { Task { await deleteRoute() } }
+ } message: {
+ Text("Die Tour wird unwiderruflich gelöscht — auch alle Fotos.")
+ }
+ .sheet(item: Binding(
+ get: { tappedPhotoUrl.map(IdentifiedURL.init) },
+ set: { tappedPhotoUrl = $0?.value }
+ )) { item in
+ PhotoViewerSheet(path: item.value)
+ }
+ .sheet(isPresented: Binding(
+ get: { shareItems != nil },
+ set: { if !$0 { shareItems = nil } }
+ )) {
+ if let items = shareItems {
+ ShareSheet(items: items)
+ }
+ }
+ }
+
+ private func shareAsImage() async {
+ guard let detail else { return }
+ isGeneratingShareImage = true
+ defer { isGeneratingShareImage = false }
+ if let img = await RouteShareImage.render(detail: detail) {
+ shareItems = [img]
+ }
+ }
+
+ private func shareAsGPX() {
+ guard let detail else { return }
+ if let url = GPXExporter.write(detail: detail) {
+ shareItems = [url]
+ }
}
@ViewBuilder
@@ -164,6 +270,17 @@ struct RouteDetailView: View {
await load()
}
+ private func deleteRoute() async {
+ isDeleting = true
+ defer { isDeleting = false }
+ do {
+ try await APIClient.shared.delete("/api/routes/\(routeId)")
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
private func formatKm(_ km: Double?) -> String {
guard let km else { return "—" }
return String(format: "%.2f km", km)
@@ -176,6 +293,44 @@ struct RouteDetailView: View {
}
}
+/// Wrapper so we can use sheet(item:) with a plain String URL path.
+private struct IdentifiedURL: Identifiable {
+ let value: String
+ var id: String { value }
+}
+
+private struct PhotoViewerSheet: View {
+ let path: String
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ Color.black.ignoresSafeArea()
+ if let url = URL(string: "https://banyaro.app\(path)") {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .success(let img):
+ img.resizable().scaledToFit()
+ case .failure:
+ ContentUnavailableView("Foto nicht ladbar", systemImage: "photo.badge.exclamationmark")
+ .foregroundStyle(.white)
+ default:
+ ProgressView().tint(.white)
+ }
+ }
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button { dismiss() } label: { Image(systemName: "xmark") }
+ .tint(.white)
+ }
+ }
+ }
+ }
+}
+
private struct StatTile: View {
let value: String
let label: String
diff --git a/BanYaroGo/Views/SettingsView.swift b/BanYaroGo/Views/SettingsView.swift
index 6ba3eec..18b8051 100644
--- a/BanYaroGo/Views/SettingsView.swift
+++ b/BanYaroGo/Views/SettingsView.swift
@@ -2,6 +2,9 @@ import SwiftUI
struct SettingsView: View {
@Environment(AuthSession.self) private var auth
+ @AppStorage("autoPauseEnabled") private var autoPauseEnabled = true
+ @AppStorage("healthKitSyncEnabled") private var healthKitSyncEnabled = false
+ @State private var showHealthPermissionAlert = false
var body: some View {
NavigationStack {
@@ -44,6 +47,37 @@ struct SettingsView: View {
}
}
+ Section {
+ Toggle(isOn: $autoPauseEnabled) {
+ Label("Auto-Pause", systemImage: "pause.circle")
+ }
+ Toggle(isOn: $healthKitSyncEnabled) {
+ Label("Apple Health Sync", systemImage: "heart.fill")
+ }
+ .onChange(of: healthKitSyncEnabled) { _, newValue in
+ if newValue {
+ Task {
+ let granted = await WalkHealthSync.shared.requestAuthorization()
+ if !granted {
+ healthKitSyncEnabled = false
+ showHealthPermissionAlert = true
+ }
+ }
+ }
+ }
+ } header: {
+ Text("Aufnahme")
+ } footer: {
+ Text("Auto-Pause: pausiert die Aufnahme, wenn du 2 Minuten lang stehen bleibst.\nApple Health: schreibt jede gespeicherte Tour als Spaziergang-Workout mit Route in Health.")
+ }
+
+ Section("Mehr auf banyaro.app") {
+ pwaLink("Forum", systemImage: "bubble.left.and.bubble.right.fill", fragment: "forum")
+ pwaLink("Hunde-Profile bearbeiten", systemImage: "pawprint.fill", fragment: "dogs")
+ pwaLink("Gassi-Treffen", systemImage: "person.2.fill", fragment: "walks")
+ pwaLink("Profil & Einstellungen", systemImage: "gearshape.fill", fragment: "settings")
+ }
+
Section {
Button("Abmelden", role: .destructive) {
auth.logout()
@@ -58,6 +92,11 @@ struct SettingsView: View {
}
.navigationTitle("Mehr")
.refreshable { await auth.loadProfile() }
+ .alert("Apple Health hat den Zugriff verweigert", isPresented: $showHealthPermissionAlert) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("Du kannst die Berechtigung in den iOS-Einstellungen unter Datenschutz & Sicherheit → Health → Ban Yaro Go nachträglich ändern.")
+ }
}
}
@@ -111,4 +150,20 @@ struct SettingsView: View {
if path.hasPrefix("http") { return URL(string: path) }
return URL(string: "https://banyaro.app\(path)")
}
+
+ @ViewBuilder
+ private func pwaLink(_ title: String, systemImage: String, fragment: String) -> some View {
+ if let url = URL(string: "https://banyaro.app/#\(fragment)") {
+ Link(destination: url) {
+ HStack {
+ Label(title, systemImage: systemImage)
+ .foregroundStyle(.primary)
+ Spacer()
+ Image(systemName: "arrow.up.right.square")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ }
}
diff --git a/BanYaroGo/Views/ShareSheet.swift b/BanYaroGo/Views/ShareSheet.swift
new file mode 100644
index 0000000..1dd45f7
--- /dev/null
+++ b/BanYaroGo/Views/ShareSheet.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+import UIKit
+
+/// Wraps UIActivityViewController so we can present arbitrary share items
+/// from SwiftUI (UIImage, URL, etc.) via .sheet.
+struct ShareSheet: UIViewControllerRepresentable {
+ let items: [Any]
+
+ func makeUIViewController(context: Context) -> UIActivityViewController {
+ UIActivityViewController(activityItems: items, applicationActivities: nil)
+ }
+
+ func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
+}
diff --git a/BanYaroGo/Views/StatisticsView.swift b/BanYaroGo/Views/StatisticsView.swift
new file mode 100644
index 0000000..a142b87
--- /dev/null
+++ b/BanYaroGo/Views/StatisticsView.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+struct StatisticsView: View {
+ @Environment(AuthSession.self) private var auth
+
+ @State private var routes: [RouteListItem] = []
+ @State private var isLoading = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ NavigationStack {
+ content
+ .navigationTitle("Statistik")
+ .task { await load() }
+ .refreshable { await load() }
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if isLoading && routes.isEmpty {
+ ProgressView()
+ } else if let errorMessage, routes.isEmpty {
+ ContentUnavailableView(
+ "Konnte Statistik nicht laden",
+ systemImage: "exclamationmark.triangle",
+ description: Text(errorMessage)
+ )
+ } else if myRoutes.isEmpty {
+ ContentUnavailableView(
+ "Noch keine Touren",
+ systemImage: "chart.bar.xaxis",
+ description: Text("Sobald du Gassi-Touren aufnimmst, siehst du hier deine Zahlen.")
+ )
+ } else {
+ List {
+ Section("Diese Woche") {
+ stats(in: weekRoutes)
+ }
+ Section("Diesen Monat") {
+ stats(in: monthRoutes)
+ }
+ Section("Allzeit") {
+ stats(in: myRoutes)
+ LabeledContent("Längste Tour", value: longestKm)
+ LabeledContent("Aktuelle Serie", value: streakLabel)
+ }
+ }
+ }
+ }
+
+ private func stats(in r: [RouteListItem]) -> some View {
+ Group {
+ LabeledContent("Distanz", value: String(format: "%.1f km", r.compactMap(\.distanzKm).reduce(0, +)))
+ LabeledContent("Dauer", value: formatTotalMinutes(r.compactMap(\.dauerMin).reduce(0, +)))
+ LabeledContent("Touren", value: "\(r.count)")
+ }
+ }
+
+ private func load() async {
+ isLoading = true
+ errorMessage = nil
+ defer { isLoading = false }
+ do {
+ routes = try await APIClient.shared.get("/api/routes")
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ // MARK: - Filtering
+
+ private var myId: Int? { auth.profile?.id }
+
+ private var myRoutes: [RouteListItem] {
+ guard let myId else { return [] }
+ return routes.filter { $0.userId == myId }
+ }
+
+ private var weekRoutes: [RouteListItem] {
+ let cal = Calendar.current
+ let start = cal.dateInterval(of: .weekOfYear, for: .now)?.start ?? .now
+ return myRoutes.filter { dateFromAPI($0.createdAt) >= start }
+ }
+
+ private var monthRoutes: [RouteListItem] {
+ let cal = Calendar.current
+ let start = cal.dateInterval(of: .month, for: .now)?.start ?? .now
+ return myRoutes.filter { dateFromAPI($0.createdAt) >= start }
+ }
+
+ // MARK: - Derived
+
+ private var longestKm: String {
+ let max = myRoutes.compactMap(\.distanzKm).max() ?? 0
+ return String(format: "%.2f km", max)
+ }
+
+ private var streakLabel: String {
+ let days = currentStreakDays()
+ if days == 0 { return "—" }
+ return days == 1 ? "1 Tag" : "\(days) Tage"
+ }
+
+ private func currentStreakDays() -> Int {
+ let cal = Calendar.current
+ let doneDays = Set(myRoutes.map { cal.startOfDay(for: dateFromAPI($0.createdAt)) })
+ guard !doneDays.isEmpty else { return 0 }
+
+ var day = cal.startOfDay(for: .now)
+ if !doneDays.contains(day) {
+ day = cal.date(byAdding: .day, value: -1, to: day) ?? day
+ }
+ var streak = 0
+ while doneDays.contains(day) {
+ streak += 1
+ day = cal.date(byAdding: .day, value: -1, to: day) ?? day
+ }
+ return streak
+ }
+
+ private func dateFromAPI(_ str: String?) -> Date {
+ guard let str else { return .distantPast }
+ let parser = DateFormatter()
+ parser.locale = Locale(identifier: "en_US_POSIX")
+ parser.timeZone = TimeZone(identifier: "UTC")
+ for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ssZ"] {
+ parser.dateFormat = format
+ if let d = parser.date(from: str) { return d }
+ }
+ return .distantPast
+ }
+
+ private func formatTotalMinutes(_ totalMin: Int) -> String {
+ let h = totalMin / 60
+ let m = totalMin % 60
+ if h > 0 { return "\(h) h \(m) min" }
+ return "\(m) min"
+ }
+}
diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift
index e764a71..94eaf80 100644
--- a/BanYaroGo/Views/TrackingView.swift
+++ b/BanYaroGo/Views/TrackingView.swift
@@ -8,7 +8,7 @@ struct TrackingView: View {
@State private var tracker = LocationTracker()
@State private var now: Date = .now
- @State private var pendingPhotos: [Data] = []
+ @State private var pendingPhotos: [CapturedPhoto] = []
@State private var showFinishSheet = false
@State private var showCamera = false
@@ -31,7 +31,10 @@ struct TrackingView: View {
.navigationBarTitleDisplayMode(.inline)
}
.onReceive(clockTicker) { now = $0 }
- .onReceive(persistTicker) { _ in persistActive() }
+ .onReceive(persistTicker) { _ in
+ tracker.checkAutoPause()
+ persistActive()
+ }
.onAppear { offerResumeIfNeeded() }
.sheet(isPresented: $showFinishSheet) {
FinishWalkSheet(
@@ -45,7 +48,8 @@ struct TrackingView: View {
}
.fullScreenCover(isPresented: $showCamera) {
CameraPicker { data in
- pendingPhotos.append(data)
+ let location = tracker.points.last
+ pendingPhotos.append(CapturedPhoto(data: data, location: location))
}
.ignoresSafeArea()
}
@@ -109,18 +113,20 @@ struct TrackingView: View {
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .topLeading) {
if tracker.isPaused {
- pausedBadge.padding(8)
+ badge("Pause", icon: "pause.circle.fill", color: .orange).padding(8)
+ } else if tracker.isAutoPaused {
+ badge("Auto-Pause", icon: "pause.circle", color: .gray).padding(8)
}
}
}
- private var pausedBadge: some View {
- Label("Pause", systemImage: "pause.circle.fill")
+ private func badge(_ text: String, icon: String, color: Color) -> some View {
+ Label(text, systemImage: icon)
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
- .background(.orange, in: Capsule())
+ .background(color, in: Capsule())
}
private var divider: some View {