banyaro-ios/BanYaroGoWidget/WalkLiveActivity.swift
rene 500a645bfd Phase 4.A.1: Live Activity + Dynamic Island für laufende Gassi-Tour
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
2026-05-30 11:35:43 +02:00

145 lines
5.6 KiB
Swift

import ActivityKit
import WidgetKit
import SwiftUI
struct WalkLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: WalkActivityAttributes.self) { context in
// Lock-Screen / Banner view
lockScreen(state: context.state)
.activityBackgroundTint(Color.accentColor.opacity(0.12))
.activitySystemActionForegroundColor(Color.accentColor)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading) {
Label("Distanz", systemImage: "ruler")
.font(.caption2)
.foregroundStyle(.secondary)
Text(distanceLabel(context.state.distanceMeters))
.font(.title3.bold().monospacedDigit())
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing) {
Label("Dauer", systemImage: "clock")
.font(.caption2)
.foregroundStyle(.secondary)
Text(durationLabel(context.state.elapsedSeconds))
.font(.title3.bold().monospacedDigit())
}
}
DynamicIslandExpandedRegion(.center) {
Image(systemName: pawIcon(for: context.state))
.font(.title2)
.foregroundStyle(Color.accentColor)
}
DynamicIslandExpandedRegion(.bottom) {
if context.state.isPaused {
statusPill("Pause", color: .orange)
} else if context.state.isAutoPaused {
statusPill("Auto-Pause", color: .gray)
} else {
Text("\(context.state.pointCount) Punkte aufgezeichnet")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} compactLeading: {
Image(systemName: pawIcon(for: context.state))
.foregroundStyle(Color.accentColor)
} compactTrailing: {
Text(distanceLabel(context.state.distanceMeters))
.font(.caption2.monospacedDigit())
} minimal: {
Image(systemName: pawIcon(for: context.state))
.foregroundStyle(Color.accentColor)
}
.widgetURL(URL(string: "banyarogo://walk"))
.keylineTint(Color.accentColor)
}
}
private func lockScreen(state: WalkActivityAttributes.WalkActivityState) -> some View {
HStack(spacing: 16) {
Image(systemName: pawIcon(for: state))
.font(.system(size: 36))
.foregroundStyle(Color.accentColor)
.frame(width: 56, height: 56)
.background(Color.accentColor.opacity(0.15), in: Circle())
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text("Ban Yaro Go")
.font(.headline)
if state.isPaused {
statusPill("Pause", color: .orange)
} else if state.isAutoPaused {
statusPill("Auto-Pause", color: .gray)
} else {
statusPill("Live", color: .green)
}
}
HStack(spacing: 16) {
statColumn(
value: distanceLabel(state.distanceMeters),
label: "Distanz"
)
Divider().frame(height: 28)
statColumn(
value: durationLabel(state.elapsedSeconds),
label: "Dauer"
)
Divider().frame(height: 28)
statColumn(
value: "\(state.pointCount)",
label: "Punkte"
)
}
}
Spacer(minLength: 0)
}
.padding()
}
private func statColumn(value: String, label: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(value)
.font(.subheadline.bold().monospacedDigit())
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func statusPill(_ text: String, color: Color) -> some View {
Text(text)
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(color, in: Capsule())
}
private func pawIcon(for state: WalkActivityAttributes.WalkActivityState) -> String {
if state.isPaused || state.isAutoPaused {
return "pause.circle.fill"
}
return "pawprint.fill"
}
private func distanceLabel(_ meters: Double) -> String {
if meters >= 1000 {
return String(format: "%.2f km", meters / 1000)
}
return "\(Int(meters)) m"
}
private func durationLabel(_ 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)
}
}