Routing is Expo Router with typed routes. Route files stay thin. Almost every file imports one screen from src/features/*/presentation and renders it. Logic belongs in the feature.

Tree

Screens

Outside the signed-in stack

Tabs

Stack routes

meal/add.tsx and meal/result.tsx are the two route files that hold real screen code. Everything else is a wrapper.

Root layout

src/app/_layout.tsx runs once per launch.
  1. applyAppDirection() pins the native layout direction to the stored language before the first render. See RTL and language.
  2. SplashScreen.preventAutoHideAsync() holds the native splash until fonts load.
  3. useFonts loads Inter, IBM Plex Sans Hebrew and Arabic, Heebo, Assistant, Rubik, Karantina and Varela Round. The layout returns null until they are ready.
  4. An effect registers the 401 handler with setUnauthorizedHandler so a rejected session calls logout, then hydrates the theme and the auth store in that order.
  5. An AppState listener calls restoreSession() each time the app becomes active.
  6. useForegroundHealthSync() runs a step sync on launch and on every return to the foreground.
  7. import '@/features/health/lib/backgroundTask' defines the background task at module scope, which TaskManager requires.
Provider order, outside to inside: GestureHandlerRootView, SafeAreaProvider, QueryClientProvider, ToastProvider, then SplashGate around the router Slot. SplashGate keeps a JS overlay that matches the native splash until authStatus !== 'unknown' and at least 2100 ms have passed. With reduced motion on, the minimum hold is skipped.

Launch decision

src/app/index.tsx reads three values from the auth store.

Auth layout

(auth)/_layout.tsx is a headerless Stack. When status === 'authed' it redirects to /choose-studio or /home, so a signed-in trainee can never sit on the login screens.

App layout

(app)/_layout.tsx guards the signed-in area and hosts everything that must live above all screens. Gates, in order: Mounted next to the Stack: GlobalWorkoutBar, AppUpdatePrompt and PendingFormPrompt. The mount effect wires the long-lived pieces:
  • Refreshes the nutrition day now, then schedules a timer for the next rollover from nextNutritionRefreshMs.
  • Restores the workout Live Activity and subscribes syncWorkoutActivity to the workout session store.
  • Resumes the rest timer.
  • Syncs the cardio Live Activity and the Android cardio notification, and subscribes both to the cardio session store.
  • On every return to active: refreshes nutrition, resumes the rest timer, re-reads the cardio notification state, calls refreshSession(), invalidates the home query, and retries push registration without prompting.
  • On background: calls syncHomeWidgets so the home screen widgets hold fresh data.
A second effect runs when the trainee becomes authed. It calls runStartupPrompts with askForPushPermission, promptForHealthAccessOnce and promptForAppUpdate, in that order. src/lib/startupPrompts.ts runs them one after another so two system sheets never stack, and exposes useStartupPrompts with a settled flag. Two hooks run for the whole signed-in session: useWorkoutReminders() and useNotificationRouting(). See Notifications.

Tab layouts

There are three platform files for the same five tabs. All three set unstable_settings.initialRouteName to home. _layout.tsx also returns AndroidTabsLayout when Platform.OS === 'android', as a fallback for the platform file. The URL scheme is perform, set in app.json. Links used inside the app: Notification taps do not use URLs. They route by a target field in the payload. See Notifications.

Typed routes

experiments.typedRoutes generates .expo/types/router.d.ts. That file is ignored by Git and is only rewritten when the Expo dev server starts. After pulling a commit that adds a route, pnpm typecheck can fail with a “not assignable” error on a router.push call even though the route file exists. Start the dev server once to regenerate the types. Do not cast the href.