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 screenbanner plus the Dynamic Island slots (compactLeading, compactTrailing, minimal, expandedLeading, expandedTrailing, expandedCenter, expandedBottom). It has three branches:
- Cardio. When
cardioActiveis 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. - Completed. A check mark, the done label and the summary line.
- 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.
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 unlessPlatform.OS === 'ios'. It builds the state and callspushState.pushStateskips the update when the serialised state equals the last one (lastStateKey). Otherwise it callsactivity.update(state), orWorkoutActivity.start(state, deepLink)when no activity exists.
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.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 atarget 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 underpatchedDependencies 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:
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:
Android cardio notification
Android has no Live Activity. The cardio timer gets an ongoing notification with a nativeChronometer, 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 thechannelNamelabel. - Notification id
7301, ongoing,setOnlyAlertOnce(true), category stopwatch, decorated custom view style. - Custom layouts
cardio_notification_small.xmlandcardio_notification_big.xmlwith a title, a status line, an optional target line and aChronometer. - 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
deepLinklabel withACTION_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)callsnative.update(labelsFor(session))ornative.dismiss(). The labels aretitle,targetLabel,liveLabel,pausedLabel,pauseAction,resumeAction,channelNameanddeepLink.initCardioNotification()subscribes toonChangeand callsuseCardioSession.getState().reload(), so the store picks up a toggle made from the notification.refreshCardioFromNotification()callsreload()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: falseandstale: true. The stale nutrition entry zeroes the consumed values and setsstale: 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.
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) tobrand-logo.pngand falls back to the Perform mark. The home widgets use it.ensurePerformMarkLogo()installs the bundled Perform mark asperform-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.