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
This commit is contained in:
rene 2026-05-30 11:35:43 +02:00
parent fec7c79b05
commit 500a645bfd
8 changed files with 458 additions and 0 deletions

View file

@ -58,5 +58,7 @@
<array>
<string>location</string>
</array>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View file

@ -6,12 +6,46 @@
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
BB0000000000000000000010 /* WalkActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000001 /* WalkActivityAttributes.swift */; };
BB0000000000000000000011 /* WalkActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000001 /* WalkActivityAttributes.swift */; };
BB0000000000000000000012 /* BanYaroGoWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000005 /* BanYaroGoWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
BB0000000000000000000041 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AA0000000000000000000001 /* Project object */;
proxyType = 1;
remoteGlobalIDString = BB0000000000000000000020;
remoteInfo = BanYaroGoWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
BB0000000000000000000024 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
BB0000000000000000000012 /* BanYaroGoWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
AA0000000000000000000005 /* BanYaroGo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BanYaroGo.app; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000001 /* WalkActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkActivityAttributes.swift; sourceTree = "<group>"; };
BB0000000000000000000003 /* BanYaroGoWidget-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "BanYaroGoWidget-Info.plist"; sourceTree = "<group>"; };
BB0000000000000000000005 /* BanYaroGoWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BanYaroGoWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
AA0000000000000000000006 /* BanYaroGo */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BanYaroGo; sourceTree = "<group>"; };
BB0000000000000000000002 /* BanYaroGoWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BanYaroGoWidget; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@ -22,13 +56,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000022 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AA0000000000000000000002 = {
isa = PBXGroup;
children = (
BB0000000000000000000006 /* Shared */,
AA0000000000000000000006 /* BanYaroGo */,
BB0000000000000000000002 /* BanYaroGoWidget */,
BB0000000000000000000003 /* BanYaroGoWidget-Info.plist */,
AA0000000000000000000003 /* Products */,
);
sourceTree = "<group>";
@ -37,10 +81,19 @@
isa = PBXGroup;
children = (
AA0000000000000000000005 /* BanYaroGo.app */,
BB0000000000000000000005 /* BanYaroGoWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
BB0000000000000000000006 /* Shared */ = {
isa = PBXGroup;
children = (
BB0000000000000000000001 /* WalkActivityAttributes.swift */,
);
path = Shared;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -51,10 +104,12 @@
AA000000000000000000000D /* Sources */,
AA000000000000000000000E /* Frameworks */,
AA000000000000000000000F /* Resources */,
BB0000000000000000000024 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
BB0000000000000000000040 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
AA0000000000000000000006 /* BanYaroGo */,
@ -64,6 +119,26 @@
productReference = AA0000000000000000000005 /* BanYaroGo.app */;
productType = "com.apple.product-type.application";
};
BB0000000000000000000020 /* BanYaroGoWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000030 /* Build configuration list for PBXNativeTarget "BanYaroGoWidgetExtension" */;
buildPhases = (
BB0000000000000000000021 /* Sources */,
BB0000000000000000000022 /* Frameworks */,
BB0000000000000000000023 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
BB0000000000000000000002 /* BanYaroGoWidget */,
);
name = BanYaroGoWidgetExtension;
productName = BanYaroGoWidgetExtension;
productReference = BB0000000000000000000005 /* BanYaroGoWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@ -77,6 +152,9 @@
AA0000000000000000000004 = {
CreatedOnToolsVersion = 26.5;
};
BB0000000000000000000020 = {
CreatedOnToolsVersion = 26.5;
};
};
};
buildConfigurationList = AA0000000000000000000007 /* Build configuration list for PBXProject "BanYaroGo" */;
@ -93,6 +171,7 @@
projectRoot = "";
targets = (
AA0000000000000000000004 /* BanYaroGo */,
BB0000000000000000000020 /* BanYaroGoWidgetExtension */,
);
};
/* End PBXProject section */
@ -105,6 +184,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000023 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -112,11 +198,28 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB0000000000000000000010 /* WalkActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000021 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BB0000000000000000000011 /* WalkActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
BB0000000000000000000040 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = BB0000000000000000000020 /* BanYaroGoWidgetExtension */;
targetProxy = BB0000000000000000000041 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
AA0000000000000000000009 /* Debug */ = {
isa = XCBuildConfiguration;
@ -285,6 +388,55 @@
};
name = Release;
};
BB0000000000000000000031 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.banyaro.ios.BanYaroGoWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
BB0000000000000000000032 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = H436BR6YWX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "BanYaroGoWidget-Info.plist";
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.banyaro.ios.BanYaroGoWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -306,6 +458,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BB0000000000000000000030 /* Build configuration list for PBXNativeTarget "BanYaroGoWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB0000000000000000000031 /* Debug */,
BB0000000000000000000032 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = AA0000000000000000000001 /* Project object */;

View file

@ -0,0 +1,41 @@
import Foundation
import ActivityKit
/// Tiny facade around ActivityKit so TrackingView doesn't have to know the
/// details. There is at most one walk activity alive at any time.
@MainActor
enum WalkActivityController {
private static var current: Activity<WalkActivityAttributes>?
static func start(startedAt: Date, initialState: WalkActivityAttributes.WalkActivityState) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
// Kill anything left over from a previous (orphaned) session.
if current != nil { end() }
let attributes = WalkActivityAttributes(startedAt: startedAt)
let content = ActivityContent(state: initialState, staleDate: nil)
do {
current = try Activity.request(
attributes: attributes,
content: content,
pushType: nil
)
} catch {
print("Live Activity request failed: \(error)")
}
}
static func update(_ state: WalkActivityAttributes.WalkActivityState) {
let activity = current
Task { @MainActor in
await activity?.update(ActivityContent(state: state, staleDate: nil))
}
}
static func end() {
let activity = current
current = nil
Task { @MainActor in
await activity?.end(nil, dismissalPolicy: .immediate)
}
}
}

View file

@ -34,7 +34,17 @@ struct TrackingView: View {
.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(
@ -320,6 +330,29 @@ struct TrackingView: View {
// 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

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Ban Yaro Go</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct BanYaroGoWidgetBundle: WidgetBundle {
var body: some Widget {
WalkLiveActivity()
}
}

View file

@ -0,0 +1,145 @@
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)
}
}

View file

@ -0,0 +1,38 @@
import Foundation
import ActivityKit
/// Live Activity attributes for a running Gassi-Tour. Lives in `Shared/` and
/// is compiled into both the app target (which starts/updates/ends activities)
/// and the widget extension (which renders the lock screen + Dynamic Island).
struct WalkActivityAttributes: ActivityAttributes {
public typealias ContentState = WalkActivityState
public struct WalkActivityState: Codable, Hashable {
public var distanceMeters: Double
public var elapsedSeconds: Int
public var pointCount: Int
public var isPaused: Bool
public var isAutoPaused: Bool
public init(
distanceMeters: Double,
elapsedSeconds: Int,
pointCount: Int,
isPaused: Bool,
isAutoPaused: Bool
) {
self.distanceMeters = distanceMeters
self.elapsedSeconds = elapsedSeconds
self.pointCount = pointCount
self.isPaused = isPaused
self.isAutoPaused = isAutoPaused
}
}
/// Walk start time fixed for the duration of the activity.
public var startedAt: Date
public init(startedAt: Date) {
self.startedAt = startedAt
}
}