Skip to content

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.

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:

  • fakeGps is forced off in release builds: bool get fakeGps => !kReleaseMode && _fakeGps; (app/lib/core/config/app_config.dart:29). kReleaseMode is itself a compile-time constant, so the compiler drops the simulator branch from release code entirely.
  • socketUrl turns http://host:4001 into ws://host:4001/socket/websocket (app/lib/core/config/app_config.dart:33-39), which is FootraceWeb.UserSocket mounted at /socket in backend/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.

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.

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.

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.

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 Exception is a marker. Dart can throw any object; implementing Exception just signals “expected failure”, as opposed to Error (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.
  • statusCode is int?: 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.

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 factory constructor can return an instance it builds however it likes. Here it is a static from_map/1 that happens to use constructor syntax.
  • dynamic turns off static checking for that value. json['email'] as String is a runtime cast that throws a TypeError if the key is missing or the wrong type. as String? allows null. ?? false is 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.

The app sends almost none of these back to the server. toJson exists for two other reasons:

  1. Caching. UserCache stores a User as JSON in SharedPreferences (app/lib/data/storage/user_cache.dart:35).
  2. The contract test. app/test/data/fixture_contract_test.dart decodes every saved backend payload in test/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.

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.

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.

  • 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 the try sits inside guard, so it sees the raw DioException first.
  • Logout is best effort. logout swallows any DioException (app/lib/data/repositories/auth_repository.dart:80-86) and is the one method not wrapped in guard.
  • Snake case leaks. dashboard reads race_types under dashboard (app/lib/data/repositories/race_type_repository.dart:20-26), and join sends race_type, opponent and bot_pace_s_km (app/lib/data/repositories/matchmaking_repository.dart:61-66). Those match the MatchmakingController.join/2 clauses at backend/lib/footrace_web/controllers/api/matchmaking_controller.ex:15 (bot) and backend/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 of Map.merge with a conditional.
  • poll returns 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 the waiting: channel is unavailable; see Realtime.
  • History caches page 1. ApiRaceHistoryRepository writes the raw first-page body to SharedPreferences on 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 with on Object, Dart’s catch-everything.

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.

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.

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.