> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Mobile architecture

> Understanding the Soundcheck mobile app architecture

This document covers the architecture patterns and structure of the Soundcheck mobile app.

> [!NOTE]
>
> Stack: **Expo SDK 57**, React Native **0.86.3**, React **19.2**, Expo Router **\~57**. Auth uses `@clerk/expo` **^4.8**; icons are `lucide-react-native` glyphs imported through the wrappers in `lib/icons/` (`@/lib/icons/<Name>`); analytics/flags use `posthog-react-native`. UI style follows the OS (`userInterfaceStyle: automatic`); `ThemeContext` resolves a persisted preference — **System** (the default) tracks the device color scheme, while an explicit **Light**/**Dark** selection under You → Settings → Appearance overrides it.

## Navigation

### Expo Router

The app uses **Expo Router** for file-based navigation. Routes are defined by the file structure in the `app/` directory. There are **four tabs** — `events`, `mailbox`, `browse`, `you` (the tab bar labels them **Gigs / Inbox / Organization / You**; the Gigs label follows the org's operator profile).

```
app/
├── (tabs)/                   # Tab navigation group
│   ├── _layout.tsx          # Tab bar config (custom AnimatedTabBar, initialRoute "events")
│   ├── events/              # "Gigs" tab — index, create, sample, [id]/, templates/
│   ├── mailbox/             # "Inbox" tab — invitations, crew offers, availability checks
│   ├── browse/              # "Organization" tab — hub of secondary surfaces (see below)
│   └── you/                 # "You" tab — profile, settings (incl. availability hours), org-settings
├── _layout.tsx              # Root layout (providers)
├── index.tsx                # Entry point / redirect
├── login.tsx  signup.tsx    # Auth + founder / org-invite wizards
├── forgot-password.tsx  set-password.tsx
├── oauth-callback.tsx  sso-needs-password.tsx  sso-pending-task.tsx
├── signup-welcome.tsx  open-link.tsx  e2e-login.tsx
├── gig-sms-invite/  org-sms-invite/
├── chat/  profile/  incidents/
├── (dev)/                   # Dev-only screens (component gallery)
├── +native-intent.ts        # Rewrites incoming links before routing
└── +not-found.tsx
```

The **Organization** tab (route `browse`) is a menu hub (`components/navigation/MenuHub.tsx`) built from `lib/navigation/menuRegistry.ts`; its entries are feature-flag and role gated (see [Feature flags](#feature-flags)). The live routes under it are `org-engagements`, `team-availability` and `task-templates`. The older CRM, repertoire and inventory routes (`customers`, `leads`, `sponsors`, `venues`, `hotels`, `setlists`, `songs`, `inventory`, `teams`, `tasks`, `dashboard`, `files`) still resolve but render `RetiredHubRoute`, which says the surface is not available on mobile (`lib/navigation/retiredHubRoutes.ts`).

The tab layout (`app/(tabs)/_layout.tsx`, rendering `components/AnimatedTabBar.tsx`) is wrapped in `<RequireAppAccess>` and hides individual tabs via `useHasVisibleMenuEntries(...)` plus the `MOBILE_FEATURE_FLAGS.MAILBOX_TOP_RIGHT` flag. **Gigs** (`events`) always stays; **Inbox** is removed from the tab bar when that flag is on; **Organization** and **You** stay while they have visible entries.

### Navigation patterns

| Pattern | Usage |
| - | - |
| **Tab Navigation** | Main app sections (Gigs, Inbox, Organization, You) |
| **Stack Navigation** | Detail screens, modals, auth flow |
| **Deep Linking** | OAuth callbacks, push notifications |

***

## State management

### Context providers

Server state uses **TanStack React Query v5** (see [Data fetching](#data-fetching)); cross-cutting app state uses React Context. The provider stack spans `app/_layout.tsx` and `lib/providers/AppProviders.tsx`:

```tsx theme={null}
// app/_layout.tsx (RootLayout, wrapped in Sentry.wrap when a DSN is set)
<ThemeProvider>
  <ErrorBoundary>
    <KeyboardProvider>          {/* react-native-keyboard-controller */}
      <ClerkProvider>
        <AppProviders>          {/* lib/providers/AppProviders.tsx */}
          <NavigationThemeProvider>
            <Stack />           {/* expo-router */}
          </NavigationThemeProvider>
          {/* PortalHost, ToastHost, ConfirmDialogHost and other hosts */}
        </AppProviders>
      </ClerkProvider>
    </KeyboardProvider>
  </ErrorBoundary>
