Add a Live Activity for the running workout
A new iOS widget extension shows the active exercise, its phase, and the work/rest countdown on the lock screen and in the Dynamic Island, driven by the run flow's live frames so locking the phone mid-set keeps the timer. The activity is seeded on open, refreshed on every page settle, dismissed when the flow is left, and cleared on next launch if stranded. Unifies the build-info stamping across all targets via a YAML anchor. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
<?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>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Workouts</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>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ final class AppServices {
|
|||||||
let workoutLauncher = WorkoutLauncher()
|
let workoutLauncher = WorkoutLauncher()
|
||||||
let workoutHealthDeleter = WorkoutHealthDeleter()
|
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.
|
/// Ephemeral live-run state fed by the watch, observed by the mirror UI. Not persisted.
|
||||||
let liveRunState: LiveRunState
|
let liveRunState: LiveRunState
|
||||||
|
|
||||||
@@ -37,6 +41,8 @@ final class AppServices {
|
|||||||
if let bootstrapTask { await bootstrapTask.value; return }
|
if let bootstrapTask { await bootstrapTask.value; return }
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
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()
|
await self.syncEngine.connect()
|
||||||
self.watchBridge.activate()
|
self.watchBridge.activate()
|
||||||
// Past the iCloud gate: request the workout-share scope so the user can still
|
// Past the iCloud gate: request the workout-share scope so the user can still
|
||||||
|
|||||||
@@ -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<State>
|
||||||
|
|
||||||
|
/// 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<WorkoutActivityAttributes>.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<WorkoutActivityAttributes>.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<WorkoutActivityAttributes>.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSHealthShareUsageDescription</key>
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ struct ExerciseProgressView: View {
|
|||||||
let onLive: (LiveProgress) -> Void
|
let onLive: (LiveProgress) -> Void
|
||||||
let onLiveEnded: () -> 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
|
/// 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
|
/// 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.
|
/// without re-broadcasting or re-recording — the originating device owns the durable write.
|
||||||
@@ -96,12 +105,14 @@ struct ExerciseProgressView: View {
|
|||||||
/// `startsCompleted`.
|
/// `startsCompleted`.
|
||||||
@State private var startsSkipped: Bool
|
@State private var startsSkipped: Bool
|
||||||
|
|
||||||
init(doc: Binding<WorkoutDocument>, logID: String, onChange: @escaping () -> Void, onLive: @escaping (LiveProgress) -> Void = { _ in }, onLiveEnded: @escaping () -> Void = {}, incomingFrame: LiveProgress? = nil) {
|
init(doc: Binding<WorkoutDocument>, 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._doc = doc
|
||||||
self.logID = logID
|
self.logID = logID
|
||||||
self.onChange = onChange
|
self.onChange = onChange
|
||||||
self.onLive = onLive
|
self.onLive = onLive
|
||||||
self.onLiveEnded = onLiveEnded
|
self.onLiveEnded = onLiveEnded
|
||||||
|
self.onActivity = onActivity
|
||||||
|
self.onActivityEnded = onActivityEnded
|
||||||
self.incomingFrame = incomingFrame
|
self.incomingFrame = incomingFrame
|
||||||
|
|
||||||
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
let log = doc.wrappedValue.logs.first { $0.id == logID }
|
||||||
@@ -266,6 +277,9 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
broadcastLive(for: newPage)
|
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
|
.onChange(of: incomingFrame) { _, frame in
|
||||||
if let frame { applyIncoming(frame) }
|
if let frame { applyIncoming(frame) }
|
||||||
@@ -288,8 +302,10 @@ struct ExerciseProgressView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.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()
|
onLiveEnded()
|
||||||
|
onActivityEnded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +330,21 @@ struct ExerciseProgressView: View {
|
|||||||
} else {
|
} else {
|
||||||
broadcastLive(for: currentPage)
|
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
|
/// Push the current flow position to the watch. The anchor is stamped *now* — the page
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ struct LiveRunCoverView: View {
|
|||||||
onLiveEnded: {
|
onLiveEnded: {
|
||||||
services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
|
services.watchBridge.sendLiveEnded(workoutID: frame.workoutID, logID: frame.logID)
|
||||||
},
|
},
|
||||||
|
onActivity: { services.liveActivity.update($0) },
|
||||||
|
onActivityEnded: { services.liveActivity.end() },
|
||||||
incomingFrame: incomingFrame
|
incomingFrame: incomingFrame
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ struct WorkoutLogListView: View {
|
|||||||
onChange: { save() },
|
onChange: { save() },
|
||||||
onLive: { services.watchBridge.sendLiveProgress($0) },
|
onLive: { services.watchBridge.sendLiveProgress($0) },
|
||||||
onLiveEnded: { services.watchBridge.sendLiveEnded(workoutID: doc.id, logID: logID) },
|
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 }
|
incomingFrame: liveRun.current.flatMap { $0.logID == logID ? $0 : nil }
|
||||||
)
|
)
|
||||||
.onAppear { liveRun.navigatedRunID = logID }
|
.onAppear { liveRun.navigatedRunID = logID }
|
||||||
|
|||||||
+39
-16
@@ -23,6 +23,19 @@ packages:
|
|||||||
url: https://git.rzen.dev/rzen/indie-sync.git
|
url: https://git.rzen.dev/rzen/indie-sync.git
|
||||||
from: "0.3.0"
|
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:
|
targets:
|
||||||
# ---- iOS app (owns iCloud Drive sync; embeds the watch app) ----------------
|
# ---- iOS app (owns iCloud Drive sync; embeds the watch app) ----------------
|
||||||
Workouts:
|
Workouts:
|
||||||
@@ -43,18 +56,16 @@ targets:
|
|||||||
- path: LICENSE.md
|
- path: LICENSE.md
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
type: file
|
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:
|
dependencies:
|
||||||
- package: IndieAbout
|
- package: IndieAbout
|
||||||
- package: IndieSync
|
- package: IndieSync
|
||||||
- target: Workouts Watch App
|
- target: Workouts Watch App
|
||||||
postBuildScripts:
|
- target: Workouts Widget
|
||||||
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
|
embed: true
|
||||||
name: Update Build Info
|
postBuildScripts: *updateBuildInfo
|
||||||
shell: /bin/sh
|
|
||||||
basedOnDependencyAnalysis: false
|
|
||||||
inputFiles:
|
|
||||||
- $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)
|
|
||||||
- $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts
|
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts
|
||||||
@@ -105,14 +116,7 @@ targets:
|
|||||||
- package: IndieSync
|
- package: IndieSync
|
||||||
- target: Workouts Watch Widget
|
- target: Workouts Watch Widget
|
||||||
embed: true
|
embed: true
|
||||||
postBuildScripts:
|
postBuildScripts: *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
|
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp
|
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp
|
||||||
@@ -153,6 +157,7 @@ targets:
|
|||||||
- path: Workouts Watch Widget
|
- path: Workouts Watch Widget
|
||||||
excludes:
|
excludes:
|
||||||
- "Resources/Info-*.plist"
|
- "Resources/Info-*.plist"
|
||||||
|
postBuildScripts: *updateBuildInfo
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp.widget
|
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Workouts.watchkitapp.widget
|
||||||
@@ -161,3 +166,21 @@ targets:
|
|||||||
SWIFT_STRICT_CONCURRENCY: complete
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
WATCHOS_DEPLOYMENT_TARGET: "26.0"
|
WATCHOS_DEPLOYMENT_TARGET: "26.0"
|
||||||
TARGETED_DEVICE_FAMILY: "4"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user