Data layer
Everything under app/lib/data/ plus app/lib/core/config/ is the part of the app that talks
to the outside world: the Phoenix API over HTTP, the keychain, and SharedPreferences. Screens
never touch any of it directly. A feature’s notifier asks a repository for a typed model; the
repository owns the URL, the Dio call and the JSON. Those are the only rules this layer
follows, and the code keeps to them.
The socket (app/lib/data/socket/) also lives under data/, but it has its own page:
Realtime. The durable GPS queue on sembast lives under
app/lib/tracking/upload/ and is covered in Tracking.
flowchart LR
subgraph features
N[Notifier<br/>AuthNotifier, QueueNotifier, ...]
end
subgraph data
R[Repository<br/>ApiAuthRepository, ...]
D[dioProvider<br/>shared Dio]
TS[TokenStore<br/>keychain]
UC[UserCache<br/>SharedPreferences]
end
C[appConfigProvider<br/>--dart-define]
N -->|typed models| R
R --> D
D -->|reads token| TS
D -->|base URL| C
D -->|401| A[authProvider.sessionExpired]
N --> UC
D -->|HTTPS JSON| B[(Phoenix<br/>/api/*)]
Every box above is a Riverpod Provider. If you have not read the
Riverpod primer yet, the short version for this page: a Provider is a
lazily built singleton in a container, ref.watch(otherProvider) declares a dependency, and
tests swap any provider for a fake with overrideWithValue. It is a DI container where every
binding is a function.
Config: AppConfig
Section titled “Config: AppConfig”app/lib/core/config/app_config.dart is the only file that reads build-time flags. Dart has no
.env at runtime; values are baked into the binary at compile time with --dart-define (or
--dart-define-from-file=env/dev.json), and read with String.fromEnvironment /
bool.fromEnvironment:
static const fromEnvironment = AppConfig( apiBaseUrl: String.fromEnvironment( 'API_BASE_URL', defaultValue: 'http://localhost:4000', ), devTools: bool.fromEnvironment('DEV_TOOLS', defaultValue: kDebugMode), fakeGps: bool.fromEnvironment('FAKE_GPS'),);(app/lib/core/config/app_config.dart:41-48)
The static const matters: String.fromEnvironment only works in a constant context, so the
whole AppConfig is a compile-time constant. The closest thing you know is Go’s
-ldflags "-X main.version=..." or a Swift #if flag, except the value is typed and has a
default. Elixir’s Application.compile_env/3 is the nearest BEAM analogue.
Two derived values sit on the class:
fakeGpsis forced off in release builds:bool get fakeGps => !kReleaseMode && _fakeGps;(app/lib/core/config/app_config.dart:29).kReleaseModeis itself a compile-time constant, so the compiler drops the simulator branch from release code entirely.socketUrlturnshttp://host:4001intows://host:4001/socket/websocket(app/lib/core/config/app_config.dart:33-39), which isFootraceWeb.UserSocketmounted at/socketinbackend/lib/footrace_web/endpoint.ex:15.
The provider is one line, app/lib/core/config/app_config.dart:52, and exists so tests can
override it. How the flags get set per environment is in
Build and release.
The HTTP client: one Dio, two interceptors
Section titled “The HTTP client: one Dio, two interceptors”Dio is the HTTP client. Think Axios or OkHttp: a configured
instance with base options and an interceptor chain. The app builds exactly one, in
buildDio (app/lib/data/api/api_client.dart:18-61), and hands it out through dioProvider.
Base options
Section titled “Base options”final dio = Dio(BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), sendTimeout: const Duration(seconds: 10), headers: const {'Accept': 'application/json'}, contentType: 'application/json', responseType: ResponseType.json, // Let every status through so the error interceptor sees the body. validateStatus: (status) => status != null && status < 400,));(app/lib/data/api/api_client.dart:23-33)
responseType: ResponseType.json means response.data arrives already decoded, as Dart’s
untyped JSON shape: Map<String, dynamic> for an object, List<dynamic> for an array. Nothing
is typed until a model’s fromJson runs. validateStatus makes any 4xx/5xx a DioException
with the response attached, which is what lets the error interceptor read your Phoenix error
bodies.
The comment on line 31 says “let every status through”, but the code does the opposite for 4xx and 5xx: those fail validation and become errors. The intent (the error path gets the body) is met either way, because Dio keeps the response on the exception.
The request interceptor: bearer token
Section titled “The request interceptor: bearer token”onRequest: (options, handler) async { if (options.extra[kNoAuth] != true) { final token = await readToken(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } } handler.next(options);},(app/lib/data/api/api_client.dart:36-44)
The token is read from the keychain on every request, not cached in memory. That costs a platform-channel call per request and buys you never sending a stale token after logout.
kNoAuth (app/lib/data/api/api_client.dart:11) is a per-request flag carried in Dio’s
extra map, which is request metadata that never goes on the wire. A repository opts out like
this (app/lib/data/repositories/auth_repository.dart:26):
static final _noAuth = Options(extra: const {kNoAuth: true});Login, register and GET /api/race_types use it. On the backend those three are the routes in
the unauthenticated pipe_through :api scope of backend/lib/footrace_web/router.ex;
everything else is under :authorized_api.
The error interceptor: 401 and normalisation
Section titled “The error interceptor: 401 and normalisation”onError: (error, handler) async { final status = error.response?.statusCode; if (status == 401 && error.requestOptions.extra[kNoAuth] != true && onUnauthorized != null) { await onUnauthorized(); } handler.reject(DioException( requestOptions: error.requestOptions, response: error.response, type: error.type, error: toApiException(error), ));},(app/lib/data/api/api_client.dart:45-58)
Two jobs. First, any 401 on an authenticated request calls onUnauthorized, which
dioProvider wires to authProvider.notifier.sessionExpired()
(app/lib/data/api/api_client.dart:103). That clears the token and cached user and flips auth
to signed out (app/lib/features/auth/auth_notifier.dart:74-78); the router’s redirect then
sends you to login. The whole chain is in Auth flow. A 401 from login itself
is excluded by the kNoAuth check, so a wrong password never looks like an expired session.
Second, it re-throws the failure as a new DioException whose .error field carries an
already-built ApiException. That is a way of smuggling a typed error through Dio’s
exception type.
The provider and its circular dependency
Section titled “The provider and its circular dependency”final dioProvider = Provider<Dio>((ref) { final config = ref.watch(appConfigProvider); final tokens = ref.watch(tokenStoreProvider); return buildDio( baseUrl: config.apiBaseUrl, readToken: tokens.read, // Read lazily: the auth notifier depends on this provider, so it must // not be resolved while this provider is being built. onUnauthorized: () => ref.read(authProvider.notifier).sessionExpired(), );});(app/lib/data/api/api_client.dart:95-105)
authProvider needs authRepositoryProvider, which needs dioProvider, which needs to call
back into authProvider. The closure breaks the cycle: ref.read runs only when a 401
arrives, long after both providers exist. It is the same trick as passing a function reference
instead of a module into a GenServer’s init args.
tokens.read is passed as a tear-off: a method reference with its receiver bound, like
&TokenStore.read/1 with the struct already applied. The TokenReader typedef on line 13
names that function type.
ApiException
Section titled “ApiException”app/lib/data/api/api_exception.dart:5-29 is the one error type the rest of the app catches.
class ApiException implements Exception { const ApiException({ required this.messages, this.statusCode, this.isNetwork = false, });
const ApiException.network() : messages = const [ 'Could not reach the server. Check your connection and try again.' ], statusCode = null, isNetwork = true; // ... bool get isUnauthorized => statusCode == 401; String get message => messages.join('\n');}Dart notes for this class:
implements Exceptionis a marker. Dart canthrowany object; implementingExceptionjust signals “expected failure”, as opposed toError(a bug).ApiException.network()is a named constructor. Dart has no overloading, so alternative constructors get names. The: messages = ...part is an initializer list, run before the body, the way Kotlin’s secondary constructor delegates.statusCodeisint?: nullable, null for a network failure. Dart’s null safety is sound, like Kotlin’s and Swift’s.
toApiException (app/lib/data/api/api_client.dart:65-84) builds it. It reads the two error
shapes the Phoenix controllers return: {"errors": [...]} and {"error": "..."}. For example
RaceController.put_position answers json(%{error: "Race has already ended"}) with a 409
(backend/lib/footrace_web/controllers/api/race_controller.ex:70-73). No response at all
(timeout, DNS, airplane mode) becomes ApiException.network().
Future<T> guard<T>(Future<T> Function() call) async { try { return await call(); } on DioException catch (e) { throw toApiException(e); }}(app/lib/data/api/api_client.dart:87-93)
Every repository method wraps its body in guard, so callers only ever see ApiException.
on X catch (e) is Dart’s typed catch, like Kotlin’s catch (e: X). Line 66 of
toApiException is the other half of the smuggling trick: if the interceptor already put an
ApiException in .error, it is returned as is.
Future<T> is a Promise / Kotlin Deferred; async/await behave as in TypeScript. The
Dart primer covers both.
Models and JSON
Section titled “Models and JSON”There is no code generation. pubspec.yaml has no json_serializable, freezed or
build_runner; every model under app/lib/data/models/ has a hand-written fromJson factory
and a toJson method. That is the plainest way to do JSON in Dart and the one with no build
step, at the cost of writing the casts yourself.
User is the smallest complete example (app/lib/data/models/user.dart:24-40):
factory User.fromJson(Map<String, dynamic> json) => User( id: json['id'].toString(), email: json['email'] as String, name: json['name'] as String?, confirmed: json['confirmed'] as bool? ?? false, insertedAt: json['insertedAt'] == null ? null : DateTime.tryParse(json['insertedAt'] as String), );
Map<String, dynamic> toJson() => { 'id': id, 'email': email, 'name': name, 'confirmed': confirmed, 'insertedAt': insertedAt?.toIso8601String(), };How to read it:
- A
factoryconstructor can return an instance it builds however it likes. Here it is a staticfrom_map/1that happens to use constructor syntax. dynamicturns off static checking for that value.json['email'] as Stringis a runtime cast that throws aTypeErrorif the key is missing or the wrong type.as String?allows null.?? falseis the null-coalescing default.json['id'].toString()accepts a number or a string. It is defensive against ids changing type, and it shows up throughout the models.=>is a one-expression body.{ 'id': id, ... }is a map literal.
The models lean towards forgiving parsing, because the backend has a few inconsistencies
that app/test/fixtures/README.md lists. Two examples worth knowing:
static RaceStatus parse(String? s) => switch (s) { 'countdown' || 'not_started' => RaceStatus.countdown, 'racing' => RaceStatus.racing, // ... _ => RaceStatus.unknown, };(app/lib/data/models/race_snapshot.dart:13-20) maps both spellings of the pre-start state
onto one enum value and anything new onto unknown. And RaceTypeInfo.fromJson reads
distanceMeters (channel payloads) or meters (GET /api/races/:id), then writes both back
out (app/lib/data/models/race_snapshot.dart:65-79).
That switch is a Dart 3 switch expression with || patterns. It is close to an Elixir
case with multiple clauses returning a value. The Dart primer has
more on patterns.
Some response wrappers are not models at all but small classes next to the repository that
uses them: JoinResult and PollMatch in
app/lib/data/repositories/matchmaking_repository.dart:9-32.
Why every model also has toJson
Section titled “Why every model also has toJson”The app sends almost none of these back to the server. toJson exists for two other reasons:
- Caching.
UserCachestores aUseras JSON inSharedPreferences(app/lib/data/storage/user_cache.dart:35). - The contract test.
app/test/data/fixture_contract_test.dartdecodes every saved backend payload intest/fixtures/into its model and encodes it back, then compares:
test('GET /api/me', () { final source = fixture('me'); expectRoundTrip(source, MeResponse.fromJson(source).toJson());});(app/test/data/fixture_contract_test.dart:23-26)
If the backend renames insertedAt, the round trip loses it and this test fails before any
screen shows a blank. A field the app ignores on purpose is listed in an ignore set
(app/test/data/fixture_contract_test.dart:28-34). This is the codebase’s answer to “how do
you keep a client in step with a server you also own”: not a generated client, but real
payloads plus a round trip. More in Testing.
Repositories
Section titled “Repositories”A repository is an abstract class (the interface) plus an Api... implementation that
takes the Dio, plus a provider. Dart has no interface keyword in common use; any class can
be implemented with implements, and abstract class is the usual way to write a pure
interface. The pattern, from app/lib/data/repositories/user_repository.dart:7-28:
abstract class UserRepository { Future<PublicProfile> profile(String userId);}
class ApiUserRepository implements UserRepository { ApiUserRepository(this._dio);
final Dio _dio;
@override Future<PublicProfile> profile(String userId) => guard(() async { final response = await _dio.get<Map<String, dynamic>>('/api/users/$userId'); return PublicProfile.fromJson(response.data!); });}
final userRepositoryProvider = Provider<UserRepository>( (ref) => ApiUserRepository(ref.watch(dioProvider)));ApiUserRepository(this._dio) is constructor shorthand that assigns the field. A leading
underscore makes a name private to its file (Dart’s privacy is per library, not per class).
response.data! asserts non-null, like Kotlin’s !!.
The abstract class is what tests fake. app/test/support/fakes.dart has a Fake<Name>Repository
for each one and overrides the provider; repository tests instead use the real
implementation over a Dio whose network is mocked (app/test/support/fake_api_client.dart),
so the interceptors run too.
Every repository
Section titled “Every repository”Backend controllers are under backend/lib/footrace_web/controllers/api/. Routes are in the
/api scopes of backend/lib/footrace_web/router.ex.
Repository (app/lib/data/repositories/) |
Methods → endpoints | Models | Backend controller |
|---|---|---|---|
auth_repository.dart |
login → POST /api/login (no auth); register → POST /api/register (no auth); me → GET /api/me; logout → POST /api/logout |
AuthResponse, MeResponse, User, UserStats |
user_session_controller.ex, me_controller.ex |
race_type_repository.dart |
dashboard → GET /api/dashboard; raceTypes → GET /api/race_types (no auth) |
RaceType |
dashboard_controller.ex, race_type_controller.ex |
matchmaking_repository.dart |
join → POST /api/matchmaking/join; poll → GET /api/competitors/:id/poll; leave → DELETE /api/matchmaking/leave |
JoinResult, PollMatch, MatchedRace |
matchmaking_controller.ex, competitor_controller.ex |
lobby_repository.dart |
list → GET /api/lobby; join → POST /api/lobby/:id/join |
LobbyEntry, MatchedRace |
lobby_controller.ex |
race_repository.dart |
show → GET /api/races/:id; abort → POST /api/races/:id/abort; results → GET /api/races/:id/results |
RaceSnapshot, RaceResult |
race_controller.ex |
race_history_repository.dart |
page → GET /api/me/races; cachedFirstPage (local) |
RaceHistoryPage, RaceHistoryItem |
me_controller.ex |
activity_repository.dart |
page → GET /api/activity |
ActivityPage, ActivityItem |
activity_controller.ex |
leaderboard_repository.dart |
global → GET /api/leaderboard; byRaceType → GET /api/leaderboard/:race_type |
LeaderboardPage, LeaderboardEntry |
leaderboard_controller.ex |
profile_repository.dart |
settings → GET /api/me/settings; updateSettings → PATCH /api/me/settings; updateName → PATCH /api/me |
UserSettings, User |
me_controller.ex |
user_repository.dart |
profile → GET /api/users/:id |
PublicProfile |
user_controller.ex |
One endpoint is missing from the table on purpose: POST /api/races/:id/positions. No
repository calls it. GPS fixes are uploaded from the foreground-service isolate with the plain
http package, because Dio and Riverpod do not exist in that isolate. See
Tracking.
Details worth knowing
Section titled “Details worth knowing”- Login 404 is rewritten. The backend answers 404 “User not found” to bad credentials; the
repository turns that into “Invalid email or password.” (
app/lib/data/repositories/auth_repository.dart:43-53). Note thetrysits insideguard, so it sees the rawDioExceptionfirst. - Logout is best effort.
logoutswallows anyDioException(app/lib/data/repositories/auth_repository.dart:80-86) and is the one method not wrapped inguard. - Snake case leaks.
dashboardreadsrace_typesunderdashboard(app/lib/data/repositories/race_type_repository.dart:20-26), andjoinsendsrace_type,opponentandbot_pace_s_km(app/lib/data/repositories/matchmaking_repository.dart:61-66). Those match theMatchmakingController.join/2clauses atbackend/lib/footrace_web/controllers/api/matchmaking_controller.ex:15(bot) andbackend/lib/footrace_web/controllers/api/matchmaking_controller.ex:50(person). - Collection-if.
if (bot) 'opponent': 'bot',inside a map literal adds the key only when the condition holds. Dart has this for lists and maps; there is no Elixir equivalent short ofMap.mergewith a conditional. pollreturns null while waiting.Future<PollMatch?>: the nullable result is the “nothing yet” signal (app/lib/data/repositories/matchmaking_repository.dart:72-83). The queue only polls when thewaiting:channel is unavailable; see Realtime.- History caches page 1.
ApiRaceHistoryRepositorywrites the raw first-page body toSharedPreferenceson every successful fetch and reads it back for an instant, offline-capable History tab (app/lib/data/repositories/race_history_repository.dart:26-56). Cache failures are swallowed withon Object, Dart’s catch-everything.
Storage
Section titled “Storage”Three small stores in app/lib/data/storage/, all following the same shape as repositories:
an abstract class, a real implementation, an in-memory implementation for tests, and a
provider.
| Store | Backing | Key | Holds | Used by |
|---|---|---|---|---|
TokenStore (token_store.dart) |
flutter_secure_storage: iOS Keychain, Android Keystore-backed storage |
auth_token |
the JWT | dioProvider, socketTransportProvider, AuthNotifier, RaceNotifier |
UserCache (user_cache.dart) |
SharedPreferences (NSUserDefaults / Android shared prefs) |
cached_user |
last User as JSON |
AuthNotifier (open straight to Home, work offline) |
ActiveRaceStore (active_race_store.dart) |
SharedPreferences |
active_race_id |
the race you are in | RaceNotifier (resume after the app is killed) |
SecureTokenStore is ten lines (app/lib/data/storage/token_store.dart:12-27). The test
version is the same interface over a field:
class InMemoryTokenStore implements TokenStore { InMemoryTokenStore([this._token]); String? _token;
@override Future<String?> read() async => _token; // ...}(app/lib/data/storage/token_store.dart:29-41). [this._token] is an optional positional
parameter.
PrefsUserCache.read deletes a cache entry it cannot parse
(app/lib/data/storage/user_cache.dart:20-30). It only catches FormatException, which is
what jsonDecode throws for malformed text. A well-formed blob with a missing email would
fail the as String cast with a TypeError instead, which this catch does not cover. That
can only happen if an older app version wrote a different shape, so it is unlikely, but it is
how the code reads.
Where sembast fits
Section titled “Where sembast fits”sembast is a pure-Dart document store (a JSON file with an
append log). The app uses it in exactly one place, the GPS fix queue in
app/lib/tracking/upload/fix_queue.dart, and nowhere under lib/data/. The reason given in
the code is that it is “pure Dart, so it works inside the foreground-service isolate with no
plugin channel” (app/lib/tracking/upload/fix_queue.dart:33-34): a queue that has to survive
a killed app should not depend on a platform plugin being registered in a second engine. Details in
Tracking.
Providers at a glance
Section titled “Providers at a glance”| Provider | File | Type | Overridden in tests by |
|---|---|---|---|
appConfigProvider |
app/lib/core/config/app_config.dart:52 |
Provider<AppConfig> |
a literal AppConfig(...) |
dioProvider |
app/lib/data/api/api_client.dart:95 |
Provider<Dio> |
not usually; repositories are faked instead |
tokenStoreProvider |
app/lib/data/storage/token_store.dart:43 |
Provider<TokenStore> |
InMemoryTokenStore |
userCacheProvider |
app/lib/data/storage/user_cache.dart:59 |
Provider<UserCache> |
InMemoryUserCache |
activeRaceStoreProvider |
app/lib/data/storage/active_race_store.dart:42 |
Provider<ActiveRaceStore> |
InMemoryActiveRaceStore |
*RepositoryProvider |
each file in app/lib/data/repositories/ |
Provider<XRepository> |
FakeXRepository in app/test/support/fakes.dart |
The overrides are assembled in one place, testOverrides(...) in app/test/support/fakes.dart
(token and user cache at lines 572-573), so a widget test boots the whole app with nothing
touching a device.
Every one of these is a plain Provider holding a service object, not state. They are built
once and only rebuilt if something they ref.watch changes, which in a running app (as
opposed to a test override) does not happen. State lives in the notifiers that call them, which the
Features page lists.
Where to go next
Section titled “Where to go next”- Day 6: Dio, models, repositories, storage walks this page with the editor open.
- Auth flow follows a token from login through the interceptor to a 401.
- Realtime is the other half of talking to Phoenix.