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
145 lines
5.6 KiB
Swift
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)
|
|
}
|
|
}
|