diff --git a/Workouts Widget/Resources/Info-Widget.plist b/Workouts Widget/Resources/Info-Widget.plist
new file mode 100644
index 0000000..d50bcac
--- /dev/null
+++ b/Workouts Widget/Resources/Info-Widget.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Workouts
+ 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)
+ ITSAppUsesNonExemptEncryption
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/Workouts Widget/WorkoutActivityAttributes.swift b/Workouts Widget/WorkoutActivityAttributes.swift
new file mode 100644
index 0000000..54b2df4
--- /dev/null
+++ b/Workouts Widget/WorkoutActivityAttributes.swift
@@ -0,0 +1,82 @@
+//
+// WorkoutActivityAttributes.swift
+// Workouts (shared: iOS app + Workouts Widget)
+//
+// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
+//
+
+import ActivityKit
+import Foundation
+
+/// The ActivityKit attributes backing the active-workout Live Activity (lock screen +
+/// Dynamic Island). This file is compiled into **both** the iOS app (which starts / updates /
+/// ends the activity via `WorkoutLiveActivityController`) and the `Workouts Widget` extension
+/// (which renders it). It intentionally depends on nothing but ActivityKit + Foundation, so it
+/// can live in the widget target without pulling in the app's model/sync layer.
+///
+/// The whole design is **push-free**: every content state carries wall-clock anchors
+/// (`phaseStart` / `phaseEnd`), so the widget renders live countdowns with
+/// `Text(timerInterval:)` / `ProgressView(timerInterval:)` and never needs a server or a
+/// background update to keep ticking — the same wall-clock-anchored trick the in-app run flow
+/// and the watch mirror already use.
+struct WorkoutActivityAttributes: ActivityAttributes {
+ /// The dynamic part of the activity — mirrors one page of the Ready → Work → Rest → Finish
+ /// run flow. Refreshed by the app on every phase / set transition while it's foregrounded.
+ struct ContentState: Codable, Hashable, Sendable {
+ /// Which page of the run flow the driving device is on.
+ enum Phase: String, Codable, Hashable, Sendable {
+ case ready, work, rest, done
+ }
+
+ var exerciseName: String
+ var phase: Phase
+
+ /// 0-based set this frame pertains to, and the planned total.
+ var setIndex: Int
+ var setCount: Int
+
+ /// Footer line under the timer, e.g. "8 reps" or "30 sec".
+ var detail: String
+
+ /// Wall-clock anchor for the phase's timer. A count-up work set counts up from here; a
+ /// count-down phase (rest / timed work / auto-Done) runs from here to `phaseEnd`.
+ var phaseStart: Date
+
+ /// End anchor for a count-down phase; `nil` for a rep-based work set (which counts up)
+ /// or the Ready lead-in (which shows no timer).
+ var phaseEnd: Date?
+
+ // MARK: Derived (shared by every widget presentation)
+
+ /// 1-based set number for display.
+ var setNumber: Int { setIndex + 1 }
+
+ /// A running count-down is present (rest, timed work, or the auto-Done deadline).
+ var isCountingDown: Bool { phaseEnd != nil }
+
+ /// A rep-based work set that counts up (no end anchor).
+ var isCountingUp: Bool { phase == .work && phaseEnd == nil }
+
+ /// Short phase word for the compact/expanded labels.
+ var phaseTitle: String {
+ switch phase {
+ case .ready: return "Ready"
+ case .work: return "Work"
+ case .rest: return "Rest"
+ case .done: return "Done"
+ }
+ }
+
+ /// One-line status under the exercise name, e.g. "Rest · Set 2 of 4" or
+ /// "Set 2 of 4 · 8 reps".
+ var statusLine: String {
+ let sets = "Set \(setNumber) of \(setCount)"
+ switch phase {
+ case .ready: return "Ready · \(sets)"
+ case .work: return detail.isEmpty ? sets : "\(sets) · \(detail)"
+ case .rest: return "Rest · \(sets)"
+ case .done: return "Complete"
+ }
+ }
+ }
+}
diff --git a/Workouts Widget/WorkoutLiveActivity.swift b/Workouts Widget/WorkoutLiveActivity.swift
new file mode 100644
index 0000000..d65d790
--- /dev/null
+++ b/Workouts Widget/WorkoutLiveActivity.swift
@@ -0,0 +1,180 @@
+//
+// WorkoutLiveActivity.swift
+// Workouts Widget
+//
+// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
+//
+
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+/// The active-workout Live Activity: a lock-screen / banner presentation plus the Dynamic
+/// Island (compact, minimal, expanded). Every timer renders straight off the content state's
+/// wall-clock anchors via `Text(timerInterval:)` / `ProgressView(timerInterval:)`, so the
+/// countdowns stay live while the phone is locked with **no push updates**.
+///
+/// Backgrounds are left to the system (lock-screen material / Dynamic Island black), so light
+/// and dark both look right without hardcoding a fill. Phase tints echo the in-app run flow —
+/// brand purple for work, teal for rest.
+struct WorkoutLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in
+ LockScreenView(state: context.state)
+ .activitySystemActionForegroundColor(.primary)
+ } dynamicIsland: { context in
+ let state = context.state
+ return DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ Label(state.phaseTitle, systemImage: Glyph.exercise)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(state.phase.tint)
+ .padding(.leading, 4)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ TimerLabel(state: state)
+ .font(.system(.title3, design: .rounded, weight: .semibold))
+ .foregroundStyle(state.phase.tint)
+ .frame(maxWidth: 96, alignment: .trailing)
+ .padding(.trailing, 4)
+ }
+ DynamicIslandExpandedRegion(.bottom) {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(state.exerciseName)
+ .font(.headline)
+ .lineLimit(1)
+ Text(state.statusLine)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ if let end = state.phaseEnd {
+ ProgressView(timerInterval: state.phaseStart...end, countsDown: true) {
+ EmptyView()
+ } currentValueLabel: {
+ EmptyView()
+ }
+ .tint(state.phase.tint)
+ }
+ }
+ .padding(.top, 2)
+ }
+ } compactLeading: {
+ Image(systemName: Glyph.exercise)
+ .foregroundStyle(state.phase.tint)
+ } compactTrailing: {
+ if let end = state.phaseEnd {
+ CountdownText(start: state.phaseStart, end: end)
+ .foregroundStyle(state.phase.tint)
+ .frame(maxWidth: 52)
+ } else {
+ Text("\(state.setNumber)/\(state.setCount)")
+ .monospacedDigit()
+ }
+ } minimal: {
+ if let end = state.phaseEnd {
+ CountdownText(start: state.phaseStart, end: end)
+ .foregroundStyle(state.phase.tint)
+ .frame(maxWidth: 52)
+ } else {
+ Image(systemName: Glyph.exercise)
+ .foregroundStyle(state.phase.tint)
+ }
+ }
+ .keylineTint(state.phase.tint)
+ }
+ }
+}
+
+// MARK: - Lock screen / banner
+
+private struct LockScreenView: View {
+ let state: WorkoutActivityAttributes.ContentState
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 14) {
+ Image(systemName: Glyph.exercise)
+ .font(.title2)
+ .foregroundStyle(state.phase.tint)
+ .frame(width: 30)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(state.exerciseName)
+ .font(.headline)
+ .lineLimit(1)
+ Text(state.statusLine)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ if let end = state.phaseEnd {
+ ProgressView(timerInterval: state.phaseStart...end, countsDown: true) {
+ EmptyView()
+ } currentValueLabel: {
+ EmptyView()
+ }
+ .tint(state.phase.tint)
+ .padding(.top, 1)
+ }
+ }
+
+ Spacer(minLength: 8)
+
+ TimerLabel(state: state)
+ .font(.system(.title, design: .rounded, weight: .semibold))
+ .foregroundStyle(state.phase.tint)
+ }
+ .padding()
+ }
+}
+
+// MARK: - Timer helpers
+
+/// Picks the right live timer for the phase: a count-down for rest / timed work / auto-Done, a
+/// stopwatch count-up for a rep-based work set, a checkmark for the terminal Done state, and
+/// nothing for the Ready lead-in.
+private struct TimerLabel: View {
+ let state: WorkoutActivityAttributes.ContentState
+
+ var body: some View {
+ if let end = state.phaseEnd {
+ CountdownText(start: state.phaseStart, end: end)
+ } else if state.phase == .work {
+ Text(state.phaseStart, style: .timer)
+ .monospacedDigit()
+ } else if state.phase == .done {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ }
+ }
+}
+
+/// A self-updating count-down rendered off wall-clock anchors — no push needed.
+private struct CountdownText: View {
+ let start: Date
+ let end: Date
+
+ var body: some View {
+ Text(timerInterval: start...end, countsDown: true)
+ .monospacedDigit()
+ .multilineTextAlignment(.trailing)
+ }
+}
+
+// MARK: - Styling
+
+private enum Glyph {
+ static let exercise = "figure.strengthtraining.traditional"
+}
+
+private extension WorkoutActivityAttributes.ContentState.Phase {
+ /// Phase tint echoing the in-app run flow (brand purple for work, teal for rest). Kept
+ /// local to the widget so the extension stays self-contained; values mirror
+ /// `Color.workTint` / `Color.restTint` in `Shared/Utils/Color+Extensions.swift`.
+ var tint: Color {
+ switch self {
+ case .ready: return Color(red: 0.51, green: 0.22, blue: 0.84)
+ case .work: return Color(red: 0.51, green: 0.22, blue: 0.84)
+ case .rest: return Color(red: 0.44, green: 0.85, blue: 0.84)
+ case .done: return .green
+ }
+ }
+}
diff --git a/Workouts Widget/WorkoutsWidgetBundle.swift b/Workouts Widget/WorkoutsWidgetBundle.swift
new file mode 100644
index 0000000..6f8ea45
--- /dev/null
+++ b/Workouts Widget/WorkoutsWidgetBundle.swift
@@ -0,0 +1,18 @@
+//
+// WorkoutsWidgetBundle.swift
+// Workouts Widget
+//
+// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
+//
+
+import SwiftUI
+import WidgetKit
+
+/// The iOS widget extension's entry point. It currently hosts only the active-workout Live
+/// Activity; ordinary home/lock-screen widgets can be added to the bundle later.
+@main
+struct WorkoutsWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ WorkoutLiveActivity()
+ }
+}
diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift
index d41512c..b3fa85e 100644
--- a/Workouts/AppServices.swift
+++ b/Workouts/AppServices.swift
@@ -14,6 +14,10 @@ final class AppServices {
let workoutLauncher = WorkoutLauncher()
let workoutHealthDeleter = WorkoutHealthDeleter()
+ /// Owns the active-workout Live Activity (lock screen + Dynamic Island). Driven by the run
+ /// flow's `LiveProgress` frames, so locking the phone mid-set doesn't lose the timer.
+ let liveActivity = WorkoutLiveActivityController()
+
/// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted.
let liveRunState: LiveRunState
@@ -37,6 +41,8 @@ final class AppServices {
if let bootstrapTask { await bootstrapTask.value; return }
let task = Task { @MainActor [weak self] in
guard let self else { return }
+ // Clear any Live Activity stranded by a previous launch (app killed mid-run).
+ await self.liveActivity.endStaleActivities()
await self.syncEngine.connect()
self.watchBridge.activate()
// Past the iCloud gate: request the workout-share scope so the user can still
diff --git a/Workouts/LiveActivity/WorkoutLiveActivityController.swift b/Workouts/LiveActivity/WorkoutLiveActivityController.swift
new file mode 100644
index 0000000..4d298b5
--- /dev/null
+++ b/Workouts/LiveActivity/WorkoutLiveActivityController.swift
@@ -0,0 +1,174 @@
+//
+// WorkoutLiveActivityController.swift
+// Workouts
+//
+// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
+//
+
+import ActivityKit
+import Foundation
+import os
+
+/// Owns the lifecycle of the active-workout Live Activity (lock screen + Dynamic Island).
+///
+/// It is driven by the **same** run-flow frames the phone↔watch mirror uses (`LiveProgress`):
+/// `ExerciseProgressView` hands us one on every phase / set settle, we translate it to a
+/// `WorkoutActivityAttributes.ContentState`, and start or update the activity. Because the
+/// content carries wall-clock anchors, the widget's countdowns keep ticking while the phone is
+/// locked without any push update from here.
+///
+/// Lifecycle:
+/// • **Start** — the first non-Ready frame of a run spins the activity up.
+/// • **Update** — every subsequent frame refreshes it (idempotent).
+/// • **End** — leaving the run flow tears it down; a completed run lingers briefly on a
+/// terminal "Complete" state before auto-dismissing.
+///
+/// Degrades silently when Live Activities are disabled (`areActivitiesEnabled`).
+///
+/// Concurrency: the public entry points are **synchronous**, so `currentLogID` / `lastState`
+/// (both `Sendable`) mutate in call order on the main actor. The async ActivityKit I/O is fired
+/// off to detached tasks that resolve the live activity from the `nonisolated`
+/// `Activity.activities` static — a *disconnected* value that crosses into the task cleanly,
+/// unlike a non-`Sendable` `Activity` stashed in a main-actor-isolated property. The run flow is
+/// per-exercise, so there's only ever one of our activities live at a time.
+@MainActor
+final class WorkoutLiveActivityController {
+ private static let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "live-activity")
+
+ private typealias State = WorkoutActivityAttributes.ContentState
+ private typealias Content = ActivityContent
+
+ /// The run the live activity belongs to, so a frame for a *different* run replaces it, and a
+ /// `nil` means nothing is showing.
+ private var currentLogID: String?
+ /// The most recent content state, so `end()` can render a terminal "Complete" card without
+ /// reaching back into the (non-`Sendable`) activity handle.
+ private var lastState: State?
+
+ private var isEnabled: Bool { ActivityAuthorizationInfo().areActivitiesEnabled }
+
+ // MARK: - Update
+
+ /// Reflect the given run frame on the Live Activity — starting one if needed. Fired on every
+ /// settle (human swipe, auto-advance, or a followed watch frame), so it must be idempotent.
+ func update(_ frame: LiveProgress) {
+ guard isEnabled else { return }
+
+ // A frame for a run other than the one we're showing — drop the stale activity first.
+ if let current = currentLogID, current != frame.logID {
+ reset()
+ }
+
+ // Swiping back to the Ready page resets the exercise — nothing to show anymore.
+ guard frame.phase != .ready else {
+ if currentLogID != nil { reset() }
+ return
+ }
+
+ let state = Self.contentState(from: frame)
+ lastState = state
+ let content = Content(state: state, staleDate: nil)
+
+ if currentLogID == nil {
+ // Don't spin one up just for the Ready lead-in; this is the first real frame.
+ currentLogID = frame.logID
+ start(content)
+ } else {
+ pushUpdate(content)
+ }
+ }
+
+ private func start(_ content: Content) {
+ do {
+ _ = try Activity.request(
+ attributes: WorkoutActivityAttributes(),
+ content: content,
+ pushType: nil
+ )
+ } catch {
+ Self.log.error("Live Activity start failed: \(error, privacy: .public)")
+ currentLogID = nil
+ lastState = nil
+ }
+ }
+
+ // MARK: - End
+
+ /// Leave the run flow. If the run reached its terminal Done state, linger briefly on a
+ /// "Complete" card before dismissing; otherwise dismiss at once.
+ func end() {
+ guard let state = lastState else { return }
+ currentLogID = nil
+ lastState = nil
+
+ if state.phase == .done {
+ var terminal = state
+ terminal.phaseEnd = nil // stop the auto-Done countdown; show the finished state
+ endAll(final: Content(state: terminal, staleDate: nil),
+ dismissal: .after(Date().addingTimeInterval(4)))
+ } else {
+ endAll(final: nil, dismissal: .immediate)
+ }
+ }
+
+ /// Immediately tear down whatever is showing (a run switch or a Ready reset).
+ private func reset() {
+ currentLogID = nil
+ lastState = nil
+ endAll(final: nil, dismissal: .immediate)
+ }
+
+ /// Dismiss any activity still on screen from a previous launch (e.g. the app was killed
+ /// mid-run). Called defensively at bootstrap.
+ func endStaleActivities() async {
+ currentLogID = nil
+ lastState = nil
+ for stale in Activity.activities {
+ await stale.end(nil, dismissalPolicy: .immediate)
+ }
+ }
+
+ // MARK: - Fire-and-forget ActivityKit I/O
+
+ /// Push a new content state to the live activity. `content` is `Sendable`; the activity is
+ /// resolved inside the task from the nonisolated `activities` list, so nothing non-`Sendable`
+ /// crosses the main-actor boundary.
+ private nonisolated func pushUpdate(_ content: Content) {
+ Task {
+ for activity in Activity.activities {
+ await activity.update(content)
+ }
+ }
+ }
+
+ /// End every live activity of our type, optionally showing a terminal `final` state first.
+ private nonisolated func endAll(final content: Content?, dismissal: ActivityUIDismissalPolicy) {
+ Task {
+ for activity in Activity.activities {
+ await activity.end(content, dismissalPolicy: dismissal)
+ }
+ }
+ }
+
+ // MARK: - Helpers
+
+ /// Translate a run-flow frame into a widget content state (mapping `.finish` → `.done`).
+ private static func contentState(from frame: LiveProgress) -> State {
+ let phase: State.Phase
+ switch frame.phase {
+ case .ready: phase = .ready
+ case .work: phase = .work
+ case .rest: phase = .rest
+ case .finish: phase = .done
+ }
+ return State(
+ exerciseName: frame.exerciseName,
+ phase: phase,
+ setIndex: frame.setIndex,
+ setCount: frame.setCount,
+ detail: frame.detail,
+ phaseStart: frame.phaseStart,
+ phaseEnd: frame.phaseEnd
+ )
+ }
+}
diff --git a/Workouts/Resources/Info-iOS.plist b/Workouts/Resources/Info-iOS.plist
index 0d5e061..b6bf3bd 100644
--- a/Workouts/Resources/Info-iOS.plist
+++ b/Workouts/Resources/Info-iOS.plist
@@ -22,6 +22,8 @@
$(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
+ NSSupportsLiveActivities
+
LSRequiresIPhoneOS
NSHealthShareUsageDescription
diff --git a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
index 357c53d..db7bea6 100644
--- a/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
+++ b/Workouts/Views/WorkoutLogs/ExerciseProgressView.swift
@@ -45,6 +45,15 @@ struct ExerciseProgressView: View {
let onLive: (LiveProgress) -> Void
let onLiveEnded: () -> Void
+ /// Reflects the run's current position onto the iOS Live Activity (lock screen + Dynamic
+ /// Island). Unlike `onLive` (watch, *human* transitions only), this fires on **every** page
+ /// settle — human, auto-advance, or a followed watch frame — plus the initial page, since the
+ /// Live Activity must track the current phase and countdown no matter who drove it.
+ /// `onActivityEnded` fires when the flow is left, so the activity can be dismissed. Both
+ /// default to no-ops, so the in-list driver only wires them where an activity is wanted.
+ let onActivity: (LiveProgress) -> Void
+ let onActivityEnded: () -> Void
+
/// The latest live-run frame the *watch* sent for this run, to follow when it drives a
/// transition (ephemeral; nil when the watch isn't driving). Applying it jumps our page
/// without re-broadcasting or re-recording — the originating device owns the durable write.
@@ -96,12 +105,14 @@ struct ExerciseProgressView: View {
/// `startsCompleted`.
@State private var startsSkipped: Bool
- init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
+ init(doc: Binding, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, onActivity: @escaping (LiveProgress) -> Void = { _ in }, onActivityEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
self._doc = doc
self.logID = logID
self.onChange = onChange
self.onLive = onLive
self.onLiveEnded = onLiveEnded
+ self.onActivity = onActivity
+ self.onActivityEnded = onActivityEnded
self.incomingFrame = incomingFrame
let log = doc.wrappedValue.logs.first { $0.id == logID }
@@ -266,6 +277,9 @@ struct ExerciseProgressView: View {
}
broadcastLive(for: newPage)
}
+ // The Live Activity tracks every settle (all causes), not just human ones — a rest
+ // that auto-advances or a page the watch drove must still refresh the lock screen.
+ emitActivity(for: newPage)
}
.onChange(of: incomingFrame) { _, frame in
if let frame { applyIncoming(frame) }
@@ -288,8 +302,10 @@ struct ExerciseProgressView: View {
}
}
.onDisappear {
- // Leaving the flow (back / done) — stop the watch from following.
+ // Leaving the flow (back / done) — stop the watch from following and dismiss the
+ // Live Activity (it lingers briefly on a completed run before auto-dismissing).
onLiveEnded()
+ onActivityEnded()
}
}
@@ -314,6 +330,21 @@ struct ExerciseProgressView: View {
} else {
broadcastLive(for: currentPage)
}
+ // Seed the Live Activity with the opening page (a resumed run starts it here; a
+ // not-started run sits on Ready, which the controller ignores until the run begins).
+ emitActivity(for: currentPage)
+ }
+
+ /// Reflect the current flow position onto the Live Activity. Built from the same
+ /// `liveSnapshot` the watch broadcast uses, but honoring the remote anchors when the page was
+ /// reached by a watch frame, so the lock-screen countdown lines up with the mirror.
+ private func emitActivity(for page: Int) {
+ guard var snapshot = liveSnapshot(for: page) else { return }
+ if page == remoteAnchorPage {
+ if let start = remoteAnchorStart { snapshot.phaseStart = start }
+ snapshot.phaseEnd = remoteAnchorEnd
+ }
+ onActivity(snapshot)
}
/// Push the current flow position to the watch. The anchor is stamped *now* — the page
diff --git a/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift
index 725f8b7..2417995 100644
--- a/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift
+++ b/Workouts/Views/WorkoutLogs/LiveRunCoverView.swift
@@ -63,6 +63,8 @@ struct LiveRunCoverView: View {
onLiveEnded: {
services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
},
+ onActivity: { services.liveActivity.update($0) },
+ onActivityEnded: { services.liveActivity.end() },
incomingFrame: incomingFrame
)
} else {
diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
index bb6dce8..d6318fa 100644
--- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
+++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift
@@ -246,6 +246,8 @@ struct WorkoutLogListView: View {
onChange: { save() },
onLive: { services.watchBridge.sendLiveProgress($0) },
onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
+ onActivity: { services.liveActivity.update($0) },
+ onActivityEnded: { services.liveActivity.end() },
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil }
)
.onAppear { liveRun.navigatedRunID = logID }
diff --git a/project.yml b/project.yml
index c51e74f..1d808e7 100644
--- a/project.yml
+++ b/project.yml
@@ -23,6 +23,19 @@ packages:
url: https://git.rzen.dev/rzen/indie-sync.git
from: "0.3.0"
+# Shared post-build phase — stamps CFBundleVersion (git commit count), BuildDate, and
+# BuildHash into each target's (and dSYM's) Info.plist. Aliased into every code-bearing
+# target below so the app, watch app, and both widget extensions stamp identical metadata.
+scriptAnchors:
+ updateBuildInfo: &updateBuildInfo
+ - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
+ name: Update Build Info
+ shell: /bin/sh
+ basedOnDependencyAnalysis: false
+ inputFiles:
+ - $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
+ - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
+
targets:
# ---- iOS app (owns iCloud Drive sync; embeds the watch app) ----------------
Workouts:
@@ -43,18 +56,16 @@ targets:
- path: LICENSE.md
buildPhase: resources
type: file
+ # The Live Activity attributes are shared with the widget extension (which owns the
+ # folder); the app compiles just this one file so it can start / update / end the activity.
+ - path: "Workouts Widget/WorkoutActivityAttributes.swift"
dependencies:
- package: IndieAbout
- package: IndieSync
- target: Workouts Watch App
- postBuildScripts:
- - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
- name: Update Build Info
- shell: /bin/sh
- basedOnDependencyAnalysis: false
- inputFiles:
- - $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
- - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
+ - target: Workouts Widget
+ embed: true
+ postBuildScripts: *updateBuildInfo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts
@@ -105,14 +116,7 @@ targets:
- package: IndieSync
- target: Workouts Watch Widget
embed: true
- postBuildScripts:
- - script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
- name: Update Build Info
- shell: /bin/sh
- basedOnDependencyAnalysis: false
- inputFiles:
- - $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
- - $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
+ postBuildScripts: *updateBuildInfo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp
@@ -153,6 +157,7 @@ targets:
- path: Workouts Watch Widget
excludes:
- "Resources/Info-*.plist"
+ postBuildScripts: *updateBuildInfo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp.widget
@@ -161,3 +166,21 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
WATCHOS_DEPLOYMENT_TARGET: "26.0"
TARGETED_DEVICE_FAMILY: "4"
+
+ # ---- iOS widget extension (the active-workout Live Activity: lock screen + Dynamic Island) --
+ Workouts Widget:
+ type: app-extension
+ platform: iOS
+ sources:
+ - path: Workouts Widget
+ excludes:
+ - "Resources/Info-*.plist"
+ postBuildScripts: *updateBuildInfo
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.widget
+ INFOPLIST_FILE: "Workouts Widget/Resources/Info-Widget.plist"
+ GENERATE_INFOPLIST_FILE: false
+ SWIFT_STRICT_CONCURRENCY: complete
+ IPHONEOS_DEPLOYMENT_TARGET: "26.0"
+ TARGETED_DEVICE_FAMILY: "1"