Appearance
๐ฑ Touli Mobile โ Project Structure โ
Expo (React Native) app ยท lives at
apps/mobilein the Touli Nx monorepo ยท shareslibs/typeswithapps/serverSee also: project-structure.md โ the client/server/libs side of the monorepo.
Contents โ
- Overview
- Architecture flow
- Full folder tree
app/โ routing & protected routesfeatures/lib/- Path aliases
- Tech stack
Overview โ
| Area | Path | What it is |
|---|---|---|
| ๐ Routing | apps/mobile/src/app/ | Expo Router file-based routes โ groups, layouts, screens |
| ๐งฉ Features | apps/mobile/src/features/ | Screen components + feature-local UI, grouped by domain |
| ๐งฐ Shared lib | apps/mobile/src/lib/ | Everything cross-feature: API layer, design system, i18n, hooks, constants |
| ๐ Shared w/ backend | libs/types | @workspace/types/* โ interfaces, enums, DTOs, API path constants used by both mobile and server |
The app is not a single monolithic utils.ts codebase โ lib/ is deliberately split by what kind of thing each file is (a network call vs. a React Query hook vs. a constant vs. a generic helper), not just dumped into one folder. See lib/ for the exact rationale of each subfolder.
Architecture flow โ
mermaid
flowchart LR
U["๐ฑ User"] --> R["Expo Router\n(app/)"]
R --> F["Feature screens\n(features/*)"]
F --> Q["lib/queries\nReact Query hooks"]
Q --> S["lib/services\naxios calls"]
S --> API["apps/server\nExpress REST API"]
F --> C["lib/components/ui\nDesign system"]
F --> I["lib/i18n\nen / ar"]
API --> DB[("PostgreSQL")]
style U fill:#fce7f3,stroke:#db2777
style R fill:#eff6ff,stroke:#2563eb
style F fill:#eff6ff,stroke:#2563eb
style Q fill:#f5f3ff,stroke:#7c3aed
style S fill:#f5f3ff,stroke:#7c3aed
style C fill:#f5f3ff,stroke:#7c3aed
style I fill:#f5f3ff,stroke:#7c3aed
style API fill:#f0fdf4,stroke:#16a34a
style DB fill:#fef3c7,stroke:#d97706Screens never call axios directly and never talk to the backend without going through lib/queries โ lib/services. See lib/ for why those are two separate folders.
Full folder tree โ
text
apps/mobile/
โโโ app.json / app.config.ts # Expo app config
โโโ eas.json # EAS Build profiles
โโโ metro.config.js # Metro bundler config
โโโ env.ts # Typed process.env wrapper
โโโ src/
โโโ app/ # Expo Router โ file-based routes (see below)
โโโ features/ # Screens + feature-local components, one folder per domain
โ โโโ auth/ # Signup (Google/Apple via thirdweb), Welcome Back, auth store, route guards
โ โโโ onboarding/ # Avatar โ name โ age โ consents โ permissions โ interests โ first challenge
โ โโโ home/ # Home tab: points card, active challenge, badges, leaderboard
โ โโโ challenges/ # Categories, challenge lists, active-challenge detail + task flow
โ โโโ gifts/ # Gift center: pending gifts, claim flow, activity
โ โโโ deeds/ # Deed submission (QR / photo+GPS+peer / AI review)
โ โโโ settings/ # Theme, language, account settings
โ โโโ notifications/ # Notifications list
โ โโโ shop/ # Shop tab (placeholder)
โ โโโ profile/ # Profile tab
โ โโโ feed/ # โ ๏ธ scaffold demo (JSONPlaceholder-style), not wired into any nav
โโโ lib/ # Cross-feature code, split by kind (see "lib/" section)
โโโ services/ # Raw backend API calls (axios) - one *.service.ts per domain
โโโ queries/ # React Query hooks (+ provider) - one *.queries.ts per domain
โโโ utils/ # Generic helpers with no backend/query dependency
โโโ constants/ # Static constant values (routes, colors, assets, theme tokens)
โโโ config/ # Static third-party config objects (navigation theme)
โโโ hooks/ # Stateful, MMKV-persisted hooks (onboarding progress, consents, ...)
โโโ i18n/ # i18next setup, split per-section locale files (en/ar)
โโโ components/ # ui/ design system, icons/, layouts/, common/app/ โ routing & protected routes โ
Expo Router maps files under src/app/ to routes. Group folders like (app), (auth), (onboarding) don't appear in the URL โ they exist purely to scope a layout and a set of guards.
text
app/
โโโ _layout.tsx # Root layout: providers + <Stack.Protected> guards (see below)
โโโ index.tsx # Splash screen โ decides where a cold start lands
โโโ (auth)/ # Reachable only when signed out
โ โโโ _layout.tsx
โ โโโ login.tsx # "Welcome Back" (returning, already-tokened users)
โ โโโ signup.tsx # Google/Apple OAuth via thirdweb
โโโ (onboarding)/ # Reachable for first-time installs, or signed-in users mid-onboarding
โ โโโ _layout.tsx
โ โโโ onboarding.tsx # Intro screen
โ โโโ avatar.tsx ยท name.tsx ยท age-consent.tsx ยท privacy-consent.tsx
โ โโโ parental-consent.tsx ยท permissions.tsx
โ โโโ challenge-interests.tsx ยท gift-interests.tsx ยท first-challenge.tsx
โโโ (app)/ # Reachable only when signed in AND fully onboarded
โ โโโ _layout.tsx # Tab bar: Home ยท Challenges ยท Gifts ยท Shop ยท Profile
โ โโโ index.tsx # Home tab
โ โโโ notifications.tsx ยท shop.tsx ยท test.tsx
โ โโโ challenges/ # Stack: list โ [category] โ active-challenge
โ โโโ gifts/ # Stack: list โ deed submission flow โ claim-gift
โ โโโ profile/ # Stack: profile โ settings
โโโ feed/ # โ ๏ธ scaffold demo routes, not linked from any real navigationProtected Routes (Expo Router <Stack.Protected>) โ
Route-group access used to be enforced by hand-rolled <Redirect> checks duplicated across (app)/_layout.tsx, (auth)/_layout.tsx, and (onboarding)/_layout.tsx โ three places computing the same thing off the same state, with a real risk of two of them disagreeing and bouncing the user back and forth forever.
That's now centralized in features/auth/use-protected-routes.ts, consumed once by the root layout:
tsx
// app/_layout.tsx
const { canAccessApp, canAccessAuth, canAccessOnboarding } = useProtectedRoutes();
<Stack>
<Stack.Screen name="index" />
<Stack.Protected guard={canAccessApp}>
<Stack.Screen name="(app)" />
</Stack.Protected>
<Stack.Protected guard={canAccessAuth}>
<Stack.Screen name="(auth)" />
</Stack.Protected>
<Stack.Protected guard={canAccessOnboarding}>
<Stack.Screen name="(onboarding)" />
</Stack.Protected>
</Stack>;| Guard | True when |
|---|---|
canAccessApp | signed in and fully onboarded |
canAccessAuth | signed out |
canAccessOnboarding | first-time install, or signed in but onboarding isn't finished |
All three stay false until the stored session has finished hydrating, so a cold start can't decide access based on the default idle status. If a guard flips to false while its group is active, Expo Router redirects to the anchor route (index) โ which is exactly the splash screen that decides where to actually send the user next.
The three group layouts no longer contain any auth logic at all โ they just render their Tabs/Stack.
features/ โ
One folder per product domain. Inside each:
text
features/<domain>/
โโโ <domain>-screen.tsx # Top-level screen(s), rendered by an app/ route file
โโโ components/ # Feature-local components (not shared elsewhere)
โโโ mappers.ts # API response โ UI-shape adapters (challenges only, currently)
โโโ use-<domain>-store.tsx # Zustand store, if the feature needs client-only state (auth only)Screens fetch data via lib/queries hooks (useMyChallenges, useChallengeDetail, useMyWallet, โฆ) โ never by calling lib/services functions directly from a component.
features/feed/ is scaffold/demo code left over from the project template (JSONPlaceholder-style posts, react-query-kit instead of the app's own query pattern) โ it isn't linked from any tab or button. login-form.tsx, login-form.test.tsx, style-demo/, and the duplicate app/login.tsx route were the same kind of leftover and have already been removed this cycle.
lib/ โ
The most important structural decision in this app: lib/ is split by what kind of thing a file is, not dumped flat. This mirrors (and was directly inspired by) the client/server split documented for apps/client/apps/server, adapted for a mobile app with no SSR and its own local-storage/auth concerns.
| Folder | Contains | Rule of thumb |
|---|---|---|
services/ | client.ts (axios instance + refresh interceptor), thirdweb.ts, one *.service.ts per domain | Raw network calls only โ no React, no hooks |
queries/ | provider.tsx (React Query provider), one *.queries.ts per domain | React Query hooks wrapping services/ functions โ the only place useQuery/useMutation should be called from for server data |
constants/ | routes.constants.ts, colors.constants.ts, assets.constants.ts, theme.constants.ts | Static values with zero logic โ route paths, hex colors, image require()s, light/dark tokens |
config/ | navigation-theme.ts, use-theme-config.tsx | Static config objects for a third-party library (React Navigation's Theme shape) โ distinct from constants/, which is this app's own values |
utils/ | device.ts, form.ts, query.ts, api-error.ts, storage.ts, token-storage.ts, error-toast.ts, store.ts | Generic helpers with no backend dependency โ MMKV wrappers, a zustand selector factory, error-message formatting |
hooks/ | use-is-first-time.tsx, use-selected-theme.tsx, use-last-onboarding-route.ts, use-age-category.tsx, use-onboarding-data.ts, use-privacy-consents.tsx | Stateful, MMKV-persisted hooks โ genuinely reactive, re-render on change |
i18n/ | index.tsx (init), resources.ts (merges locale files), language.ts, key-path.ts, rtl.ts, locales/<section>/{en,ar}.json | i18next setup; translations split one JSON file per top-level section, per language |
components/ | ui/ (atoms โ molecules โ organisms design system), icons/, layouts/, common/ (EmptyState, ComingSoonBadge, โฆ) | Presentational only โ no data fetching |
Why services/ and queries/ are separate folders โ
A *.service.ts file is a plain async function hitting an endpoint (getChallengeDetail(id), joinChallenge(id, mode)). A *.queries.ts file wraps those in useQuery/useMutation, owning cache keys and invalidation. Keeping them apart means:
- A service function can be reused outside React Query (e.g. imperative calls in the auth store) without dragging hooks in.
- Cache-invalidation logic for a whole domain lives in one file, not scattered next to each individual fetch function.
lib/i18n/locales/ layout โ
text
locales/
โโโ common/{en,ar}.json
โโโ onboarding/{en,ar}.json
โโโ settings/{en,ar}.json
โโโ notifications/{en,ar}.json
โโโ challenges/{en,ar}.json
โโโ gifts/{en,ar}.json
โโโ deeds/{en,ar}.json
โโโ home/{en,ar}.json
โโโ auth/{en,ar}.jsonresources.ts imports every file and re-merges them into the same { common: {...}, home: {...}, ... } shape i18next always saw โ so t('home.points.label')-style calls anywhere in the app are unaffected by the split.
Path aliases โ
| Alias | Resolves to |
|---|---|
@/lib/* | apps/mobile/src/lib/* |
@/features/* | apps/mobile/src/features/* |
@workspace/types/* | libs/types/src/* โ interfaces, enums, DTOs, API path constants (shared with apps/server) |
@workspace/models | libs/models/src โ Sequelize models (server-only, not used by mobile) |
@workspace/ui/icons/* | libs/ui/src/icons/* โ shared SVG logo assets |
Within lib/, every subfolder (services/, queries/, utils/, constants/, config/, hooks/) exposes an index.ts barrel, so most cross-feature imports read @/lib/services, @/lib/queries, @/lib/constants, etc. rather than reaching into an individual file.
Tech stack โ
| Layer | Library | Version |
|---|---|---|
| Framework | Expo | ~54.0 |
| Framework | React Native | 0.81.6 |
| Framework | React | 19.1.4 |
| Routing | Expo Router | ~6.0.22 |
| Server state | TanStack React Query | ^5.90.19 |
| Client state | Zustand | 5.0.3 |
| Local storage | react-native-mmkv | ^4.3.2 |
| Secure storage | expo-secure-store | ~15.0 |
| Auth / wallet | thirdweb | ^5.120.1 |
| HTTP | axios | ^1.15.2 |
| i18n | i18next / react-i18next | ^25.8.0 / ^16.5.3 |
| Animation | react-native-reanimated | ~4.1.6 |
| Bottom sheets | @gorhom/bottom-sheet | ^5.2.8 |
| Forms | @tanstack/react-form | ^1.27.7 |
| Monorepo | Nx | ^20.7.1 |
| Language | TypeScript | ~5.9.2 |
Touli ยท apps/mobile ยท keep this file in sync when lib/ or app/ structure changes