Mobile App
See also: Local Development · Testing · CI CD and Deploys · Auth and Users · Story Engine
apps/stretched-mobile — an Ionic + Capacitor shell (appId: app.stretched.mobile) around the same component library (@stretched/stretched-components) and the same Firebase project as the web app. Dev server runs on port 4300. Human-facing docs: apps/stretched-mobile/README.md; gotcha list: apps/stretched-mobile/CLAUDE.md.
App flow
Routes in apps/stretched-mobile/src/app/app.routes.ts:
/login— the lib'ssc-user-login; Google sign-in goes through theGOOGLE_SIGN_IN_FNtoken, provided natively bycore/auth/native-google-sign-in.ts(@capacitor-firebase/authentication→signInWithCredentialin the shell,signInWithPopupin a plain browser). Never callsignInWithPopupdirectly in mobile code — Google blocks OAuth in embedded WebViews. See Auth and Users.authGuardwraps everything else;incomeStoryGuardsends users without a completed income story (users/{uid}.stories.valueOfYourTime[year].isComplete) to/onboarding;encryptionGuardroutes to/unlockonly when the user doc hasencryptionmeta.- On native platforms the Firebase JS SDK auth is created with
initializeAuth+indexedDBLocalPersistence(app.config.ts), nevergetAuth()—getAuth()'s popup/redirect resolver hangs on thecapacitor://scheme,authStatenever emits, andauthGuardblanks the whole app. iOS also requiresios/App/App/GoogleService-Info.plistto exist or the Firebase plugins crash the app at launch (a dev-only stand-in is committed; the real one is part of the one-time console setup). - Route shape rule:
/onboarding,/unlock,/share,/settingsare full-screen (no tab bar);/home,/dashboard, and/stories[/:name]live inside theTabsPageshell — keep new flows on the right side of that split. - Home derives the translator rate from saved income values (
hourlyRateFrom()); encrypted entries decrypt only whenEncryptionServiceis unlocked. - Stories read from
STORY_CATALOG_SOURCE(bundledVALUE_OF_TIME_STORYtoday; same provider seam as the web/storiesroute — swap for a CDN fetch when publishing exists). Shared Firebase Remote Config story flags filter that source on navigation; if the required income story is disabled,incomeStoryGuardbypasses onboarding so the app cannot deadlock. Emulated builds remain local and never call Remote Config. See Story Engine and Feature Flags.
Where logic lives (the facade rule)
Anything both apps need lives in the shared library; mobile's core/services/user-stories.service.ts (UserStoriesService) is only a guest-aware facade over the lib's canonical services (UserStoryEntriesService for story entries, UserService for currency/language preferences). Guest mode (GuestSessionService) is in-memory only and Firebase-free — the reload-wipe is a feature.
Native niceties
Share-to-translate intents (ShareIntentService → parse-price.ts → /home?price=…), deep links / app shortcuts (app.stretched.mobile://translate via DeepLinkService), on-device price OCR (PriceScanService, native-only — guard with isSupported), canvas share cards (core/share/share-card.ts, token-driven), biometric unlock (BiometricUnlockService, password in Keychain/Keystore), app lock overlay (privacy gate only), weekly check-in via local notifications (no FCM — cost rule), Crashlytics guarded by the google-services.json existence check.
Scripts
Web-parity + native, from the repo root (see Tooling and Scripts):
| Script | Purpose |
|---|---|
bun run mobile:start:{emulated,dev,prod} | Dev server on 4300 → local emulator / deployed dev / prod Firebase |
bun run mobile:build:{emulated,dev,prod} | Web bundle per environment |
bun run mobile:setup | One-time toolchain: JDK 21 + Android SDK, user-local, Win/mac (tools/mobile-setup.ts) |
bun run mobile:apk:{dev,prod} | Local debug-signed APK (tools/mobile-apk.ts; --install adb-sideloads) |
bun run e2e:mobile:emulated | Mobile Cypress suite vs a FRESH emulator (see Testing) |
Native shells & Firebase per-platform setup
android/andios/are Capacitor-generated but committed. Never hand-editios/App/CapApp-SPM/Package.swift(CLI-managed, regenerated bycap sync ios).- Universal + adaptive (decided 2026-07-22): iOS targets iPhone + iPad (
TARGETED_DEVICE_FAMILY = "1,2"), no orientation lock on either platform, and layouts adapt by M3 window size class —--page-max-width(src/styles.scss) widens 36→44→54rem atgt-compact/gt-medium, list screens reflow via the gates (dashboard), and focused screens (home translator, login, unlock) deliberately keep a centered 36rem column. See the app README § "Device targets & window size classes" and Design System and Foundations § Mobile first. - Per-platform Firebase app IDs are a human console step (README § one-time setup): register the Android/iOS apps in the Firebase console (
stretched-devfirst,stretched-2c16abefore release), then placegoogle-services.jsonatapps/stretched-mobile/android/app/andGoogleService-Info.plistin the Xcode project. On-device Google sign-in silently depends on this. - CI native builds are the manual-only "Mobile Build" workflow — see CI CD and Deploys.
Gotchas (the ones that bite)
- Gradle needs JDK 21 — system Java (26) is too new for Gradle 8.14 and is used by the Firebase emulator;
mobile:setupinstalls the right one user-locally (tools/mobile-machine-paths.tshas per-OS locations). - Gradle daemon pipe hang — after a successful build the warm daemon keeps stdout open, so piping gradle output (
… | tail) hangs forever after success. Redirect to a file or check for the APK on disk. gradlew's executable bit isn't recorded (created on Windows) — on macOS/CI invoke viash gradlew ….android/build.gradleforces every library module onto the app'scompileSdkVersion— community plugins hardcode older SDKs; keep that block when regenerating.- Workspace tsconfig lib < es2021 — no
String.replaceAll/Array.atin app source (vitest passes, the Angular build fails). - CSS rules from the web app apply verbatim (grid not flex, tokens for everything); Ionic's
--ion-*vars are bridged to the design tokens insrc/styles.scss— see Design System and Foundations.
Testing
Unit tests via vitest (vite.config.mts; @ionic/* + ionicons must stay in server.deps.inline). E2E: apps/stretched-mobile-e2e, 390×844 viewport; emulator-only specs gate on CYPRESS_EMULATED=1, and the guest journey in app.cy.ts runs everywhere because it's server-free. Visual diffing (mobile:visual) shoots every route at 390×844 (Compact) plus the --expanded manifest rows at 1024×768 (Expanded) — the ≥840px adaptations are diffed. Medium (600–839) and Cypress remain Compact-only; eyeball an iPad simulator before store submission. See Testing and Local Development.