Three native surfaces show app state outside the app. On iOS, one Live Activity covers the active workout and the cardio timer, and three home screen widgets show today’s workout, nutrition and streak. On Android, a local Expo module posts an ongoing notification with a native chronometer for the cardio timer. All paths are relative to the mobile repo root. The iOS surfaces are written in TypeScript with expo-widgets and the SwiftUI components from @expo/ui/swift-ui. Layout functions start with the 'widget' directive and can only use what is passed in as state or props. They cannot import the strings module, which is why every label travels inside the state.

Live Activity

Files

State

WorkoutActivityState is a flat object. The important fields: buildState(session) in the controller fills the workout fields from the session, using currentExercise (first exercise that is not done), upcomingSet (first set that is not done) and the same computePlaceholders map the logger uses, so the figure matches the app.

Layouts

The layout function returns the lock screen banner plus the Dynamic Island slots (compactLeading, compactTrailing, minimal, expandedLeading, expandedTrailing, expandedCenter, expandedBottom). It has three branches:
  1. Cardio. When cardioActive is true and the workout is not completed, the whole activity shows the cardio timer: activity icon and title, a live clock, an optional target line and progress bar, and a pause or resume button. This branch wins even while a workout is running.
  2. Completed. A check mark, the done label and the summary line.
  3. Workout. Two states in one card. While training it shows the next weight and reps and a round check button. While resting it shows a countdown, a skip button and a linear progress bar over the rest window.
Timers are rendered by the system from date ranges, not pushed from JavaScript: Text with timerInterval and ProgressView with timerInterval. The elapsed workout clock uses a range of startedAt to startedAt + 14400000 (4 hours), and the cardio clock uses a 10 hour range (36000000 ms). The layout does not rely on the system mirroring for right to left. It reads state.isRTL and builds each row in the right order by hand. While cardio is paused the clock is a static label (cardioElapsedLabel) and not a system timer. cardioFields computes cardioStartedAt as runningSince - accumulatedMs so the running clock continues from the banked time.

Controller lifecycle

src/app/(app)/_layout.tsx wires the controller in one effect:
  • syncWorkoutActivity(session) receives the session only when it is started. A new day id ends the previous activity first.
  • syncCardioActivity(cardio) stores the cardio session. With no workout and no cardio it ends the activity.
  • syncActivity() returns early unless Platform.OS === 'ios'. It builds the state and calls pushState.
  • pushState skips the update when the serialised state equals the last one (lastStateKey). Otherwise it calls activity.update(state), or WorkoutActivity.start(state, deepLink) when no activity exists.
The deep link is perform://workout/DAY_ID, with ?plan=PROGRAM_ID appended when the session has a program id. A cardio-only activity links to perform://cardio/session.
The activity is not started until the Perform logo file exists in the shared widgets directory. A Live Activity can snapshot its first state before a quick follow-up update arrives, and that first snapshot would miss the logo for good. syncActivity waits for ensurePerformMarkLogo() when performMarkPath() is empty.
Ending. endActivity(session) clears the rest and dismisses rest alerts. If every exercise is done it ends with a final completed state that the system dismisses after 4 minutes (COMPLETED_DISMISS_MS). Otherwise it ends immediately. After an app kill. adoptRunningActivity() takes the first instance from WorkoutActivity.getInstances() and ends any duplicates. It only runs when a workout or cardio snapshot was found on disk (adoptionAllowed). sweepOrphanActivities() runs at init and ends every instance when the app has no session and no cardio, so a stale card never stays on the lock screen.

Buttons

Buttons in the layout carry a target string. initWorkoutActivityInteractions() registers addUserInteractionListener and routes on it: A set logged from the lock screen goes through the same store actions as a set logged in the app. The rest alert content for this path comes from setIslandRestAlertConfig, which useRestNotificationConfig keeps up to date. The rest timer itself is described on Workout session engine.

Patches

Both patches are registered under patchedDependencies in pnpm-workspace.yaml.

patches/expo-widgets@57.0.2.patch

The patch edits ios/Widgets/AppIntent.swift and adds the same two lines to both intent structs, WidgetUserInteraction and LiveActivityUserInteraction:
With these, a button press on a widget or the Live Activity runs the intent without opening the app, and the system does not ask the user to authenticate first. That lets the trainee tick a set or skip a rest from a locked phone.

patches/@expo__ui@57.0.3.patch

The patch adds a hidesCurrentValueLabel boolean prop to the SwiftUI ProgressView wrapper. It touches three files: the type declarations (build/swift-ui/ProgressView/index.d.ts and src/swift-ui/ProgressView/index.tsx) and the native view (ios/ProgressView.swift). When the prop is true and timerInterval is set, the native view builds the progress view with an empty currentValueLabel:
Without it, SwiftUI draws its own time label under a timer driven bar. The Live Activity sets the prop on the rest bar and the cardio target bar, because the card already shows its own clock.
Both patches change Swift code, so they only take effect in a new native build. They are keyed to exact versions (expo-widgets@57.0.2, @expo/ui@57.0.3). Upgrading either package means re-creating the patch. See OTA updates and Native modules and plugins.

