From 500a645bfdf04818e1a6490bc71216a2f09978d4 Mon Sep 17 00:00:00 2001 From: rene Date: Sat, 30 May 2026 11:35:43 +0200 Subject: [PATCH] =?UTF-8?q?Phase=204.A.1:=20Live=20Activity=20+=20Dynamic?= =?UTF-8?q?=20Island=20f=C3=BCr=20laufende=20Gassi-Tour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BanYaroGo-Info.plist | 2 + BanYaroGo.xcodeproj/project.pbxproj | 161 ++++++++++++++++++ .../Tracking/WalkActivityController.swift | 41 +++++ BanYaroGo/Views/TrackingView.swift | 33 ++++ BanYaroGoWidget-Info.plist | 29 ++++ BanYaroGoWidget/BanYaroGoWidgetBundle.swift | 9 + BanYaroGoWidget/WalkLiveActivity.swift | 145 ++++++++++++++++ Shared/WalkActivityAttributes.swift | 38 +++++ 8 files changed, 458 insertions(+) create mode 100644 BanYaroGo/Tracking/WalkActivityController.swift create mode 100644 BanYaroGoWidget-Info.plist create mode 100644 BanYaroGoWidget/BanYaroGoWidgetBundle.swift create mode 100644 BanYaroGoWidget/WalkLiveActivity.swift create mode 100644 Shared/WalkActivityAttributes.swift 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 + } +}