Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions workout-tracker/e2e/native-platform.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -375,6 +375,58 @@ test.describe('Native Live Activity wiring', () => {
});
});

test.describe('Live Activity content state', () => {
// toContentState() only ever produces the Record<string, string> 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(() => {
Expand Down
130 changes: 96 additions & 34 deletions workout-tracker/ios/App/LiveActivityWidget/LiveActivityWidget.swift
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,28 +11,34 @@
// `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
// banner, expanded island, and compact/minimal island — in *both* the
// 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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)
Expand All@@ -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<Date>?) -> 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)
}
}
}
Expand All@@ -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<Date>? {
guard let start, let end, start < end else { return nil }
return start...end
}
}
18 changes: 16 additions & 2 deletions workout-tracker/src/native/liveActivity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, string> {
// 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<string, string> {
return {
exerciseName: state.exerciseName,
setProgress: `${state.setIndex}/${state.setTotal}`,
restEndTime: state.restEndTime !== null ? String(state.restEndTime) : '',
restStartTime: state.restStartTime !== null ? String(state.restStartTime) : '',
};
}

Expand DownExpand Up@@ -63,7 +77,7 @@ export async function endWorkoutActivity(): Promise<void> {
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) {
Expand Down
25 changes: 20 additions & 5 deletions workout-tracker/src/ui/workout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -195,10 +195,14 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
// 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).
Expand DownExpand Up@@ -717,11 +721,21 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
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());
}

Expand DownExpand Up@@ -831,7 +845,7 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
const timer = createTimerState(restSeconds);
await putTimerState(timer);
scheduleBackgroundTimerNotification(timer.expectedEndTime);
syncLiveActivity(timer.expectedEndTime);
syncLiveActivity(timer);

timerEl.classList.remove('hidden');
setDoneButtonDisabled(true);
Expand DownExpand Up@@ -1116,6 +1130,7 @@ export async function renderWorkout(container: HTMLElement): Promise<void> {
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
Expand Down
Loading