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 {