@tracked/notifications
v0.0.2
Published
Persistent notifications module for workout app
Maintainers
Readme
@tracked/notifications
Production-ready Expo module for persistent workout timer notifications with Android foreground services and iOS Live Activities.
Built from the ground up with native Swift and Kotlin, this module provides accurate, battery-efficient workout timers that survive app backgrounding, force quits, and device restarts.
✨ Features
Android
- ✅ Foreground service with HEALTH service type (Android 14+)
- ✅ Persistent notifications with pause/resume/stop action buttons
- ✅ Drift-free timers using
SystemClock.elapsedRealtime() - ✅ State persistence survives app kill and device restart
- ✅ Doze mode exempt - continues running in background
- ✅ Deep linking to specific workout screens
- ✅ Android 13+ permission handling
iOS
- ✅ Live Activities on lock screen and Dynamic Island (iOS 16.2+)
- ✅ Widget Extension with SwiftUI implementation
- ✅ Real-time updates without background execution
- ✅ State restoration after app termination
- ✅ Dynamic Island compact, minimal, and expanded views
- ✅ Timestamp-based calculations for accuracy
Cross-Platform
- ✅ TypeScript-first API with complete type safety
- ✅ React hooks for easy integration
- ✅ Event emitters for timer ticks and actions
- ✅ Unified API across Android and iOS
- ✅ Permission management built-in
📦 Installation
npm install @tracked/notifications
# or
pnpm add @tracked/notificationsAdd to app.config.ts
export default {
plugins: [
'@tracked/notifications',
// or with custom App Group:
// ['@tracked/notifications', { appGroupId: 'group.com.yourapp.workouts' }]
]
}Prerequisites
- Expo SDK 53+ or React Native 0.79+
- Android: minSdkVersion 26+ (Android 8.0+)
- iOS: iOS 16.2+ (for Live Activities)
🚀 Quick Start
Basic Usage
import { Notifications, useWorkoutTimer } from '@tracked/notifications';
function WorkoutScreen() {
const {
isActive,
isPaused,
elapsedTime,
formattedTime,
start,
pause,
resume,
stop,
update
} = useWorkoutTimer('workout_123');
const handleStart = async () => {
await start({
workoutId: 'workout_123',
workoutType: 'Strength Training',
exerciseName: 'Squats',
currentSet: 1,
currentReps: 0,
notification: {
title: 'Workout Active',
showPauseButton: true,
showStopButton: true,
}
});
};
return (
<View>
<Text>{formattedTime}</Text>
{!isActive && <Button onPress={handleStart} title="Start Workout" />}
{isActive && !isPaused && <Button onPress={pause} title="Pause" />}
{isActive && isPaused && <Button onPress={resume} title="Resume" />}
{isActive && <Button onPress={stop} title="Stop" />}
</View>
);
}Updating Timer State
// Update exercise, sets, reps during workout
await Notifications.updateTimer('workout_123', {
exerciseName: 'Deadlifts',
currentSet: 2,
currentReps: 5,
});
// Start rest timer (60 seconds)
await Notifications.updateTimer('workout_123', {
restTimerDuration: 60000, // milliseconds
});Permission Handling
import { useNotificationPermissions } from '@tracked/notifications';
function PermissionScreen() {
const { hasPermission, isLoading, requestPermission } = useNotificationPermissions();
if (isLoading) return <ActivityIndicator />;
if (!hasPermission) {
return (
<Button
onPress={requestPermission}
title="Enable Notifications"
/>
);
}
return <Text>Notifications enabled ✓</Text>;
}📱 Setup
iOS + Android: Automatic Configuration
The config plugin automatically handles all native setup:
✅ iOS:
- Adds
NSSupportsLiveActivitiesto Info.plist - Configures App Groups entitlements
- Creates Widget Extension target
- Copies Swift files (WorkoutActivityAttributes, WorkoutLiveActivity)
✅ Android:
- Adds foreground service permissions
- Configures notification channels
- Sets up health service type (Android 14+)
Build & Run
# Generate native projects (optional - EAS Build does this automatically)
npx expo prebuild --clean
# Run on iOS
npx expo run:ios
# Run on Android
npx expo run:androidEAS Build
Works seamlessly with EAS Build - no extra configuration needed:
eas build --platform ios
eas build --platform androidEAS Build automatically runs expo prebuild, which applies the config plugin.
Testing Android Foreground Service
# Enable Doze mode to test foreground service exemption
adb shell dumpsys deviceidle force-idle
# Verify timer continues running and notification remains visible
# Exit Doze mode
adb shell dumpsys deviceidle unforceManual Setup (Not Recommended)
If you need to manually configure the iOS Widget Extension for some reason, see ios/WIDGET_EXTENSION_SETUP.md. However, this is not necessary when using the config plugin.
📖 API Reference
Module Methods
startWorkoutTimer(config: WorkoutTimerConfig): Promise<string>
Starts a new workout timer with foreground service (Android) or Live Activity (iOS).
Parameters:
workoutId(string, required): Unique identifierworkoutType(string): e.g., "Strength Training", "HIIT"exerciseName(string): Current exercise namecurrentSet(number): Starting set numbercurrentReps(number): Starting rep counttotalDuration(number, optional): Total duration in millisecondsnotification(NotificationConfig): Notification display config
Returns: Activity/timer ID
updateTimer(workoutId: string, update: WorkoutTimerUpdate): Promise<void>
Updates the current workout timer state.
Update Fields:
exerciseName: Change exercisecurrentSet: Update set numbercurrentReps: Update rep countrestTimerDuration: Start rest timer (milliseconds)sessionTimerDisplay: Custom time display
pauseTimer(workoutId: string): Promise<void>
Pauses the timer. Notification shows "Paused" state.
resumeTimer(workoutId: string): Promise<void>
Resumes a paused timer. Accurately accounts for pause duration.
stopTimer(workoutId: string): Promise<void>
Stops timer and removes notification/Live Activity.
getActiveTimer(): Promise<WorkoutTimerState | null>
Gets current active timer state, or null if no timer running.
requestPermissions(): Promise<boolean>
Requests notification permissions (Android 13+, iOS 12+).
hasPermissions(): Promise<boolean>
Checks if notification permissions are granted.
Events
onTimerTick
Fired every second during active timer.
Notifications.addListener('onTimerTick', (event: TimerTickEvent) => {
console.log(`Elapsed: ${event.elapsed}ms`);
console.log(`Formatted: ${event.formattedTime}`);
});onWorkoutComplete
Fired when timer reaches total duration.
Notifications.addListener('onWorkoutComplete', (event: WorkoutCompleteEvent) => {
console.log(`Workout completed: ${event.totalDuration}ms`);
});onActionPressed
Fired when notification action button pressed.
Notifications.addListener('onActionPressed', (event: ActionPressedEvent) => {
switch (event.action) {
case 'pause':
// Handle pause from notification
break;
case 'resume':
// Handle resume
break;
case 'stop':
// Handle stop
break;
}
});React Hooks
useWorkoutTimer(workoutId: string)
Hook for managing a workout timer.
Returns:
isActive(boolean): Whether timer is runningisPaused(boolean): Whether timer is pausedelapsedTime(number): Elapsed time in millisecondsformattedTime(string): Formatted time (HH:MM:SS)currentState(WorkoutTimerState | null): Full state objectstart(config): Function to start timerpause(): Function to pauseresume(): Function to resumestop(): Function to stopupdate(data): Function to update state
useNotificationPermissions()
Hook for managing notification permissions.
Returns:
hasPermission(boolean | null): Permission statusisLoading(boolean): Loading staterequestPermission(): Function to request
useNotificationActions(onAction: callback)
Hook for handling notification action presses.
useAppStateRestoration(workoutId, onRestored)
Hook for automatic state restoration when app returns to foreground.
Utility Functions
formatTime(milliseconds: number): string
Formats milliseconds to HH:MM:SS string.
formatTime(65000); // "00:01:05"parseTime(formatted: string): number
Parses HH:MM:SS string to milliseconds.
parseTime("00:01:05"); // 65000🎨 Type Definitions
Complete TypeScript definitions included:
interface WorkoutTimerConfig {
workoutId: string;
workoutType: string;
exerciseName: string;
totalDuration?: number;
currentSet?: number;
currentReps?: number;
notification: NotificationConfig;
}
interface NotificationConfig {
title: string;
body?: string;
showPauseButton?: boolean;
showStopButton?: boolean;
deepLinkUrl?: string;
useChronometer?: boolean; // Android only
color?: string; // Android only
}
interface WorkoutTimerState {
workoutId: string;
status: TimerStatus;
isPaused: boolean;
startTimestamp: number;
totalPausedTime: number;
currentExercise: string;
currentSet: number;
currentReps: number;
sessionTimerDisplay: string;
restTimerEnd: number | null;
}
enum TimerStatus {
IDLE = 'idle',
RUNNING = 'running',
PAUSED = 'paused',
STOPPED = 'stopped',
COMPLETED = 'completed',
}🏗️ Architecture
Android Architecture
JavaScript Layer (React Native)
↓
NotificationsModule.kt (Expo Bridge)
↓
WorkoutTimerService (Foreground Service)
↓
NotificationManager (System Notifications)
↓
SharedPreferences (State Persistence)Key Components:
- WorkoutTimerService.kt: Foreground service managing timer
- NotificationsModule.kt: Expo module bridge
- BroadcastReceiver: Service-to-JS event communication
iOS Architecture
JavaScript Layer (React Native)
↓
NotificationsModule.swift (Expo Bridge)
↓
ActivityKit Framework (Live Activities)
↓
WorkoutWidget (Widget Extension)
↓
UserDefaults (State Persistence)Key Components:
- NotificationsModule.swift: Expo module with ActivityKit
- WorkoutActivityAttributes.swift: Shared data model
- WorkoutLiveActivity.swift: SwiftUI widget implementation
⚡ Performance & Accuracy
Timer Accuracy
Both platforms use timestamp-based calculations to avoid drift:
Android:
val elapsed = SystemClock.elapsedRealtime() - startTime - totalPausedTimeiOS:
let elapsed = Date().timeIntervalSince(startedAt) - totalPausedTimeResult: ±1 second accuracy over extended periods (tested up to 1 hour).
Battery Impact
- Android: Minimal - foreground service with 1-second update interval
- iOS: Negligible - Live Activities use system UI, no app execution
🧪 Testing
Manual Testing Checklist
Basic Flow:
- [ ] Start timer → verify notification/Live Activity appears
- [ ] Verify timer updates every second
- [ ] Pause → verify "Paused" indicator
- [ ] Resume → verify timer continues from correct time
- [ ] Stop → verify notification/Live Activity disappears
Background & State:
- [ ] Background app → verify timer continues
- [ ] Force quit → reopen → verify timer restored
- [ ] Restart device → verify timer restored (Android)
- [ ] Lock screen → verify Live Activity visible (iOS)
Edge Cases:
- [ ] Time zone change during workout
- [ ] Phone call interruption
- [ ] Low memory conditions
- [ ] Multiple app switches
Automated Testing
# Run example app tests
cd example
npm test🐛 Troubleshooting
Android Issues
Notification not appearing:
- Check POST_NOTIFICATIONS permission (Android 13+)
- Verify notification channel created
- Check app is not in battery optimization
Service killed by system:
- Verify
foregroundServiceType="health"in manifest - Check Doze mode exemption
- Test on different OEM devices (Xiaomi, Huawei can be aggressive)
Timer drift:
- Ensure using
SystemClock.elapsedRealtime()notSystem.currentTimeMillis() - Verify calculation uses timestamps, not interval accumulation
iOS Issues
Live Activity not showing:
- Check iOS version is 16.2+
- Verify
NSSupportsLiveActivitiesin Info.plist - Ensure App Groups configured correctly
- Check widget extension builds successfully
State not persisting:
- Verify UserDefaults using shared App Groups
- Check both targets have same group identifier
- Test on device, not just simulator
Timer stops in background:
- Expected behavior - iOS doesn't allow background timers
- State restoration handles time calculation on foreground
📝 Examples
See example/ directory for complete working app demonstrating:
- Permission handling
- Starting/pausing/resuming timer
- Updating exercise and set/rep counts
- Rest timer implementation
- Deep linking from notifications
- State restoration after app kill
🤝 Contributing
Contributions welcome! Please follow:
- Fork the repository
- Create feature branch (
git checkout -b feature/amazing-feature) - Commit changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - Open Pull Request
📄 License
MIT
🙏 Credits
Built with:
- Expo Modules API
- ActivityKit (iOS)
- Foreground Services (Android)
Inspired by the comprehensive research in NOTIFICATION_SPEC.md.
📞 Support
- Issues: GitHub Issues
- Documentation: See
/ios/WIDGET_EXTENSION_SETUP.mdfor iOS setup - Spec: See
NOTIFICATION_SPEC.mdfor implementation details
