diff --git a/BanYaroGo-Info.plist b/BanYaroGo-Info.plist
index 6dfd178..a15a288 100644
--- a/BanYaroGo-Info.plist
+++ b/BanYaroGo-Info.plist
@@ -58,5 +58,7 @@
location
+ NSSupportsLiveActivities
+
diff --git a/BanYaroGo.xcodeproj/project.pbxproj b/BanYaroGo.xcodeproj/project.pbxproj
index 074f142..904124b 100644
--- a/BanYaroGo.xcodeproj/project.pbxproj
+++ b/BanYaroGo.xcodeproj/project.pbxproj
@@ -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 = ""; };
+ BB0000000000000000000003 /* BanYaroGoWidget-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "BanYaroGoWidget-Info.plist"; sourceTree = ""; };
+ 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 = ""; };
+ BB0000000000000000000002 /* BanYaroGoWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = BanYaroGoWidget; sourceTree = ""; };
/* 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 = "";
@@ -37,10 +81,19 @@
isa = PBXGroup;
children = (
AA0000000000000000000005 /* BanYaroGo.app */,
+ BB0000000000000000000005 /* BanYaroGoWidgetExtension.appex */,
);
name = Products;
sourceTree = "";
};
+ BB0000000000000000000006 /* Shared */ = {
+ isa = PBXGroup;
+ children = (
+ BB0000000000000000000001 /* WalkActivityAttributes.swift */,
+ );
+ path = Shared;
+ sourceTree = "";
+ };
/* 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 */;
diff --git a/BanYaroGo/Tracking/WalkActivityController.swift b/BanYaroGo/Tracking/WalkActivityController.swift
new file mode 100644
index 0000000..5595b53
--- /dev/null
+++ b/BanYaroGo/Tracking/WalkActivityController.swift
@@ -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?
+
+ 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)
+ }
+ }
+}
diff --git a/BanYaroGo/Views/TrackingView.swift b/BanYaroGo/Views/TrackingView.swift
index 94eaf80..13d79ce 100644
--- a/BanYaroGo/Views/TrackingView.swift
+++ b/BanYaroGo/Views/TrackingView.swift
@@ -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
diff --git a/BanYaroGoWidget-Info.plist b/BanYaroGoWidget-Info.plist
new file mode 100644
index 0000000..5c6ceb3
--- /dev/null
+++ b/BanYaroGoWidget-Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Ban Yaro Go
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/BanYaroGoWidget/BanYaroGoWidgetBundle.swift b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
new file mode 100644
index 0000000..318e7f0
--- /dev/null
+++ b/BanYaroGoWidget/BanYaroGoWidgetBundle.swift
@@ -0,0 +1,9 @@
+import WidgetKit
+import SwiftUI
+
+@main
+struct BanYaroGoWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ WalkLiveActivity()
+ }
+}
diff --git a/BanYaroGoWidget/WalkLiveActivity.swift b/BanYaroGoWidget/WalkLiveActivity.swift
new file mode 100644
index 0000000..b3b36fb
--- /dev/null
+++ b/BanYaroGoWidget/WalkLiveActivity.swift
@@ -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)
+ }
+}
diff --git a/Shared/WalkActivityAttributes.swift b/Shared/WalkActivityAttributes.swift
new file mode 100644
index 0000000..cabac06
--- /dev/null
+++ b/Shared/WalkActivityAttributes.swift
@@ -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
+ }
+}