</ThemeProvider>

// AppProviders (inner stack)
<QueryProvider>                 {/* TanStack Query — lib/query/QueryProvider.tsx */}
  <NetworkProvider>
    <AuthProvider>
      {/* sync and telemetry bridges (siblings) */}
      <PostHogProvider>
        <AppUpdateGate>
          <LiveScheduleProvider>
            <FeedbackProvider>
              <ChatProvider>
                {children}
```

### Available contexts

Context providers live in `lib/context/`. The app-wide ones:

| Context | Location | Purpose |
| - | - | - |
| `AuthContext` | `lib/context/AuthContext.tsx` | Authentication state, Clerk integration |
| `ChatContext` | `lib/context/ChatContext.tsx` | Chat functionality, messages |
| `NetworkContext` | `lib/context/NetworkContext.tsx` | Network connectivity state |
| `ThemeContext` | `lib/context/ThemeContext.tsx` | Theme management, dark mode |
| `PostHogContext` | `lib/context/PostHogContext.tsx` | Analytics, feature flags, tracking |

The rest are scoped to one surface: the gig detail screen (`EventDetailsContext`, `EventDetailInviteResponseContext`, `EventDetailPanelActiveContext`, `EventDetailViewportContext`, `GigDetailAnalyticsContext`) and the floating tab bar (`FloatingTabBarCreateContext`, `FloatingTabBarOverlayContext`). `lib/context/auth/` holds the pieces `AuthContext` is built from.

The React Query client is not a `lib/context` provider — it lives in `lib/query/QueryProvider.tsx` (wraps `QueryClientProvider` with the shared `queryClient` and an AppState `focusManager` bridge).

### Using contexts

```tsx theme={null}
import { useAuth } from '@/lib/context/AuthContext';

