banyaro-ios/BanYaroGo/Views/TrackingView.swift
rene d807db57a2 1.1: Home-Screen-Widget + Siri-Kurzbefehl „Gassi gehen"
App Group group.app.banyaro.ios verbindet App und Widget-Extension
(Entitlements in beiden Targets, CODE_SIGN_ENTITLEMENTS fürs Widget).

Home-Screen-Widget (D):
- BanYaroHomeWidget (klein + mittel): Tagesfoto, Hundename, nächster Termin.
- App schreibt beim Heim-Laden einen Snapshot (HomeWidgetData) in die App
  Group und triggert WidgetCenter-Reload; Snapshot wird bei Logout/401 geleert.

Siri-/Kurzbefehl (E):
- StartWalkIntent „Gassi gehen" + AppShortcutsProvider (öffnet die App).
- WalkLauncher überbrückt Intent → UI: Flag in der App Group, beim Aktivwerden
  eingelöst → Aufnehmen-Tab + Aufnahme-Start (TrackingView.startFresh).
- MainTabView mit Tab-Auswahl (Tags), BanYaroGoApp liest scenePhase.
2026-06-02 20:01:16 +02:00

391 lines
13 KiB
Swift

import SwiftUI
import SwiftData
import MapKit
struct TrackingView: View {
@Environment(\.modelContext) private var modelContext
@Query private var activeWalks: [ActiveWalk]
@State private var tracker = LocationTracker()
@State private var now: Date = .now
@State private var pendingPhotos: [CapturedPhoto] = []
@State private var showFinishSheet = false
@State private var showCamera = false
@State private var showResumeDialog = false
@State private var didCheckResume = false
private let launcher = WalkLauncher.shared
private let clockTicker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let persistTicker = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
var body: some View {
NavigationStack {
Group {
if tracker.isTracking {
activeTracking
} else {
startScreen
}
}
.navigationTitle("Aufnehmen")
.navigationBarTitleDisplayMode(.inline)
}
.onReceive(clockTicker) { now = $0 }
.onReceive(persistTicker) { _ in
tracker.checkAutoPause()
persistActive()
updateLiveActivity()
}
.onChange(of: tracker.isTracking) { _, isTracking in
if isTracking {
startLiveActivity()
} else {
WalkActivityController.end()
}
}
.onChange(of: tracker.isPaused) { _, _ in updateLiveActivity() }
.onChange(of: tracker.isAutoPaused) { _, _ in updateLiveActivity() }
.onAppear {
offerResumeIfNeeded()
startIfRequested()
}
.onChange(of: launcher.pendingStart) { _, pending in
if pending { startIfRequested() }
}
.sheet(isPresented: $showFinishSheet) {
FinishWalkSheet(
points: tracker.points,
durationSeconds: tracker.effectiveElapsedSeconds,
distanceMeters: tracker.totalDistanceMeters,
initialPhotos: pendingPhotos,
onDiscard: { discardCurrentWalk() },
onSaved: { discardCurrentWalk() }
)
}
.fullScreenCover(isPresented: $showCamera) {
CameraPicker { data in
let location = tracker.points.last
pendingPhotos.append(CapturedPhoto(data: data, location: location))
}
.ignoresSafeArea()
}
.confirmationDialog(
resumeDialogTitle,
isPresented: $showResumeDialog,
titleVisibility: .visible
) {
Button("Tour fortsetzen") { resumeStoredWalk() }
Button("Jetzt speichern") { loadStoredWalkIntoTracker(thenFinish: true) }
Button("Verwerfen", role: .destructive) { deleteStoredWalk() }
} message: {
if let stored = activeWalks.first {
Text("\(stored.points.count) Punkte aufgezeichnet, gestartet um \(formatTime(stored.startedAt)).")
}
}
}
// MARK: - Active tracking
private var activeTracking: some View {
ZStack(alignment: .top) {
Map {
UserAnnotation()
if tracker.points.count >= 2 {
MapPolyline(coordinates: tracker.points.map {
CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon)
})
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 5, lineJoin: .round))
}
}
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
.mapControlVisibility(.hidden)
.ignoresSafeArea(edges: .bottom)
VStack {
statsCard
Spacer()
bottomControls
.padding(.bottom, 12)
}
.padding(.horizontal)
cameraOverlayButton
}
}
private var statsCard: some View {
HStack(spacing: 0) {
stat(
value: String(format: "%.2f", tracker.totalDistanceMeters / 1000),
unit: "km",
label: "Distanz"
)
divider
stat(value: formatDuration(tracker.effectiveElapsedSeconds), unit: "", label: "Dauer")
divider
stat(value: "\(pendingPhotos.count)", unit: "", label: "Fotos")
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .topLeading) {
if tracker.isPaused {
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 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(color, in: Capsule())
}
private var divider: some View {
Divider().frame(height: 36)
}
private func stat(value: String, unit: String, label: String) -> some View {
VStack(spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(value).font(.title2.bold().monospacedDigit())
if !unit.isEmpty {
Text(unit).font(.callout).foregroundStyle(.secondary)
}
}
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
private var cameraOverlayButton: some View {
VStack {
Spacer()
HStack {
Spacer()
Button {
showCamera = true
} label: {
Image(systemName: "camera.fill")
.font(.title2)
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor, in: Circle())
.shadow(radius: 4)
}
.padding(.trailing, 20)
.padding(.bottom, 100) // sit above the bottom controls
}
}
.allowsHitTesting(true)
}
private var bottomControls: some View {
HStack(spacing: 12) {
Button {
if tracker.isPaused { tracker.resume() } else { tracker.pause() }
persistActive()
} label: {
Image(systemName: tracker.isPaused ? "play.fill" : "pause.fill")
.font(.title3.bold())
.frame(width: 56, height: 56)
.foregroundStyle(.white)
.background(tracker.isPaused ? Color.accentColor : Color.orange, in: Circle())
}
Button {
tracker.stop()
showFinishSheet = true
} label: {
HStack {
Image(systemName: "stop.circle.fill")
Text("Stoppen").bold()
}
.frame(maxWidth: .infinity, minHeight: 56)
}
.background(.red, in: Capsule())
.foregroundStyle(.white)
}
}
// MARK: - Start screen
private var startScreen: some View {
VStack(spacing: 28) {
Spacer()
Image(systemName: "figure.walk.circle.fill")
.font(.system(size: 96))
.foregroundStyle(Color.accentColor)
VStack(spacing: 6) {
Text("Bereit für die nächste Gassi?")
.font(.title3.bold())
Text("Tippe auf Start, um deine Tour aufzuzeichnen — auch wenn dein iPhone gesperrt ist.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
}
if tracker.permissionDenied {
permissionWarning
}
Button {
startFresh()
} label: {
HStack {
Image(systemName: "play.fill")
Text("Aufnahme starten").bold()
}
.frame(maxWidth: .infinity, minHeight: 56)
}
.background(Color.accentColor, in: Capsule())
.foregroundStyle(.white)
.padding(.horizontal)
Spacer()
}
}
private var permissionWarning: some View {
VStack(spacing: 6) {
Label("Standortzugriff fehlt", systemImage: "location.slash.fill")
.font(.subheadline.bold())
.foregroundStyle(.red)
Text("Bitte erlaube den Standortzugriff in den iOS-Einstellungen unter Datenschutz → Ortungsdienste → Ban Yaro Go.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
// MARK: - Walk lifecycle
private func startFresh() {
// Discard any existing stale ActiveWalk before starting a new one
for w in activeWalks { modelContext.delete(w) }
let walk = ActiveWalk(startedAt: .now)
modelContext.insert(walk)
try? modelContext.save()
pendingPhotos = []
tracker.startOrRequest()
}
/// Vom Siri-Kurzbefehl Gassi gehen" angestoßen: Aufnahme starten, sofern
/// nicht schon eine läuft.
private func startIfRequested() {
guard launcher.pendingStart, !tracker.isTracking else { return }
launcher.pendingStart = false
startFresh()
}
private func offerResumeIfNeeded() {
guard !didCheckResume else { return }
didCheckResume = true
if !activeWalks.isEmpty, !tracker.isTracking {
showResumeDialog = true
}
}
private func resumeStoredWalk() {
guard let walk = activeWalks.first else { return }
tracker.restore(
startedAt: walk.startedAt,
points: walk.points,
accumulatedPausedSeconds: walk.accumulatedPausedSeconds,
lastUpdate: walk.lastUpdate,
wasPaused: walk.isPaused
)
pendingPhotos = []
}
private func loadStoredWalkIntoTracker(thenFinish: Bool) {
guard let walk = activeWalks.first else { return }
// Mirror walk state into tracker without actually starting GPS we
// just need the data for FinishWalkSheet.
tracker.restore(
startedAt: walk.startedAt,
points: walk.points,
accumulatedPausedSeconds: walk.accumulatedPausedSeconds,
lastUpdate: walk.lastUpdate,
wasPaused: true // prevent automatic GPS restart
)
tracker.stop()
pendingPhotos = []
if thenFinish {
showFinishSheet = true
}
}
private func deleteStoredWalk() {
for w in activeWalks { modelContext.delete(w) }
try? modelContext.save()
}
private func discardCurrentWalk() {
// Called after FinishWalkSheet onSaved or onDiscard. Resets state.
deleteStoredWalk()
tracker = LocationTracker()
pendingPhotos = []
}
private func persistActive() {
guard tracker.isTracking, let walk = activeWalks.first else { return }
walk.points = tracker.points
walk.accumulatedPausedSeconds = tracker.accumulatedPausedSeconds
walk.pausedAt = tracker.isPaused ? (tracker.pausedAt ?? .now) : nil
walk.lastUpdate = .now
try? modelContext.save()
}
// MARK: - Helpers
private func startLiveActivity() {
guard let startedAt = tracker.startedAt else { return }
WalkActivityController.start(
startedAt: startedAt,
initialState: liveActivityState()
)
}
private func updateLiveActivity() {
guard tracker.isTracking else { return }
WalkActivityController.update(liveActivityState())
}
private func liveActivityState() -> WalkActivityAttributes.WalkActivityState {
WalkActivityAttributes.WalkActivityState(
distanceMeters: tracker.totalDistanceMeters,
elapsedSeconds: tracker.effectiveElapsedSeconds,
pointCount: tracker.points.count,
isPaused: tracker.isPaused,
isAutoPaused: tracker.isAutoPaused
)
}
private func formatDuration(_ seconds: Int) -> String {
let h = seconds / 3600
let m = (seconds % 3600) / 60
let s = seconds % 60
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
return String(format: "%d:%02d", m, s)
}
private func formatTime(_ date: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateStyle = .short
f.timeStyle = .short
return f.string(from: date)
}
private var resumeDialogTitle: String {
guard let stored = activeWalks.first else { return "" }
return "Tour nicht gespeichert (\(formatTime(stored.startedAt)))"
}
}