Case Study
A full-featured mobile fitness app built with React Native, Firebase, and RevenueCat — offline-first, AI-powered, and subscription-ready.
No custom server. Firebase + managed services handle everything from auth to sync to billing.
React Native with Expo SDK 54, running React 19 on the new architecture. Hand-built UI with no component library — every screen uses RN primitives, react-native-svg, and a custom theme system.
Firebase Auth for identity, Firebase Realtime Database for cloud sync, and a thin VPS proxy at api.harmjoy.us for AI routine generation (GPT-4o-mini) and push token management.
RevenueCat for subscription billing across platforms. Health Connect for step, weight, and workout sync. Google Drive for encrypted cloud backups. Expo Notifications for push.
A fat client with managed services. The app owns all state; Firebase provides sync and auth; RevenueCat handles billing.
What happens when a user completes a workout — a cascade of 7 side effects from a single action.
Three sign-in paths that converge into one Firebase UID. Anonymous accounts upgrade seamlessly without data loss.
Guest — signInAnonymously(), full app access, upgradeable
Email — Firebase email/password auth
Google — PKCE S256 flow via expo-auth-session
RevenueCat logIn(uid) → pullFromCloud(uid) → registerPushToken → setUserPublicProfile → registerEmailLookup (SHA-256 hash for friend discovery)
Freemium with RevenueCat. A 7-day trial with write-once RTDB timestamps prevents trial resets.
3 custom routines
7-day workout history
All 906 exercises
Health Connect sync
Unlimited routines
Full history
AI routine generation
Programs & progress photos
Google Drive backups
Trial start is a write-once RTDB timestamp. Firebase rule: !data.exists(). Can't be reset by reinstalling.
Firebase RTDB rules act as the authorization layer. No custom server needed for access control.
| Path | Rule | Why |
|---|---|---|
| appData | Owner-only read/write | User's workout data is private |
| publicProfile | Auth read, owner write, field validation | Regex on fitnessLevel, length limits on bio |
| notifications | fromUid === auth.uid | Anti-spoofing: can't send notifications as someone else |
| trialStart | Write-once (!data.exists()) | Prevents trial timestamp manipulation |
| sharedGoals | Friends-gated via RTDB rule ref | Only friends can read shared goals |
| emailLookup | No anonymous, value === auth.uid | SHA-256 hashed emails for friend discovery |
Everything from AI coaching to encrypted backups, built with no external UI libraries.
AI avatar trained via Flux.1 Dev + LoRA on Replicate. 12 curated images, 80+ messages. Deterministic daily rotation seeded by dayHash(YYYYMMDD) — same content all day, rotates at midnight. 4 touchpoints: home card, AI modal, rest timer tips, workout summary.
Premium users can generate personalized routines. Sends equipment, goals, fitness level, muscle focus, and recent activity to GPT-4o-mini via the VPS proxy. Returns a structured routine ready to start.
Google Drive backups using the hidden appDataFolder. AES-256 encryption with a passphrase derived from the Firebase UID. Up to 5 versions kept. Legacy unencrypted backups handled transparently on restore.
Android-only integration for steps (polled every 5min), weight (bidirectional sync), and workout sessions. Writes ExerciseSession records post-completion with duration and calorie data.
Three Android channels (workout reminders, streak alerts, goal deadlines). Weekly reminders scheduled per-day from the planner. Streak-at-risk fires at 7 PM if no workout logged today.
Android widget built with react-native-android-widget. Shows today's routine, streak, weekly count, and top goal progress. Updates every 30 minutes. Tap to open the app.
4 base themes, 8 accent colors, 4 shape presets, 4 font sizes, and a compact mode. All resolved at runtime and applied via useMemo + StyleSheet.create.
Shape presets: sharp (0x), minimal (0.35x), rounded (1x), pill (1.6x) — applied via r() radius scaler. Font sizes: small (0.88x) through xlarge (1.24x) via fs() scaler.
resolveTheme(themeId, accentId) merges a base theme with an accent color at runtime. ThemeContext provides colors, r(), fs(), and compactMode to every component. No styled-components, no Tamagui.