function MyComponent() {
  const { user, isSignedIn, signOut } = useAuth();

  if (!isSignedIn) {
    return <LoginPrompt />;
  }

  return <UserProfile user={user} />;
}
```

***

## Styling & theme

Native appearance support uses `userInterfaceStyle: 'automatic'` and `expo-system-ui`; applying these native changes requires rebuilding and reinstalling the app, not just a JavaScript reload or OTA update.

### NativeWind v4

Styling is primarily **NativeWind v4** — Tailwind utility classes via `className`, configured in `tailwind.config.js` with a `global.css` (imported in `_layout.tsx`) that defines semantic HSL tokens **mirroring the web app's `globals.css`** (`--primary`, `--secondary`, `--destructive`, `--success`, `--warning`, `--info`, `--finance`, plus `.dark` overrides). Newer surfaces use a shadcn-for-RN layer in `components/ui/` built on `@rn-primitives/*` + `class-variance-authority`.

```tsx theme={null}
import { View } from 'react-native';
import { Text } from '@/components/ui/text';

<View className="bg-background p-4">
  <Text className="text-foreground font-semibold">Hello</Text>
</View>
```

> [!NOTE]
>
> The brand primary is now an indigo (`--primary: 239 84% 67%`), not the legacy purple. `#9b87f5` survives only as the app-icon/splash background (`app.config.js`) and the switch-active accent.

### Tokens outside classes

Colors have one source: the CSS variables in `global.css`. Where a class cannot apply (icon `color` props, third-party props, animated styles), read the hex mirror with `useCssTokens()` from `lib/theme/useSemanticColors.ts` (values in `constants/theme/tokens/cssTokens.ts`). `constants/theme/tokens/` also holds the non-color tokens: `layout.ts`, `controls.ts`, `typography.ts`, `shadows.ts` and `motion.ts`. The older `useColors()` palette (`constants/theme/hooks/useColors.ts`) stays only in files that already use it.

***

## Component architecture

The app has a **single UI layer**: NativeWind v4 + `@rn-primitives` in `components/ui`.

```
components/
├── ui/                     # Shared primitives (@rn-primitives + NativeWind, cva variants)
├── gig/  mailbox/  chat/  you/  ...   # One folder per feature
├── AnimatedTabBar.tsx      # Custom tab bar
└── ErrorBoundary.tsx
```

* A component used by one feature goes in `components/<feature>/`. Only a primitive shared across features goes in `components/ui/`, with a line in `docs/design-system/component-catalog.md`. `components/AGENTS.md` has the rules and the "use X, not Y" list.
* Icons are imported from `@/lib/icons/<Name>`. New sheets use `Modal variant="bottomSheet"` from `components/ui/modal.tsx`.
* Gluestack is removed. `npm run check:gluestack` is a permanent ban (empty allowlist).

***

## Data fetching

### TanStack React Query + service layer

Server state is managed with **React Query v5**. Service modules in `lib/api/` (`gigService`, `guestListService`, …) call the generated OpenAPI client through `generatedApiCore.execute` (`lib/api/generatedApiCore.ts`), which attaches the Clerk token. Query hooks wrap those services and take their keys from the `queryKeys` factory in `lib/query/queryClient.ts`.

```tsx theme={null}
import { useQuery } from '@tanstack/react-query';
import { gigService } from '@/lib/api/GigService';
import { unwrapApiResponse } from '@/lib/api/unwrapApiResponse';
import { queryKeys } from '@/lib/query/queryClient';

const { data: gig } = useQuery({
  queryKey: queryKeys.events.detail(gigId),
  queryFn: async () => unwrapApiResponse(await gigService.getEvent(gigId)),
});
```

Services never throw: a failed request resolves `{ success: false, message, status }`, so the `queryFn` must check `success` and throw, which is what `unwrapApiResponse` does ([pitfall 26](https://docs.soundchecklive.io/engineering/reference/pitfalls.md)). New data hooks go in `lib/<domain>/hooks/`; `lib/AGENTS.md` has the placement rules.

> [!NOTE]
>
> Use React Query for data fetching. Don't hand-roll fetch/cache hooks.

### Token management

Clerk JWT tokens are automatically attached to requests:

```
┌─────────────────┐
│   User Login    │
│  (via Clerk)    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ Clerk Session   │
│   Established   │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  AuthContext    │
│  sets up token  │
│  getter         │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  API Request    │
│  (with Bearer)  │
└─────────────────┘
```

***

## Custom hooks

Hooks sit beside the domain they serve: `lib/<domain>/hooks/` for domain and data hooks (for example `lib/gig/hooks/`), `lib/hooks/` for device and UI hooks (`useWindowClass`, `useDynamicTypeScale`, `useFeatureFlag`). `lib/AGENTS.md` lists the helpers to reach for first.

***

## Performance optimizations

### Data caching

React Query handles caching and background revalidation:

* **Stale-While-Revalidate**: cached data shows instantly, fresh data fetches in the background
* **Focus refetch**: a `focusManager` AppState bridge (in `QueryProvider`) revalidates on app foreground
* **Pull-to-Refresh**: invalidates/refetches the relevant queries

### Component optimization

```tsx theme={null}
import { memo } from 'react';
import { View } from 'react-native';
import { Text } from '@/components/ui/text';

const EventCard = memo(function EventCard({ title }: EventCardProps) {
  return (
    <View className="bg-card p-4">
      <Text>{title}</Text>
    </View>
  );
});
```

***

## Error handling

### Error boundary

`components/ErrorBoundary.tsx` wraps the app in `app/_layout.tsx`, shows a fallback screen and reports the error through `lib/logger.ts`.

### API error handling

A service call resolves with `success: false` instead of throwing, so check the result rather than wrapping the call in `try/catch`:

```tsx theme={null}
const response = await gigService.getEvent(gigId);
if (!response.success) {
  showUserFacingError(response, { context: 'gig.load' });
  return;
}
```

`showUserFacingError` (`lib/errors/userFacingError.ts`) maps the failure to friendly copy and shows it as an error toast; never show `response.message` raw (`npm run check:raw-error-text`).

***

## Best practices

### Component guidelines

1. **Keep components small** - Split large components into smaller ones
2. **Use TypeScript** - All components should be fully typed
3. **Prefer composition** - Build complex UIs from simple components
4. **Memoize when needed** - Use `memo()` for expensive renders

### State management guidelines

1. **Colocate state** - Keep state as close to where it's used as possible
2. **Context for global state** - Use context for truly global state
3. **Avoid prop drilling** - Use context when passing props through many levels

### Performance guidelines

1. **Use FlatList** - For long lists instead of ScrollView
2. **Optimize images** - Use appropriate sizes and formats
3. **Minimize re-renders** - Use `useMemo` and `useCallback` appropriately

***

## Feature flags

Nearly every mobile surface is **PostHog feature-flag gated**. Two things drive visibility:

* `lib/constants/featureFlags.ts` — re-exports the flag catalog generated from [`flags/registry.json`](https://github.com/soundchecklive/soundcheck-mobile-app/blob/dev/flags/README.md) into `lib/generated/featureFlags.ts`: `SHARED_FEATURE_FLAGS` (keys shared with web, such as `crew-availability-offers`, `departments`, `event-chats`, `open-shifts`) and `MOBILE_FEATURE_FLAGS` (mobile-only keys such as `ORG_ENGAGEMENTS`, `TEAM_AVAILABILITY`, `MAILBOX_TOP_RIGHT`). Add or retire flags in the registry and run `npm run generate:flags`; never edit the generated file.
* `lib/navigation/menuRegistry.ts` — each menu entry declares an optional `flag`, `tab`, `group`, and `requiresRole`. `lib/navigation/menuVisibility.ts` (`useVisibleMenuEntries`) resolves visibility in order: role gate → no-flag entries always visible → local `EXPO_PUBLIC_FF_*` override → PostHog evaluation.

Flags are effectively **org-targeted**. On identify, `PostHogContext` calls `posthog.group('organization', orgId)` and seeds person properties (`organization_ids`, `active_organization_id/role`, `is_organization_owner/admin`, `environment`) so flags can target by membership and active org, then reloads flags.

> [!NOTE]
>
> Because targeting keys off the org, a flag scoped to an `organization_id` only lights up on mobile once that org is the active org. See the rollout matrix in [Mobile feature flag rollout](https://docs.soundchecklive.io/engineering/mobile/feature-flag-rollout.md).

***

## Native projects

The `ios/` and `android/` directories are **gitignored** and generated by `expo prebuild` from `app.config.js` — never commit them. See [Environment Setup](https://docs.soundchecklive.io/engineering/development/environment-setup.md) for prebuild commands.

***

## Related

* [Mobile feature flag rollout](https://docs.soundchecklive.io/engineering/mobile/feature-flag-rollout.md) - Which flags to enable per org type
* [Mobile Setup](https://docs.soundchecklive.io/engineering/mobile/setup.md) - Set up your development environment
* [Authentication](https://docs.soundchecklive.io/engineering/mobile/authentication.md) - Learn about auth flow and tokens
* [Mobile Overview](https://docs.soundchecklive.io/engineering/mobile/overview.md) - General overview of the mobile app


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.