Neues Widget-Extension-Target BanYaroGoWidgetExtension: - Bundle-ID app.banyaro.ios.BanYaroGoWidget - NSExtensionPointIdentifier = com.apple.widgetkit-extension - Synced root group + explizite Info.plist + Embed-Phase in App-Target - Cross-Membership der Shared/WalkActivityAttributes.swift in beiden Targets Shared/WalkActivityAttributes.swift: - ActivityAttributes mit startedAt (fix) - ContentState mit distanceMeters, elapsedSeconds, pointCount, isPaused, isAutoPaused BanYaroGoWidget/WalkLiveActivity.swift: - Lock-Screen-View: Pfote-Icon + Status-Pille (Live/Pause/Auto-Pause) + Stats-Spalten (Distanz/Dauer/Punkte) - Dynamic Island compact: Pfote leading, Distanz trailing - Dynamic Island minimal: nur Pfote - Dynamic Island expanded: Distanz/Dauer/Status mit Pfote zentriert WalkActivityController: - @MainActor Facade um ActivityKit - start() prüft areActivitiesEnabled, killt orphaned current, request mit Initial-State - update() async via Task - end() mit dismissalPolicy.immediate TrackingView: - .onChange(of: tracker.isTracking) → Start/End - persistTicker (5s) → update - .onChange(of: tracker.isPaused/isAutoPaused) → sofort update für saubere UX BanYaroGo-Info.plist: NSSupportsLiveActivities = true
376 lines
13 KiB
Swift
376 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 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() }
|
|
.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()
|
|
}
|
|
|
|
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)))"
|
|
}
|
|
}
|