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
|
|
@ -50,6 +50,10 @@
|
||||||
<string>Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.</string>
|
<string>Damit deine Gassi-Tour auch bei gesperrtem Bildschirm weiter aufgezeichnet wird, brauchen wir Standortzugriff im Hintergrund.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Für Fotos während deiner Gassi-Tour brauchen wir Zugriff auf die Kamera.</string>
|
<string>Für Fotos während deiner Gassi-Tour brauchen wir Zugriff auf die Kamera.</string>
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>Wir lesen keine Daten aus Apple Health.</string>
|
||||||
|
<key>NSHealthUpdateUsageDescription</key>
|
||||||
|
<string>Auf Wunsch speichern wir deine Gassi-Touren als Spaziergang-Workout mit Route in Apple Health.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
|
|
|
||||||
10
BanYaroGo.entitlements
Normal file
10
BanYaroGo.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.developer.healthkit.access</key>
|
||||||
|
<array/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -238,6 +238,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = H436BR6YWX;
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
|
|
@ -263,6 +264,7 @@
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = BanYaroGo.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = H436BR6YWX;
|
DEVELOPMENT_TEAM = H436BR6YWX;
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ final class APIClient {
|
||||||
guard let http = response as? HTTPURLResponse else {
|
guard let http = response as? HTTPURLResponse else {
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
|
if http.statusCode == 401 {
|
||||||
|
NotificationCenter.default.post(name: .apiUnauthorized, object: nil)
|
||||||
|
throw APIError.unauthorized
|
||||||
|
}
|
||||||
guard (200..<300).contains(http.statusCode) else {
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
let detail = Self.parseErrorDetail(from: data)
|
let detail = Self.parseErrorDetail(from: data)
|
||||||
throw APIError.server(status: http.statusCode, message: detail)
|
throw APIError.server(status: http.statusCode, message: detail)
|
||||||
|
|
@ -92,6 +96,45 @@ final class APIClient {
|
||||||
return try decoder.decode(T.self, from: data)
|
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<T: Decodable, B: Encodable>(_ 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": [{...}]}.
|
/// FastAPI returns errors as {"detail": "message"} or {"detail": [{...}]}.
|
||||||
private static func parseErrorDetail(from data: Data) -> String? {
|
private static func parseErrorDetail(from data: Data) -> String? {
|
||||||
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,24 @@ import Foundation
|
||||||
|
|
||||||
enum APIError: LocalizedError {
|
enum APIError: LocalizedError {
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
|
case unauthorized
|
||||||
case server(status: Int, message: String?)
|
case server(status: Int, message: String?)
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .invalidResponse:
|
case .invalidResponse:
|
||||||
return "Ungültige Server-Antwort."
|
return "Ungültige Server-Antwort."
|
||||||
|
case .unauthorized:
|
||||||
|
return "Bitte erneut anmelden."
|
||||||
case .server(let status, let message):
|
case .server(let status, let message):
|
||||||
if let msg = message, !msg.isEmpty { return msg }
|
if let msg = message, !msg.isEmpty { return msg }
|
||||||
return "Fehler vom Server (HTTP \(status))."
|
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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,23 @@ struct RouteCreateBody: Encodable {
|
||||||
let dogIds: [Int]
|
let dogIds: [Int]
|
||||||
let isPublic: Bool
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Dark.png
Normal file
BIN
BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 834 KiB |
BIN
BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Tinted.png
Normal file
BIN
BanYaroGo/Assets.xcassets/AppIcon.appiconset/AppIcon-Tinted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 564 KiB |
|
|
@ -5,6 +5,30 @@
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"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" : {
|
"info" : {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ final class AuthSession {
|
||||||
token = savedToken
|
token = savedToken
|
||||||
APIClient.shared.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 }
|
var isLoggedIn: Bool { token != nil }
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@ struct BanYaroGoApp: App {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(auth)
|
.environment(auth)
|
||||||
}
|
}
|
||||||
.modelContainer(for: ActiveWalk.self)
|
.modelContainer(for: [ActiveWalk.self, PhotoLocation.self])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
BanYaroGo/Support/GPXExporter.swift
Normal file
47
BanYaroGo/Support/GPXExporter.swift
Normal file
|
|
@ -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 " <trkpt lat=\"\(p.lat)\" lon=\"\(p.lon)\"><ele>\(alt)</ele></trkpt>"
|
||||||
|
} else {
|
||||||
|
return " <trkpt lat=\"\(p.lat)\" lon=\"\(p.lon)\"/>"
|
||||||
|
}
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
|
||||||
|
let safeName = detail.name
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
|
||||||
|
return """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="Ban Yaro Go" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<trk>
|
||||||
|
<name>\(safeName)</name>
|
||||||
|
<trkseg>
|
||||||
|
\(trkpts)
|
||||||
|
</trkseg>
|
||||||
|
</trk>
|
||||||
|
</gpx>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
112
BanYaroGo/Support/RouteShareImage.swift
Normal file
112
BanYaroGo/Support/RouteShareImage.swift
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
85
BanYaroGo/Support/WalkHealthSync.swift
Normal file
85
BanYaroGo/Support/WalkHealthSync.swift
Normal file
|
|
@ -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<HKSampleType> = [
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
BanYaroGo/Tracking/CapturedPhoto.swift
Normal file
8
BanYaroGo/Tracking/CapturedPhoto.swift
Normal file
|
|
@ -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?
|
||||||
|
}
|
||||||
|
|
@ -11,13 +11,17 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
var points: [GPSPoint] = []
|
var points: [GPSPoint] = []
|
||||||
var isTracking: Bool = false
|
var isTracking: Bool = false
|
||||||
var isPaused: Bool = false
|
var isPaused: Bool = false
|
||||||
|
var isAutoPaused: Bool = false
|
||||||
var startedAt: Date?
|
var startedAt: Date?
|
||||||
var pausedAt: Date?
|
var pausedAt: Date?
|
||||||
var accumulatedPausedSeconds: Int = 0
|
var accumulatedPausedSeconds: Int = 0
|
||||||
|
var accumulatedAutoPausedSeconds: Int = 0
|
||||||
var authorizationStatus: CLAuthorizationStatus
|
var authorizationStatus: CLAuthorizationStatus
|
||||||
var permissionDenied: Bool = false
|
var permissionDenied: Bool = false
|
||||||
|
|
||||||
private var pendingStart: Bool = false
|
private var pendingStart: Bool = false
|
||||||
|
private var lastPointAt: Date?
|
||||||
|
private var autoPauseStartedAt: Date?
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
self.authorizationStatus = manager.authorizationStatus
|
self.authorizationStatus = manager.authorizationStatus
|
||||||
|
|
@ -93,6 +97,7 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
if isPaused, let pausedAt {
|
if isPaused, let pausedAt {
|
||||||
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
|
accumulatedPausedSeconds += Int(Date.now.timeIntervalSince(pausedAt))
|
||||||
}
|
}
|
||||||
|
endAutoPauseIfNeeded()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
pausedAt = nil
|
pausedAt = nil
|
||||||
manager.stopUpdatingLocation()
|
manager.stopUpdatingLocation()
|
||||||
|
|
@ -102,17 +107,43 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
|
|
||||||
// MARK: - Derived state
|
// 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 {
|
var effectiveElapsedSeconds: Int {
|
||||||
guard let startedAt else { return 0 }
|
guard let startedAt else { return 0 }
|
||||||
let total = Int(Date.now.timeIntervalSince(startedAt))
|
let total = Int(Date.now.timeIntervalSince(startedAt))
|
||||||
var paused = accumulatedPausedSeconds
|
var paused = accumulatedPausedSeconds + accumulatedAutoPausedSeconds
|
||||||
if isPaused, let pausedAt {
|
if isPaused, let pausedAt {
|
||||||
paused += Int(Date.now.timeIntervalSince(pausedAt))
|
paused += Int(Date.now.timeIntervalSince(pausedAt))
|
||||||
}
|
}
|
||||||
|
if isAutoPaused, let autoPauseStartedAt {
|
||||||
|
paused += Int(Date.now.timeIntervalSince(autoPauseStartedAt))
|
||||||
|
}
|
||||||
return max(0, total - paused)
|
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 {
|
var totalDistanceMeters: Double {
|
||||||
guard points.count >= 2 else { return 0 }
|
guard points.count >= 2 else { return 0 }
|
||||||
var total: Double = 0
|
var total: Double = 0
|
||||||
|
|
@ -157,7 +188,13 @@ final class LocationTracker: NSObject, CLLocationManagerDelegate {
|
||||||
guard !newPoints.isEmpty else { return }
|
guard !newPoints.isEmpty else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard self.isTracking, !self.isPaused else { return }
|
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.points.append(contentsOf: newPoints)
|
||||||
|
self.lastPointAt = .now
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
22
BanYaroGo/Tracking/PhotoLocation.swift
Normal file
22
BanYaroGo/Tracking/PhotoLocation.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
84
BanYaroGo/Views/EditRouteSheet.swift
Normal file
84
BanYaroGo/Views/EditRouteSheet.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
|
||||||
struct FinishWalkSheet: View {
|
struct FinishWalkSheet: View {
|
||||||
let points: [GPSPoint]
|
let points: [GPSPoint]
|
||||||
let durationSeconds: Int
|
let durationSeconds: Int
|
||||||
let distanceMeters: Double
|
let distanceMeters: Double
|
||||||
let initialPhotos: [Data]
|
let initialPhotos: [CapturedPhoto]
|
||||||
let onDiscard: () -> Void
|
let onDiscard: () -> Void
|
||||||
let onSaved: () -> Void
|
let onSaved: () -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@AppStorage("healthKitSyncEnabled") private var healthKitSyncEnabled = false
|
||||||
|
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var selectedDogIds: Set<Int> = []
|
@State private var selectedDogIds: Set<Int> = []
|
||||||
|
|
@ -17,7 +20,7 @@ struct FinishWalkSheet: View {
|
||||||
@State private var isLoadingDogs = false
|
@State private var isLoadingDogs = false
|
||||||
|
|
||||||
@State private var photoSelection: [PhotosPickerItem] = []
|
@State private var photoSelection: [PhotosPickerItem] = []
|
||||||
@State private var photoData: [Data] = []
|
@State private var photoData: [CapturedPhoto] = []
|
||||||
@State private var loadingPhotos = false
|
@State private var loadingPhotos = false
|
||||||
|
|
||||||
@State private var saveState: SaveState = .idle
|
@State private var saveState: SaveState = .idle
|
||||||
|
|
@ -35,7 +38,7 @@ struct FinishWalkSheet: View {
|
||||||
points: [GPSPoint],
|
points: [GPSPoint],
|
||||||
durationSeconds: Int,
|
durationSeconds: Int,
|
||||||
distanceMeters: Double,
|
distanceMeters: Double,
|
||||||
initialPhotos: [Data] = [],
|
initialPhotos: [CapturedPhoto] = [],
|
||||||
onDiscard: @escaping () -> Void,
|
onDiscard: @escaping () -> Void,
|
||||||
onSaved: @escaping () -> Void
|
onSaved: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
|
|
@ -56,6 +59,14 @@ struct FinishWalkSheet: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
if points.count >= 2 {
|
||||||
|
Section {
|
||||||
|
MiniRouteMap(track: points, lineWidth: 4)
|
||||||
|
.frame(height: 220)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if distanceMeters < shortDistanceThreshold {
|
if distanceMeters < shortDistanceThreshold {
|
||||||
shortDistanceWarning
|
shortDistanceWarning
|
||||||
}
|
}
|
||||||
|
|
@ -168,13 +179,23 @@ struct FinishWalkSheet: View {
|
||||||
private var photoStrip: some View {
|
private var photoStrip: some View {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(Array(photoData.enumerated()), id: \.offset) { _, data in
|
ForEach(Array(photoData.enumerated()), id: \.offset) { _, photo in
|
||||||
if let img = UIImage(data: data) {
|
if let img = UIImage(data: photo.data) {
|
||||||
Image(uiImage: img)
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 80, height: 80)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.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 {
|
private func loadPhotos(from items: [PhotosPickerItem]) async {
|
||||||
loadingPhotos = true
|
loadingPhotos = true
|
||||||
defer { loadingPhotos = false }
|
defer { loadingPhotos = false }
|
||||||
var loaded: [Data] = []
|
var loaded: [CapturedPhoto] = initialPhotos // keep camera-captured ones
|
||||||
for item in items {
|
for item in items {
|
||||||
if let data = try? await item.loadTransferable(type: Data.self) {
|
if let data = try? await item.loadTransferable(type: Data.self) {
|
||||||
loaded.append(data)
|
loaded.append(CapturedPhoto(data: data, location: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
photoData = loaded
|
photoData = loaded
|
||||||
|
|
@ -268,15 +289,27 @@ struct FinishWalkSheet: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !photoData.isEmpty {
|
if !photoData.isEmpty {
|
||||||
for (index, raw) in photoData.enumerated() {
|
for (index, photo) in photoData.enumerated() {
|
||||||
saveState = .uploadingPhotos(done: index, total: photoData.count)
|
saveState = .uploadingPhotos(done: index, total: photoData.count)
|
||||||
let resized = ImageResize.resizedJPEG(from: raw)
|
let resized = ImageResize.resizedJPEG(from: photo.data)
|
||||||
do {
|
do {
|
||||||
try await APIClient.shared.uploadFile(
|
let responseData = try await APIClient.shared.uploadFile(
|
||||||
"/api/routes/\(route.id)/photo",
|
"/api/routes/\(route.id)/photo",
|
||||||
filename: "photo_\(index + 1).jpg",
|
filename: "photo_\(index + 1).jpg",
|
||||||
data: resized
|
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 {
|
} catch {
|
||||||
errorMessage = "Tour ist gespeichert, aber Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
|
errorMessage = "Tour ist gespeichert, aber Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
|
||||||
saveState = .idle
|
saveState = .idle
|
||||||
|
|
@ -284,9 +317,22 @@ struct FinishWalkSheet: View {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
try? modelContext.save()
|
||||||
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
|
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()
|
onSaved()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ struct MainTabView: View {
|
||||||
DogsListView()
|
DogsListView()
|
||||||
.tabItem { Label("Hunde", systemImage: "pawprint.fill") }
|
.tabItem { Label("Hunde", systemImage: "pawprint.fill") }
|
||||||
|
|
||||||
|
StatisticsView()
|
||||||
|
.tabItem { Label("Statistik", systemImage: "chart.bar.fill") }
|
||||||
|
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.tabItem { Label("Mehr", systemImage: "person.crop.circle") }
|
.tabItem { Label("Mehr", systemImage: "person.crop.circle") }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,37 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MapKit
|
import MapKit
|
||||||
|
|
||||||
/// Non-interactive map showing a polyline for a GPS track. Suitable for
|
/// Non-interactive map showing a polyline for a GPS track. Optional photo
|
||||||
/// list-row previews as well as larger detail headers.
|
/// annotations can be tapped to fire a callback.
|
||||||
struct MiniRouteMap: View {
|
struct MiniRouteMap: View {
|
||||||
let track: [GPSPoint]
|
let track: [GPSPoint]
|
||||||
var lineWidth: CGFloat = 3
|
var lineWidth: CGFloat = 3
|
||||||
|
var photoLocations: [PhotoLocation] = []
|
||||||
|
var onPhotoTap: ((PhotoLocation) -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Map(initialPosition: .region(region)) {
|
Map(initialPosition: .region(region)) {
|
||||||
MapPolyline(coordinates: coordinates)
|
MapPolyline(coordinates: coordinates)
|
||||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
.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))
|
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(onPhotoTap != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var coordinates: [CLLocationCoordinate2D] {
|
private var coordinates: [CLLocationCoordinate2D] {
|
||||||
|
|
@ -31,7 +49,6 @@ struct MiniRouteMap: View {
|
||||||
latitude: (minLat + maxLat) / 2,
|
latitude: (minLat + maxLat) / 2,
|
||||||
longitude: (minLon + maxLon) / 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 latDelta = max((maxLat - minLat) * 1.4, 0.002)
|
||||||
let lonDelta = max((maxLon - minLon) * 1.4, 0.002)
|
let lonDelta = max((maxLon - minLon) * 1.4, 0.002)
|
||||||
return MKCoordinateRegion(
|
return MKCoordinateRegion(
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,54 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
|
|
||||||
struct RouteDetailView: View {
|
struct RouteDetailView: View {
|
||||||
let routeId: Int
|
let routeId: Int
|
||||||
let fallbackName: String
|
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 detail: RouteDetail?
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var tappedPhotoUrl: String?
|
||||||
|
|
||||||
@State private var newPhotoSelection: [PhotosPickerItem] = []
|
@State private var newPhotoSelection: [PhotosPickerItem] = []
|
||||||
@State private var isUploadingPhoto = false
|
@State private var isUploadingPhoto = false
|
||||||
@State private var photoUploadProgress: (done: Int, total: Int) = (0, 0)
|
@State private var photoUploadProgress: (done: Int, total: Int) = (0, 0)
|
||||||
@State private var photoErrorMessage: String?
|
@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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if let detail {
|
if let detail {
|
||||||
MiniRouteMap(track: detail.gpsTrack, lineWidth: 4)
|
MiniRouteMap(
|
||||||
.frame(height: 320)
|
track: detail.gpsTrack,
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
lineWidth: 4,
|
||||||
.padding(.horizontal)
|
photoLocations: photoLocations,
|
||||||
|
onPhotoTap: { loc in tappedPhotoUrl = loc.photoUrl }
|
||||||
|
)
|
||||||
|
.frame(height: 320)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
StatTile(value: formatKm(detail.distanzKm), label: "Distanz", icon: "ruler")
|
StatTile(value: formatKm(detail.distanzKm), label: "Distanz", icon: "ruler")
|
||||||
|
|
@ -59,6 +86,85 @@ struct RouteDetailView: View {
|
||||||
guard !items.isEmpty else { return }
|
guard !items.isEmpty else { return }
|
||||||
Task { await uploadSelected(items: items) }
|
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
|
@ViewBuilder
|
||||||
|
|
@ -164,6 +270,17 @@ struct RouteDetailView: View {
|
||||||
await load()
|
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 {
|
private func formatKm(_ km: Double?) -> String {
|
||||||
guard let km else { return "—" }
|
guard let km else { return "—" }
|
||||||
return String(format: "%.2f km", km)
|
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 {
|
private struct StatTile: View {
|
||||||
let value: String
|
let value: String
|
||||||
let label: String
|
let label: String
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import SwiftUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(AuthSession.self) private var auth
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
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 {
|
Section {
|
||||||
Button("Abmelden", role: .destructive) {
|
Button("Abmelden", role: .destructive) {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
|
|
@ -58,6 +92,11 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
.navigationTitle("Mehr")
|
.navigationTitle("Mehr")
|
||||||
.refreshable { await auth.loadProfile() }
|
.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) }
|
if path.hasPrefix("http") { return URL(string: path) }
|
||||||
return URL(string: "https://banyaro.app\(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
BanYaroGo/Views/ShareSheet.swift
Normal file
14
BanYaroGo/Views/ShareSheet.swift
Normal file
|
|
@ -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) {}
|
||||||
|
}
|
||||||
140
BanYaroGo/Views/StatisticsView.swift
Normal file
140
BanYaroGo/Views/StatisticsView.swift
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ struct TrackingView: View {
|
||||||
|
|
||||||
@State private var tracker = LocationTracker()
|
@State private var tracker = LocationTracker()
|
||||||
@State private var now: Date = .now
|
@State private var now: Date = .now
|
||||||
@State private var pendingPhotos: [Data] = []
|
@State private var pendingPhotos: [CapturedPhoto] = []
|
||||||
|
|
||||||
@State private var showFinishSheet = false
|
@State private var showFinishSheet = false
|
||||||
@State private var showCamera = false
|
@State private var showCamera = false
|
||||||
|
|
@ -31,7 +31,10 @@ struct TrackingView: View {
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
.onReceive(clockTicker) { now = $0 }
|
.onReceive(clockTicker) { now = $0 }
|
||||||
.onReceive(persistTicker) { _ in persistActive() }
|
.onReceive(persistTicker) { _ in
|
||||||
|
tracker.checkAutoPause()
|
||||||
|
persistActive()
|
||||||
|
}
|
||||||
.onAppear { offerResumeIfNeeded() }
|
.onAppear { offerResumeIfNeeded() }
|
||||||
.sheet(isPresented: $showFinishSheet) {
|
.sheet(isPresented: $showFinishSheet) {
|
||||||
FinishWalkSheet(
|
FinishWalkSheet(
|
||||||
|
|
@ -45,7 +48,8 @@ struct TrackingView: View {
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showCamera) {
|
.fullScreenCover(isPresented: $showCamera) {
|
||||||
CameraPicker { data in
|
CameraPicker { data in
|
||||||
pendingPhotos.append(data)
|
let location = tracker.points.last
|
||||||
|
pendingPhotos.append(CapturedPhoto(data: data, location: location))
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
}
|
}
|
||||||
|
|
@ -109,18 +113,20 @@ struct TrackingView: View {
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
if tracker.isPaused {
|
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 {
|
private func badge(_ text: String, icon: String, color: Color) -> some View {
|
||||||
Label("Pause", systemImage: "pause.circle.fill")
|
Label(text, systemImage: icon)
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(.orange, in: Capsule())
|
.background(color, in: Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
private var divider: some View {
|
private var divider: some View {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue