Skip to content

Mobile — Expo Client

demostatics-mobile_application is the native phone client. It is a client of the display tier, not a system of its own: every screen in it reads or writes demostatics-web over that project’s /api/v1, and it computes nothing.

The app and the web UI share one database, one user table, one role set, one ban list and one forum. A post written on the phone is the same row the web forum renders — there is no sync step, no mirror and no mobile-only store. demostatics-web is the identity authority; the phone holds a bearer token issued by it and nothing else.

It deliberately performs no analytics. Heavy statistics, forecasting and simulation belong to the desktop sibling, which runs them locally — see Desktop. The data both clients display is meant to be produced by a worker backend that does not exist in code anywhere, which is why the Data tab is a working surface over rows that only a local seeder has ever written. See Worker Tier.

demostatics-web (Laravel 11)
display tier + /api/v1
server of record, only identity authority
|
+-------------+-------------+
| |
demostatics-mobile the web app's own
Expo / React Native browser UI
(this page)
demostatics-pc_application (Rust desktop)
NOT a client of the above. Targets a different, unimplemented
contract and talks to its own in-repo mock stream server.
worker tier
produces the data both display surfaces show. Not built.
AreaStatusNote
Auth stack: register, login, forgot password, resend verificationShippedPassword reset (consuming the emailed token) has no screen
News — editorial articles, category/subcategory filterShipped
Forum — posts, threaded comments, search, sort, community filterShippedComments are not paginated in the UI
Polls — vote, move your vote, live result bars, discussionShippedCreate and delete only; no poll editing
Data — Reports, Database, seven-dimension filter, date rangeShipped as a surfaceThe rows it lists have no producer
Profile — account, staff presence, moderation board, about/diagnosticsShipped
Moderation actions (ban, unban)Not builtThe API functions exist in the client; nothing calls them
Charts, entitlements, billing, push, device linking, alerts, calculator, settings, mapPlannedRoadmap phases 3–9, no code
i18nPlannedEvery string is an inline literal today
Release config — eas.json, dev client, CINot built

Routing is expo-router: a file under src/app/ is a route. The real tree in demostatics-mobile_application/src/app/:

RouteFileWhat it does
/(auth)/login(auth)/login.tsxEmail + password, surfaces a ban reason carried over from a failed restore
/(auth)/register(auth)/register.tsxCreates an account; the result is an unverified session
/(auth)/forgot-password(auth)/forgot-password.tsxRequests the reset email
/(tabs)(tabs)/index.tsxNews — the editorial feed, and the app’s home. Owns the unverified-account notice
/(tabs)/forum(tabs)/forum.tsxForum — post list, search, community filter, sort
/(tabs)/polls(tabs)/polls.tsxPolls — poll list with vote counts
/(tabs)/data(tabs)/data.tsxData — Reports and Database behind a segmented control
/(tabs)/profile(tabs)/profile.tsxProfile — account, verification, staff rosters, navigation
/post/[id]post/[id].tsxPost detail plus the comment thread
/poll/[id]poll/[id].tsxPoll detail, options, result bars, comment thread
/article/[id]article/[id].tsxEditorial article detail
/compose/postcompose/post.tsxModal. Create and edit, keyed off an optional id param
/compose/pollcompose/poll.tsxModal. Create only — 2 to 6 options, 1 to 7 voting days
/profile/editprofile/edit.tsxName and email
/profile/passwordprofile/password.tsxCurrent password + new password
/aboutabout.tsxStanding description, About Us articles, and the health diagnostics footer
/moderationmoderation.tsxThe public moderation board (editor cards)
+not-found+not-found.tsxUnmatched route

The five tabs are declared in (tabs)/_layout.tsx in product order: what is happening (News), what people are saying (Forum, Polls), the worker-produced numbers (Data), and you (Profile).

compose/post.tsx is one screen for two jobs. It reads useLocalSearchParams<{ id?: string }>(); when the param parses to a positive integer the screen fetches the existing post, prefills once behind a ref so a background refetch cannot overwrite typing, and switches the mutation from createPost to updatePost. There is no equivalent for polls — the API has no poll update route.

Versions read from demostatics-mobile_application/package.json.

