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
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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue