A React Native / Expo iOS study timer whose state is mirrored to a native iOS Live Activity on the lock screen and Dynamic Island. The React Native app owns the timer; a hand-written native bridge drives ActivityKit; a SwiftUI widget renders it.
- App + bridge: Expo (SDK 56) + React Native + TypeScript
- Bridge: a local Expo module (Swift) calling ActivityKit — hand-written, no prebuilt package
- Widget: Swift / SwiftUI Widget Extension (generated by
@bacons/apple-targets)
Visual architecture walkthrough:
docs/architecture.html(open in a browser). Architecture rationale and trade-offs: seeDISCUSSION.md. Engineering invariants/conventions: seeCLAUDE.md.
The app owns the timer and displays it in HH:MM:SS (per spec), with Pause/Resume/Stop and a
goal progress bar.
| Running | Paused |
|---|---|
![]() |
![]() |
The Live Activity shows the session name, elapsed time, goal bar, and interactive Pause/Stop — tapping them controls the timer without opening the app.
Compact — (truncated) session name + live time:
Expanded — full name, on-device ticking time, goal ring, and interactive Pause/Stop:
| Running | Paused |
|---|---|
![]() |
![]() |
Minimal — just the elapsed time, shown beside another app's Live Activity (a Clock timer here). The minimal layout only appears when two apps have active Live Activities, so this is captured on a physical device.
- macOS with Xcode 16+ (Live Activities need iOS 16.2+; Dynamic Island UI needs an iPhone Pro simulator, e.g. iPhone 15/16/17 Pro)
- Node 18+ and npm
- CocoaPods (
sudo gem install cocoapodsorbrew install cocoapods) - Watchman recommended (
brew install watchman)
This is a development build, not Expo Go — Live Activities require custom native code, which Expo Go cannot load.
From the repo root:
# 1. Install JS dependencies
npm install
# 2. Generate the native iOS project (ios/ is gitignored and regenerated here)
npx expo prebuild -p ios --clean
# 3. Build + install + launch on an iOS simulator (pick an iPhone Pro for the Dynamic Island)
npx expo run:iosThe first build compiles React Native + the widget extension and takes several minutes.
npx expo run:ios starts Metro, builds, installs, and launches the app. To target a specific
simulator: npx expo run:ios --device "iPhone 17 Pro".
- Enter a session name, pick a goal (30s / 5m / 25m / 50m), and tap Start New Session → a Live Activity appears.
- The in-app timer counts up in HH:MM:SS; Pause/Resume and Stop control it. When it reaches the goal (default 5:00) the session completes — the clock stops at the goal and both the app and the Live Activity show "Goal reached" (this freeze happens on-device, so it still stops at the goal while backgrounded or killed).
- See the Live Activity:
- Lock screen: in the Simulator press ⌘L (or Device → Lock) → the banner shows the session name, elapsed time, and progress bar.
- Dynamic Island: leave the app (swipe to home / open another app). The compact pill shows the (truncated) session name + time; long-press it to see the expanded view (name, time, progress ring, and Pause/Stop controls).
- Pause freezes the time and shows "Paused" in the app and the Live Activity; Resume continues; Stop removes the Live Activity.
- Control it from the Live Activity: the Pause/Resume/Stop buttons on the lock screen and expanded Dynamic Island drive the timer via an App Intent — no need to open the app. When you return to the app it reconciles against the live activity, so its state stays in sync.
App.tsx RN screen (timer UI: start/pause/resume/stop)
hooks/useTimer.ts timer state machine + startAnchor math
lib/format.ts HH:MM:SS / progress helpers
modules/study-timer/ hand-written Expo module (the RN <-> ActivityKit bridge)
index.ts typed TS API the app calls
index.android.ts / .web.ts no-op stubs (Live Activities are iOS-only)
src/StudyTimer.types.ts shared TS types
ios/StudyTimerModule.swift ActivityKit: areEnabled/start/update/end/endAll/getActiveSessions
ios/StudyAttributes.swift ActivityAttributes (synced copy — see CLAUDE.md)
targets/widget/ SwiftUI Widget Extension (via @bacons/apple-targets)
StudyLiveActivity.swift lock screen + Dynamic Island (compact/expanded/minimal)
StudyWidgetBundle.swift @main widget bundle
StudyAttributes.swift ActivityAttributes (source of truth)
expo-target.config.js widget target config (frameworks, App Group)
app.json Expo config: bundle id, App Group, NSSupportsLiveActivities, plugin
ios/ and android/ are generated by expo prebuild and are gitignored. Don't edit them by
hand — edit the module/target source + app.json, then re-run prebuild.
Simulator is sufficient for the full feature set. To run on a real iPhone:
- Set your Apple Team in Xcode (open
ios/LiveTimer.xcworkspace→ Signing & Capabilities for both the app target andStudyWidget), or addios.appleTeamIdtoapp.json. npx expo run:ios --deviceand select your iPhone.
A free Apple ID works for development installs — there are no special capabilities to provision. The interactive lock-screen controls share state through ActivityKit's own activity store, so no App Group (and no paid account) is required.
- Dynamic Island not visible: use an iPhone Pro simulator; the Dynamic Island only shows when the app is not in the foreground.
- SwiftUI widget build oddities:
xcrun simctl --set previews delete all, then rebuild. - Changed native/config but nothing updated: re-run
npx expo prebuild -p ios --clean.






