diff --git a/workout-tracker/e2e/native-platform.spec.ts b/workout-tracker/e2e/native-platform.spec.ts index ce71d58..bcbf0e5 100644 --- a/workout-tracker/e2e/native-platform.spec.ts +++ b/workout-tracker/e2e/native-platform.spec.ts @@ -375,6 +375,58 @@ test.describe('Native Live Activity wiring', () => { }); }); +test.describe('Live Activity content state', () => { + // toContentState() only ever produces the Record that + // crosses the JS-to-native bridge (ActivityKit content states can't carry + // numbers), so it's tested directly rather than through the plugin-wiring + // suite above — the console-warning signal that suite polls for can't + // observe payload contents, only that a method was called at all. + test('includes restStartTime alongside restEndTime, both stringified', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('#app'); + + const contentState = await page.evaluate(async () => { + const { toContentState } = await import('/src/native/liveActivity.ts'); + return toContentState({ + dayName: 'Day 1', + exerciseName: 'Bench Press', + setIndex: 2, + setTotal: 5, + restEndTime: 1700000090000, + restStartTime: 1700000000000, + }); + }); + + expect(contentState.restEndTime).toBe('1700000090000'); + expect(contentState.restStartTime).toBe('1700000000000'); + }); + + // Regression coverage for the ProgressView(timerInterval:) range this + // feeds on the Swift side: a range anchored on a missing start would trap + // ("Range requires lowerBound <= upperBound") on the widget, so the JS + // side must always emit an explicit empty string (never omit the key) + // when not resting, mirroring restEndTime's existing not-resting shape. + test('sends restStartTime as an empty string when not resting', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('#app'); + + const contentState = await page.evaluate(async () => { + const { toContentState } = await import('/src/native/liveActivity.ts'); + return toContentState({ + dayName: 'Day 1', + exerciseName: 'Bench Press', + setIndex: 2, + setTotal: 5, + restEndTime: null, + restStartTime: null, + }); + }); + + expect(contentState.restEndTime).toBe(''); + expect(contentState.restStartTime).toBe(''); + }); +}); + test.describe('Native data export', () => { test('export writes a file via Filesystem and opens the native share sheet', async ({ page }) => { await page.addInitScript(() => { diff --git a/workout-tracker/ios/App/LiveActivityWidget/LiveActivityWidget.swift b/workout-tracker/ios/App/LiveActivityWidget/LiveActivityWidget.swift index ae81245..5b1e38e 100644 --- a/workout-tracker/ios/App/LiveActivityWidget/LiveActivityWidget.swift +++ b/workout-tracker/ios/App/LiveActivityWidget/LiveActivityWidget.swift @@ -11,15 +11,21 @@ // `toContentState()`: exerciseName (a human-readable exercise catalog name, // e.g. "Hanging Leg Raise" — resolved from the exerciseId slug on the TS // side by src/logic/exerciseName.ts, so this file only ever deals with -// display-ready text), setProgress ("x/y"), and restEndTime (a stringified -// epoch-ms timestamp, empty string when not resting). +// display-ready text), setProgress ("x/y"), and restEndTime/restStartTime +// (each a stringified epoch-ms timestamp, empty string when not resting). // // Every presentation (lock screen, expanded island, compact/minimal island) // makes rest-vs-active an explicit state, not just "is there a timer or // not": orange tint while resting vs. the accent color while active, plus -// (see brandedIcon below) an orange ring around the app icon itself while -// resting — so a glance at the pill alone tells you which one you're in, -// even before you've registered whether digits are present. +// (see brandedIcon below) a ring around the app icon itself while resting +// that depletes clockwise over the rest period — full right as rest starts, +// gone right as the next set is due — so a glance at the pill alone tells +// you which state you're in *and* roughly how much rest is left, even +// before you've registered whether digits are present. That last part +// matters most in `minimal`: it's the one presentation with no digits at +// all, just this one glyph, so the ring there isn't a redundant echo of +// nearby text the way it would be next to `compactTrailing`'s countdown — +// it's the only signal. // // LiveActivityIcon (Assets.xcassets, in this same target folder) is the app // icon. It's shown everywhere this widget renders an icon — lock screen @@ -27,12 +33,12 @@ // active and resting states, so the pill always reads as *this* app's // activity rather than a generic system glyph a glance could mistake for // any other running timer (Clock app, another app's Live Activity, ...). -// brandedIcon() draws a ring around it while resting rather than swapping +// brandedIcon() draws the ring around it while resting rather than swapping // the icon out for a bare SF Symbol (or overlaying a small badge glyph — // illegible at this scale, and clipped by the system's automatic circular // mask on the `minimal` presentation), so app identity and rest-vs-active -// state are both visible at once even in the compact/minimal island's -// single small glyph slot. +// state/progress are all visible at once even in the compact/minimal +// island's single small glyph slot. import ActivityKit import WidgetKit @@ -83,6 +89,10 @@ struct WorkoutLiveActivityWidget: Widget { .padding() } dynamicIsland: { context in let restEndTime = restEndDate(context.state.values["restEndTime"]) + let restRange = restProgressRange( + start: restStartDate(context.state.values["restStartTime"]), + end: restEndTime + ) // dynamicIsland's closure type is a plain (Context) -> DynamicIsland, // not @ViewBuilder — so once a `let` precedes it, Swift's @@ -110,7 +120,7 @@ struct WorkoutLiveActivityWidget: Widget { } } } compactLeading: { - brandedIcon(size: 20, cornerRadius: 5, isResting: restEndTime != nil) + brandedIcon(size: 20, cornerRadius: 5, restRange: restRange) } compactTrailing: { if let restEndTime { Text(timerInterval: Date.now...restEndTime, countsDown: true) @@ -121,43 +131,62 @@ struct WorkoutLiveActivityWidget: Widget { Text(context.state.values["setProgress"] ?? "") } } minimal: { - brandedIcon(size: 18, cornerRadius: nil, isResting: restEndTime != nil) + brandedIcon(size: 18, cornerRadius: nil, restRange: restRange) } .keylineTint(restEndTime != nil ? Color.orange : Color.accentColor) } } - // The app icon for the compact/minimal Dynamic Island, ringed in orange - // while resting. `size` drives both the icon's frame and the ring's - // line width, so callers just pick one number per slot. `cornerRadius` - // selects the shape: a rounded square (compact leading slot, matching - // the app's normal icon shape) when non-nil, a circle (minimal slot) - // when nil — the ring reuses the exact same shape as the clip, so on - // `minimal`, where the system additionally forces its own circular - // mask on whatever this returns, the ring already coincides with that - // mask instead of a corner badge that would fall outside it and get - // clipped away. - private func brandedIcon(size: CGFloat, cornerRadius: CGFloat?, isResting: Bool) -> some View { + // The app icon for the compact/minimal Dynamic Island, ringed while + // resting with a `ProgressView(timerInterval:)` that the system + // animates continuously on its own — no repeated content-state pushes + // needed — depleting from a full ring at rest's start to none at rest's + // end, the same "hand SwiftUI a fixed date range once" trick + // `Text(timerInterval:)` above already uses for the digits. `size` + // drives the icon's frame, so callers just pick one number per slot. + // `cornerRadius` selects the icon's own clip shape: a rounded square + // (compact leading slot, matching the app's normal icon shape) when + // non-nil, a circle (minimal slot) when nil — the ring itself is always + // circular regardless, since `.circular` is the only + // ProgressViewStyle that supports the animated `timerInterval` form. + // `restRange` being nil (not resting, or a range that failed + // restProgressRange()'s validation below) draws no ring at all, same as + // the old `isResting == false` case. + private func brandedIcon(size: CGFloat, cornerRadius: CGFloat?, restRange: ClosedRange?) -> some View { + // The ring needs room outside the icon's own edge to read as a ring + // rather than an overlapping stroke, so the icon shrinks slightly + // whenever one is actually going to be drawn. + let ringInset = size * 0.16 + let iconSize = restRange != nil ? size - ringInset * 2 : size let icon = Image("LiveActivityIcon") .resizable() .scaledToFit() - .frame(width: size, height: size) - let ringWidth = size * 0.14 + .frame(width: iconSize, height: iconSize) + // Only the clip shape actually differs per call site (rounded + // square vs. circle) — the ring itself is always circular (see the + // doc comment above), so it's applied once below rather than + // duplicated per branch. return Group { if let cornerRadius { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - icon.clipShape(shape).overlay { - if isResting { - shape.strokeBorder(Color.orange, lineWidth: ringWidth) - } - } + icon.clipShape(RoundedRectangle(cornerRadius: max(cornerRadius - ringInset, 2), style: .continuous)) } else { - icon.clipShape(Circle()).overlay { - if isResting { - Circle().strokeBorder(Color.orange, lineWidth: ringWidth) - } - } + icon.clipShape(Circle()) + } + } + .frame(width: size, height: size) + .background { + if let restRange { + // `ProgressView(timerInterval:).progressViewStyle(.circular)` + // is a system control with its own intrinsic size — unlike + // the old `Shape.strokeBorder`, it won't stretch to fill + // `.background`'s proposed size on its own, so it needs an + // explicit frame or it can render smaller than the icon + // it's meant to ring. + ProgressView(timerInterval: restRange, countsDown: true) + .progressViewStyle(.circular) + .tint(.orange) + .frame(width: size, height: size) } } } @@ -174,4 +203,37 @@ struct WorkoutLiveActivityWidget: Widget { // expand/collapse, ...) would otherwise crash the widget extension. return date > Date() ? date : nil } + + private func restStartDate(_ raw: String?) -> Date? { + // No "must be in the future" check here, unlike restEndDate() above + // — a rest start is expected to be in the past by the time this + // renders. restProgressRange() below is what validates it against + // the end date. + guard let raw, let ms = Double(raw), ms > 0 else { return nil } + return Date(timeIntervalSince1970: ms / 1000) + } + + // The date range brandedIcon() hands to `ProgressView(timerInterval:)`. + // Like `Text(timerInterval:)`, it traps ("Range requires lowerBound <= + // upperBound") on an invalid range — this guards both directions: a + // missing/unparseable restStartTime (e.g. a content-state push from + // before this field existed) and a malformed or zero-duration push + // where start ends up >= end. `end` itself is already guarded by + // restEndDate() above before it ever reaches here, so this only needs + // to check start against it; nil either way just means no ring, not a + // crash. + // + // This validation would belong in ios/App/AppLogic per CLAUDE.md's + // "New Swift logic goes in ios/App/AppLogic, with a test" (so it'd get + // AppLogicTests coverage instead of relying on the Simulator smoke + // test), but AppLogic isn't linked into the LiveActivityWidget + // extension target — only App is (see "Link AppLogic into the App + // target" in ios/MANUAL_SETUP.md) — and adding that link is an + // Xcode-GUI-only step, same as adding a new unit test target, not + // something scriptable from here. It lives beside restEndDate() above + // instead, for the same reason that one already does. + private func restProgressRange(start: Date?, end: Date?) -> ClosedRange? { + guard let start, let end, start < end else { return nil } + return start...end + } } diff --git a/workout-tracker/src/native/liveActivity.ts b/workout-tracker/src/native/liveActivity.ts index f997399..1d09043 100644 --- a/workout-tracker/src/native/liveActivity.ts +++ b/workout-tracker/src/native/liveActivity.ts @@ -10,13 +10,27 @@ export interface WorkoutActivityState { setTotal: number; /** Absolute epoch ms the current rest timer ends at, or null if not resting. */ restEndTime: number | null; + /** + * Absolute epoch ms the current rest timer started at, or null if not + * resting. Paired with restEndTime so the widget can draw a depleting + * progress ring around the app icon (full at the start of rest, empty at + * the end) instead of a plain "resting: yes/no" indicator — restEndTime + * alone isn't enough for that, since the ring needs both ends of a fixed + * date range. See LiveActivityWidget.swift's restProgressRange(). + */ + restStartTime: number | null; } -function toContentState(state: WorkoutActivityState): Record { +// Exported (rather than kept private to this module) so it's directly +// unit-testable the way isNativePlatform()/formatTime()/getRemainingMs() +// are elsewhere in this codebase — see e2e/native-platform.spec.ts's "Live +// Activity content state" suite. +export function toContentState(state: WorkoutActivityState): Record { return { exerciseName: state.exerciseName, setProgress: `${state.setIndex}/${state.setTotal}`, restEndTime: state.restEndTime !== null ? String(state.restEndTime) : '', + restStartTime: state.restStartTime !== null ? String(state.restStartTime) : '', }; } @@ -63,7 +77,7 @@ export async function endWorkoutActivity(): Promise { const { LiveActivity } = await import('capacitor-live-activity'); await LiveActivity.endActivity({ id: ACTIVITY_ID, - contentState: { exerciseName: '', setProgress: '', restEndTime: '' }, + contentState: { exerciseName: '', setProgress: '', restEndTime: '', restStartTime: '' }, dismissalPolicy: 'immediate', }); } catch (err) { diff --git a/workout-tracker/src/ui/workout.ts b/workout-tracker/src/ui/workout.ts index d11b14e..d006c88 100644 --- a/workout-tracker/src/ui/workout.ts +++ b/workout-tracker/src/ui/workout.ts @@ -11,7 +11,7 @@ import { completeWorkoutAtomic, putTrainingMaxesAtomic, } from '../db/database'; -import type { CompletedSet, WorkoutLog, TemplateSet, ActiveWorkout, ProgressionState } from '../db/types'; +import type { CompletedSet, WorkoutLog, TemplateSet, ActiveWorkout, ProgressionState, TimerState } from '../db/types'; import { calculateWorkingWeight, calculatePlates, formatPlates, calculateResetTM } from '../logic/calculator'; import { advanceState } from '../logic/progression'; import { computeVolumeGroups, evaluateBonusSetNeed, getVolumeGroupKey, computeBonusInsertionIndex, computeVolumeProgress, findRemovableBonusSetIndex, computeOwedReps } from '../logic/volume'; @@ -195,10 +195,14 @@ export async function renderWorkout(container: HTMLElement): Promise { // detectors only ever race against each other, never against a different // render's. let timerExpiryHandled = false; - // Rest-timer end time as last reported to the Live Activity (native-only; + // Rest-timer start/end as last reported to the Live Activity (native-only; // no-op on web). Tracked separately from the DOM/IndexedDB timer state so // the activity payload can be rebuilt on demand without re-reading either. + // Both travel together — restStartTime only ever has meaning paired with + // a restEndTime — so they're only ever written together, by + // syncLiveActivity()/restStartTimeOf() below. let liveActivityRestEndTime: number | null = null; + let liveActivityRestStartTime: number | null = null; // Restore in-progress workout if one exists for this same day (either it // already matched, or the conflict above was just resolved by resuming it). @@ -717,11 +721,21 @@ export async function renderWorkout(container: HTMLElement): Promise { setIndex: Math.min(currentSetIndex + 1, workoutSets.length), setTotal: workoutSets.length, restEndTime: liveActivityRestEndTime, + restStartTime: liveActivityRestStartTime, }; } - function syncLiveActivity(restEndTime: number | null) { - liveActivityRestEndTime = restEndTime; + // TimerState only stores expectedEndTime + durationMs (see logic/timer.ts) + // — derived here, in the one place both syncLiveActivity() and the + // mount-time timer-recovery branch below need it, rather than + // recomputing `expectedEndTime - durationMs` at each call site. + function restStartTimeOf(timer: TimerState): number { + return timer.expectedEndTime - timer.durationMs; + } + + function syncLiveActivity(timer: TimerState | null) { + liveActivityRestEndTime = timer?.expectedEndTime ?? null; + liveActivityRestStartTime = timer ? restStartTimeOf(timer) : null; void updateWorkoutActivity(liveActivityState()); } @@ -831,7 +845,7 @@ export async function renderWorkout(container: HTMLElement): Promise { const timer = createTimerState(restSeconds); await putTimerState(timer); scheduleBackgroundTimerNotification(timer.expectedEndTime); - syncLiveActivity(timer.expectedEndTime); + syncLiveActivity(timer); timerEl.classList.remove('hidden'); setDoneButtonDisabled(true); @@ -1116,6 +1130,7 @@ export async function renderWorkout(container: HTMLElement): Promise { const remaining = getRemainingMs(existingTimer); if (remaining > 0) { liveActivityRestEndTime = existingTimer.expectedEndTime; + liveActivityRestStartTime = restStartTimeOf(existingTimer); timerEl.classList.remove('hidden'); setDoneButtonDisabled(true); // timerInterval is shared module state — if a previous render left