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:
rene 2026-05-30 11:19:53 +02:00
parent 30e0fbe7ec
commit c01e3d6be7
26 changed files with 978 additions and 28 deletions

View 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
}
}
}

View file

@ -1,15 +1,18 @@
import SwiftUI
import SwiftData
import PhotosUI
struct FinishWalkSheet: View {
let points: [GPSPoint]
let durationSeconds: Int
let distanceMeters: Double
let initialPhotos: [Data]
let initialPhotos: [CapturedPhoto]
let onDiscard: () -> Void
let onSaved: () -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@AppStorage("healthKitSyncEnabled") private var healthKitSyncEnabled = false
@State private var name: String
@State private var selectedDogIds: Set<Int> = []
@ -17,7 +20,7 @@ struct FinishWalkSheet: View {
@State private var isLoadingDogs = false
@State private var photoSelection: [PhotosPickerItem] = []
@State private var photoData: [Data] = []
@State private var photoData: [CapturedPhoto] = []
@State private var loadingPhotos = false
@State private var saveState: SaveState = .idle
@ -35,7 +38,7 @@ struct FinishWalkSheet: View {
points: [GPSPoint],
durationSeconds: Int,
distanceMeters: Double,
initialPhotos: [Data] = [],
initialPhotos: [CapturedPhoto] = [],
onDiscard: @escaping () -> Void,
onSaved: @escaping () -> Void
) {
@ -56,6 +59,14 @@ struct FinishWalkSheet: View {
var body: some View {
NavigationStack {
Form {
if points.count >= 2 {
Section {
MiniRouteMap(track: points, lineWidth: 4)
.frame(height: 220)
.listRowInsets(EdgeInsets())
}
}
if distanceMeters < shortDistanceThreshold {
shortDistanceWarning
}
@ -168,13 +179,23 @@ struct FinishWalkSheet: View {
private var photoStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(photoData.enumerated()), id: \.offset) { _, data in
if let img = UIImage(data: data) {
ForEach(Array(photoData.enumerated()), id: \.offset) { _, photo in
if let img = UIImage(data: photo.data) {
Image(uiImage: img)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(alignment: .topTrailing) {
if photo.location != nil {
Image(systemName: "location.fill")
.font(.caption2)
.foregroundStyle(.white)
.padding(4)
.background(Color.accentColor, in: Circle())
.padding(4)
}
}
}
}
}
@ -236,10 +257,10 @@ struct FinishWalkSheet: View {
private func loadPhotos(from items: [PhotosPickerItem]) async {
loadingPhotos = true
defer { loadingPhotos = false }
var loaded: [Data] = []
var loaded: [CapturedPhoto] = initialPhotos // keep camera-captured ones
for item in items {
if let data = try? await item.loadTransferable(type: Data.self) {
loaded.append(data)
loaded.append(CapturedPhoto(data: data, location: nil))
}
}
photoData = loaded
@ -268,15 +289,27 @@ struct FinishWalkSheet: View {
}
if !photoData.isEmpty {
for (index, raw) in photoData.enumerated() {
for (index, photo) in photoData.enumerated() {
saveState = .uploadingPhotos(done: index, total: photoData.count)
let resized = ImageResize.resizedJPEG(from: raw)
let resized = ImageResize.resizedJPEG(from: photo.data)
do {
try await APIClient.shared.uploadFile(
let responseData = try await APIClient.shared.uploadFile(
"/api/routes/\(route.id)/photo",
filename: "photo_\(index + 1).jpg",
data: resized
)
// Persist GPS location of this photo if we have one
if let coord = photo.location,
let obj = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any],
let fotoUrl = obj["foto_url"] as? String {
let loc = PhotoLocation(
routeId: route.id,
photoUrl: fotoUrl,
lat: coord.lat,
lon: coord.lon
)
modelContext.insert(loc)
}
} catch {
errorMessage = "Tour ist gespeichert, aber Foto \(index + 1) konnte nicht hochgeladen werden: \(error.localizedDescription)"
saveState = .idle
@ -284,9 +317,22 @@ struct FinishWalkSheet: View {
return
}
}
try? modelContext.save()
saveState = .uploadingPhotos(done: photoData.count, total: photoData.count)
}
// Apple Health sync (only if user opted in)
if healthKitSyncEnabled, points.count >= 2 {
let endedAt = Date.now
let startedAt = endedAt.addingTimeInterval(-Double(durationSeconds))
await WalkHealthSync.shared.saveWalk(
points: points,
startedAt: startedAt,
endedAt: endedAt,
distanceMeters: distanceMeters
)
}
onSaved()
dismiss()
}

View file

@ -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") }
}

View file

@ -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(

View file

@ -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

View file

@ -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)
}
}
}
}
}

View 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) {}
}

View 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"
}
}

View file

@ -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 {