Android cardio notification

Android has no Live Activity. The cardio timer gets an ongoing notification with a native Chronometer, so the clock keeps counting without JavaScript.

The local module

modules/cardio-notification is a local Expo module. expo-module.config.json limits it to Android and registers expo.modules.cardionotification.CardioNotificationModule. The module never receives timer values from JavaScript. update(labels) only saves localised strings. The notification reads the timer state from perform-active-cardio.json in the app’s files directory, the same file the JavaScript store writes (see Cardio session). CardioSnapshot reads session.accumulatedMs and session.runningSince and caps elapsed time at 600 minutes, the same cap as MAX_CARDIO_MINUTES.

What CardioNotifier.render posts

  • Channel cardio-session, default importance, no sound, no vibration, no badge, public on the lock screen. The channel name comes from the channelName label.
  • Notification id 7301, ongoing, setOnlyAlertOnce(true), category stopwatch, decorated custom view style.
  • Custom layouts cardio_notification_small.xml and cardio_notification_big.xml with a title, a status line, an optional target line and a Chronometer.
  • The chronometer base is SystemClock.elapsedRealtime() - state.elapsedMs(now), and it runs only while the session is running.
  • One action button, pause or resume, wired to CardioToggleReceiver.
  • A content intent that opens the deepLink label with ACTION_VIEW, scoped to the app’s own package.
render cancels the notification when there is no snapshot, or when notifications are not allowed. On Android 13 and later that includes a missing POST_NOTIFICATIONS permission. Labels are stored in SharedPreferences (perform_cardio_notification, key labels) so the receiver can re-render when the app process has no JavaScript running.

Pause and resume from the notification

CardioToggleReceiver calls CardioSnapshot.toggle, which edits the JSON file directly: pausing banks the elapsed time into accumulatedMs and sets runningSince to null, and resuming sets runningSince to now. It writes to a .tmp file and renames it over the original. The receiver then re-renders the notification and calls CardioNotificationModule.notifyChanged(), which emits onChange if the module is alive.

JavaScript wrapper

src/features/movement/lib/cardioNotification.ts:
  • syncCardioNotification(session) calls native.update(labelsFor(session)) or native.dismiss(). The labels are title, targetLabel, liveLabel, pausedLabel, pauseAction, resumeAction, channelName and deepLink.
  • initCardioNotification() subscribes to onChange and calls useCardioSession.getState().reload(), so the store picks up a toggle made from the notification.
  • refreshCardioFromNotification() calls reload() when the app becomes active, which covers a toggle made while JavaScript was not running.
requireOptionalNativeModule returns null on a binary built before the module existed, and every call is wrapped in try and catch. The JavaScript is therefore safe to ship over the air to older builds. They simply show no notification.

iOS home screen widgets

Declaration

app.json declares three widgets in the expo-widgets plugin config: The displayName and description values in app.json are Hebrew. The widget extension target and its app group entitlement are listed under extra.eas.build.experimental.ios.appExtensions.

Layouts

src/features/home/widgets/homeWidgets.tsx registers each widget with createWidget(name, layout). The name must match app.json. Layouts read environment.widgetFamily to size themselves and environment.colorScheme to pick the surface colour. The workout widget link carries no plan id, so the day screen resolves the day by scanning plans (see Workout screens).

Sync

syncHomeWidgets(input) in homeWidgetsSync.ts is the only writer. It returns early unless the platform is iOS. It keeps the last home and streak in module memory and calls pushTimelines():
  • The workout and nutrition widgets get a two entry timeline: the current props now, and a stale entry at the next local midnight. The stale workout entry sets completed: false and stale: true. The stale nutrition entry zeroes the consumed values and sets stale: true. The widget then asks the user to open the app instead of showing yesterday’s numbers as today’s.
  • These two are only pushed when the home data was fetched today (lastHomeFetchedAt >= startOfToday()).
  • The streak widget gets a single entry with the current streak and weekly dots.
Nutrition values come from home.rings by key (calories, protein, carbs, fat). The unit label is the portion label from mbpLabelOf(user) when the user has mbpEnabled, otherwise grams.

Logo files

src/features/branding/widgetBrandLogo.ts copies images into widgetsDirectory from expo-widgets, so the widget extension can load them by file path (logoPath).
  • ensureWidgetBrandLogo() downloads the studio logo (brand.logoUrl) to brand-logo.png and falls back to the Perform mark. The home widgets use it.
  • ensurePerformMarkLogo() installs the bundled Perform mark as perform-banner-logo-v5.png. The Live Activity uses it. The file name carries a version so a changed asset is not served from an old cache. A PNG is used because the SwiftUI image loader cannot decode the source SVG.
Branding itself is covered on Theming and branding.