PackageVersionRole
typescript~6.0.3strict: true, path alias @/*src/*
react19.2.3
react-native0.86.2
expo~57.0.9SDK 57
expo-router~57.0.9File-based routing
@tanstack/react-query^5.101.4All server state
zustand^5.0.14Session state only
expo-secure-store~57.0.1Keychain / Keystore token storage
expo-device~57.0.1Device name for the token label
lucide-react-native^1.28.0Icons
react-native-web~0.21.0Development-only web target
jest / jest-expo~29.7.0 / ~57.0.3Test runner
eslint / eslint-config-expo^9.0.0 / ~57.0.1Flat config in eslint.config.js

app.json sets scheme: demostatics, com.demostatics.mobile as both the Android package and the iOS bundle identifier, and turns on two experiments: typedRoutes (route strings are type-checked against the real tree) and reactCompiler.

The layering is deliberate and narrow.

One file touches fetch. src/lib/api/client.ts is the only place an HTTP call is made. It attaches Accept: application/json, adds the bearer token unless the caller passed anonymous: true, drops empty query values instead of sending ?search=, returns undefined on 204, and turns every failure into an ApiError. Nothing above it ever sees a Response object or a raw status code.

One function per endpoint. src/lib/api/index.ts mirrors the contract group by group — health, auth, forum, comments, polls, reports, database, editorial, geo, moderation. Callers get resources, not envelopes: the .data unwrapping happens here. Paginated lists are the exception and return Page<T> whole, because their meta is what drives infinite scroll.

A hand-maintained type mirror. src/lib/api/types.ts is a TypeScript transcription of the JSON API. Nothing generates it. When a resource changes on the server it has to be changed here in the same pass, or the app compiles cleanly against a shape that no longer exists. This is the single most fragile seam in the repo. See JSON API v1.

One store. src/stores/session.ts is the only zustand store — status, token, user, ban reason. Everything else is react-query cache.

One infinite-scroll wrapper. src/hooks/useInfiniteList.ts holds the pagination wiring once: page 1 upward, meta.last_page decides when to stop, pages flattened for FlatList, plus isEmpty and a loadMore that no-ops while already fetching.

All cache keys in one file. src/lib/query.ts exports the QueryClient (30s stale window, refetch on focus, no retry on any 4xx) and every key. Prefixes are hierarchical, so invalidating ['posts'] covers every filtered feed variant and the detail keys nested under it.

The token is an opaque Sanctum bearer token. It is not a JWT and it carries no claims, but it is not unbounded: demostatics-web/config/sanctum.php sets 'expiration' => env('SANCTUM_EXPIRATION', 60 * 24 * 30), so a token expires 30 days after issue by default, and expired rows are pruned daily by sanctum:prune-expired --hours=168. The client has no refresh path — an expired token surfaces as a 401 and the session handler drops it. The client stores it under the key demostatics.token.

src/lib/storage.ts puts that key in expo-secure-store — the Keychain on iOS, the Keystore on Android. expo-secure-store has no web implementation, so on web it falls back to localStorage; that path only ever runs under expo start --web during development.

The HTTP layer never imports the store. Instead the store registers two callbacks at module scope, before any component renders:

  • setTokenProvider(() => useSession.getState().token) gives client.ts a read-through to the current token.
  • setUnauthenticatedHandler(...) is called by client.ts on any 401. It drops the stored token and flips the session to guest, so an expired or revoked token cannot leave the app retrying forever against a screen that will never load.

Callbacks rather than imports because the store already imports the client — wiring it the other way round would be a module cycle.

restore() runs once from the root layout:

read demostatics.token
|
+-- no token ------------------------> status = guest
|
+-- token present
set token, call GET /auth/me
|
+-- 200 ---------------------> status = authenticated, user = response
|
+-- ApiError.status === 0 ---> status = authenticated, TOKEN KEPT
| (server unreachable) screens show their own offline state
|
+-- any other error ---------> delete token, status = guest
(ban reason captured if banned:true)

The status-0 branch is the part worth knowing. A stored token is trusted only as far as /auth/me, because it may have been revoked server-side by a logout elsewhere, a ban or an account deletion. But an unreachable server is not a revoked token, so an offline cold start keeps the session instead of silently signing the user out and losing it.

Tokens are named after the device, so a user could later tell their sessions apart and revoke one without killing the rest:

`${Device.deviceName || Device.modelName} (${Platform.OS})`

with Demostatics ${Platform.OS} as the fallback when both are null. The “signed-in devices” screen that would use this is Planned, not built — see Roadmap.

A 403 from /api/v1 can mean three different things, and the client tells them apart from explicit flags in the body rather than from the status code.

CauseBody carriesApiError surfaceWhat the user sees
Account is bannedbanned: true, ban_reason.banned, .banReasonThe reason, on the login screen after the session is dropped
Email not verifiedemail_unverified: true.emailUnverified, .isUnverified”Verify your email” — with resend and re-check actions
Policy denial (not your post)neither flagplain 403The server’s own sentence

Keying isUnverified off the explicit flag rather than off the status is what stops a verified user being told to check their inbox when what actually happened was that they do not own the post they tried to edit.

Ahead of a request, the client uses one predicate:

export function canWrite(user: User | null): boolean {
return !!user?.email_verified && !user.is_banned;
}

That mirrors the API’s verified tier, which in demostatics-web/routes/api.php gates post/comment/poll writes, voting, /reports, /database-items and the moderation ban routes. Unverified accounts can read the forum, polls and news, but cannot post, vote or open Data. That is the API’s rule, not a client-side one — the client only avoids firing a request whose answer it already knows.

For per-object permissions the client reimplements nothing. Every post, poll and comment carries a server-computed can block ({ update?, delete? }) derived from the same Laravel policies the web UI uses, and the screen renders what it is told.

Both worker-produced resources extend the same Dimensions interface — seven nullable id columns:

ColumnChain
region_idgeo
subregion_idgeo
country_idgeo
state_idgeo
city_idgeo
category_idclassification
subcategory_idclassification

src/components/DimensionFilter.tsx drives both chains as collapsible levels, each level enabled only once its parent is chosen. Picking a level clears every level below it in the same chain: a country left over from a previous region would otherwise still be sent, AND with the new region server-side, and silently match nothing. Unset levels are omitted from the query rather than sent as 0.

The sheet also holds a start_date / end_date range, validated as literal YYYY-MM-DD (re-formatting the parsed date back to the input string, because Date() rolls 2026-02-31 over into March) with the end required to be on or after the start. It keeps a draft copy of the selection, so closing without applying leaves the live filters untouched.

ReportDatabaseItem
Endpoint/reports/database-items
Distinct fieldssummary, published_atvalue, summary, measured_at
Card showsTitle, summary, dimension tags, publication dateTitle, the value in large type, summary, tags, measurement time
Access tierverifiedverified

Cards can only name three of the seven dimensions. Region, category and subcategory have lookup endpoints reachable from what the row carries. Country, state and city do not: their endpoints are keyed by a parent id (/subregions/{id}/countries, /countries/{id}/states, /states/{id}/cities) that the row does not always carry — a row may set country_id with a null subregion_id. Those levels are left off the card rather than shown as bare numbers.

The app is useless without the server, so start the server first.

Terminal window
cd demostatics-web
composer install
php artisan migrate
php artisan db:seed --class=DemoSeeder
php artisan serve

DemoSeeder creates verified demo accounts (admin@demostatics.test, editor@demostatics.test, member@demostatics.test) all with the password password.

Confirm the API answers:

Terminal window
curl -s -H 'Accept: application/json' http://localhost:8000/api/v1/health
{
"status": "ok",
"app": "Demostatics",
"api": "v1",
"world_store": true,
"can_register": false,
"queue": "requires_worker"
}

can_register: false means outbound mail is not deliverable (MAIL_MAILER=log or array), so new accounts can never verify and Data stays locked. queue: "requires_worker" means queued jobs are accepted and never run. Both are expected in a default local setup.

Terminal window
npm install
npm start # then press a / i / w

Or go straight to a target:

Terminal window
npm run android
npm run ios
npm run web

On a simulator or on web the defaults resolve on their own: http://localhost:8000 everywhere except the Android emulator, which gets http://10.0.2.2:8000.

A phone on wifi cannot reach your laptop’s loopback. Bind Laravel to all interfaces and point the app at your machine’s LAN address:

Terminal window
# in demostatics-web
php artisan serve --host=0.0.0.0 --port=8000
Terminal window
# in demostatics-mobile_application
echo 'EXPO_PUBLIC_API_URL=http://192.168.1.42:8000' > .env

The About screen prints the origin the app resolved and whether the geo store is reachable — check there first when a filter renders empty.

Terminal window
npm test # jest
npm run typecheck # tsc --noEmit
npm run lint # eslint, including the React Compiler rules
npm run build:web # static export; catches bundler-level breakage

npm test currently runs 102 tests across 6 suites: the HTTP layer’s mapping of responses onto typed errors, the session store’s token handling (including the offline case where the session must survive), the formatters, and three UI atoms. Native modules are mocked in jest.setup.js.

Full setup for the rest of the platform is on Getting Started.

Each of the following was verified against the source, not inferred.

GapStatusDetail
Four API functions have no callerNot builtauth.resetPassword, auth.deleteAccount, moderation.ban, moderation.unban exist in src/lib/api/index.ts and appear nowhere else in src/. Forgot-password sends the email; nothing consumes the emailed token
Comments are not paginated in the UIPartialCommentThread calls api.comments.onPost/onPoll with no page param and renders page 1, while its heading prints meta.total — so a thread of 60 comments says “60 comments” above the first page of them (10 by default, RESULTS_PER_PAGE in demostatics-web/config/demostatics.php)
About screen shows one of three health fieldsPartialOnly world_store is surfaced. can_register and queue — the two fields that explain a silently broken deployment — are typed in src/lib/api/types.ts and never rendered
Country / state / city cannot be named on Data cardsPartialTheir lookups are keyed by a parent the row does not carry
WEB_ORIGIN is deadNot builtExported from src/lib/config.ts, referenced nowhere
src/global.css is deadNot builtA :root font-stack block, imported by nothing
No release build configurationNot builtNo eas.json, no dev client, no CI workflow of any kind in the repo
No i18nPlannedRoughly 155 user-facing strings, every one an inline literal. Extraction is scheduled for roadmap Phase 5
No entitlements, billing, paywall, push, device linking, alerts, calculator, settings screen or mapPlannedRoadmap phases 3–9. No code exists for any of them
No screen or route is testedPartialThe 102 tests cover the HTTP layer, the session store, the formatters and three UI atoms. No test mounts a screen or exercises a route