Skip to content

Day 6: Dio, models, repositories, storage

Today is the layer between the screens and your Phoenix controllers. Every HTTP call the app makes goes through one Dio instance, gets the same bearer header, and fails with the same exception type. Every response becomes a typed model in one place. Once you have this layer in your head, any repository in app/lib/data/repositories/ takes about two minutes to read.

Day 5 showed AuthNotifier calling _repo.me() and catching ApiException, and reading the token and user stores through providers. Today you see where those come from. You need Provider, ref.watch vs ref.read, and the idea that a test can override any provider. The data layer structure page is the map. This page is the walk.

Open app/lib/core/config/app_config.dart. AppConfig has three fields. They come from compile-time constants at lines 41-48:

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'),
);

String.fromEnvironment is not an environment variable read at runtime. The compiler substitutes the value of --dart-define=API_BASE_URL=... at build time, and the result is a const. The nearest thing you know is Vite’s import.meta.env or an Android BuildConfig field. In Elixir it is compile_env, not System.get_env. Changing it means rebuilding, not restarting.

Two derived values sit on the class. fakeGps (line 29) is forced off in release builds whatever the flag says. socketUrl (lines 33-39) turns http://host:4001 into ws://host:4001/socket/websocket. That path matches socket "/socket", FootraceWeb.UserSocket at backend/lib/footrace_web/endpoint.ex:15, plus the /websocket suffix Phoenix adds for the WebSocket transport. You will use it tomorrow.

Line 52 is the provider:

final appConfigProvider = Provider<AppConfig>((_) => AppConfig.fromEnvironment);

That is the whole trick for testability. Nobody reads AppConfig.fromEnvironment directly. They ref.watch(appConfigProvider), and a test replaces it with overrideWithValue(AppConfig(...)). The shared test helper does exactly that at app/test/support/fakes.dart:545-547.

Open app/lib/data/api/api_client.dart. The client is built by a plain function, buildDio (lines 18-61), and wrapped in a provider afterwards (lines 95-105). The split is on purpose. Tests can call buildDio directly with fake inputs and get the real interceptors, without any Riverpod.

Dio is the Retrofit or OkHttp of Dart, or axios if you think in TypeScript. BaseOptions (lines 23-33) sets the base URL, 10-second timeouts, JSON in and out. Then:

// Let every status through so the error interceptor sees the body.
validateStatus: (status) => status != null && status < 400,

Read the code, not the comment. This makes Dio treat 4xx and 5xx as errors. They become a DioException of type badResponse that still carries response.data, and that is what the onError interceptor reads. The comment means “the body survives”. It does not mean every status counts as a success.

Lines 35-59 add an InterceptorsWrapper with two hooks. If you have written a Plug pipeline, this is the same idea: a function that sees the request on the way out and the error on the way back.

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);
},

options.extra is a per-request bag that never goes on the wire. The kNoAuth flag (line 11) marks login and register (app/lib/data/repositories/auth_repository.dart:26) and the public race-types call. Those skip the header, and their 401s do not sign you out.

The token is read on every request, from the keychain, through the readToken callback. Nothing caches it in memory. That costs a platform call per request and buys simplicity. After AuthNotifier writes a new token, the next request has it.

onError: (error, handler) async {
final status = error.response?.statusCode;
if (status == 401 &&
error.requestOptions.extra[kNoAuth] != true &&
onUnauthorized != null) {
await onUnauthorized();
}
handler.reject(DioException(
// ...
error: toApiException(error),
));
},

A 401 on an authenticated call runs onUnauthorized, which the provider wires to ref.read(authProvider.notifier).sessionExpired() (line 103). That is the line Day 5 left open. The comment at lines 101-102 explains why it is ref.read inside a closure. authProvider depends on authRepositoryProvider, which depends on dioProvider. If dioProvider watched authProvider while being built, Riverpod would throw a circular dependency error. Reading it lazily, at the moment of a 401, breaks the cycle.

On the backend side, 401s come from the :authorized_api pipeline, which runs plug Footrace.Users.Pipeline (backend/lib/footrace_web/router.ex:20-24). The repo’s CLAUDE.md says the body names the reason (token_expired, invalid_token, unauthenticated). The app does not branch on it. Any 401 means sign out.

ApiException (app/lib/data/api/api_exception.dart:5-29) is the only error type a repository throws. It carries a list of messages, an optional statusCode (null for network failures), and isNetwork. toApiException (app/lib/data/api/api_client.dart:65-84) builds one from the backend’s two error shapes. One is {"errors": [...]}, which the changeset paths use, for example backend/lib/footrace_web/controllers/api/user_session_controller.ex:55. The other is {"error": "..."}, used by most controllers, for example backend/lib/footrace_web/controllers/api/matchmaking_controller.ex:24.

The interceptor stores the ApiException inside the DioException (line 56), because an interceptor can only reject with a DioException. guard (lines 87-93) unwraps it at the repository boundary:

Future<T> guard<T>(Future<T> Function() call) async {
try {
return await call();
} on DioException catch (e) {
throw toApiException(e);
}
}

Line 66 returns the stored exception if there is one. So callers above the data layer never see Dio types. on DioException catch (e) is Dart’s typed catch, the same as Kotlin’s catch (e: IOException). The generic <T> and the Future<T> Function() parameter type read like TypeScript’s <T>(call: () => Promise<T>) => Promise<T>.

There is no code generation in this repo: no json_serializable, no freezed. Every model is a class with const constructor, final fields, a factory fromJson, and a toJson. Open app/lib/data/models/race_type.dart:22-37:

factory RaceType.fromJson(Map<String, dynamic> json) => RaceType(
slug: json['slug'] as String,
title: json['title'] as String,
meters: (json['meters'] as num).toInt(),
miles: (json['miles'] as num?)?.toDouble() ??
(json['meters'] as num) / 1609.344,
unit: RaceUnit.parse(json['unit'] as String?),
);

Decoded JSON in Dart is Map<String, dynamic>. dynamic turns off type checks, like any. Each as is a runtime cast that throws TypeError if the backend sends the wrong type. as num then .toInt() handles JSON numbers that may arrive as int or double. A factory constructor is a constructor that may run code and return an instance. Treat it like a Kotlin companion fun fromJson(...).

The enum below it (lines 40-50) is an “enhanced enum” with a static method, and the method uses a switch expression:

static RaceUnit parse(String? value) => switch (value) {
'metric' => RaceUnit.metric,
'imperial' => RaceUnit.imperial,
_ => RaceUnit.both,
};

Every enum in lib/data/models follows this pattern: parse with a _ fallback, so an unknown string from a newer backend becomes a safe default instead of a crash. RaceStatus.parse at app/lib/data/models/race_snapshot.dart:13-20 is the interesting one. It maps both countdown and not_started to countdown, because the engine and the REST API spell the pre-start state differently. CompetitorStatus.parse (lines 39-46) folds started into racing for the same reason. app/test/fixtures/README.md lists these backend inconsistencies on purpose.

The models use enum, not sealed. Dart’s sealed class appears in this app for event and state hierarchies whose cases carry different data: RaceEvent in app/lib/data/socket/race_channel.dart:5 (tomorrow) and QueueState in app/lib/features/queue/queue_notifier.dart:15 (Day 8).

Open app/lib/data/repositories/matchmaking_repository.dart:53-69 and backend/lib/footrace_web/controllers/api/matchmaking_controller.ex side by side.

App Backend
Route '/api/matchmaking/join' (line 60) post "/matchmaking/join" in the :authorized_api scope, backend/lib/footrace_web/router.ex:122
Body {'race_type': slug}, plus 'opponent': 'bot' and 'bot_pace_s_km' for bots (lines 61-66) matched in join/2 heads at lines 15 and 50. A missing race_type falls to line 94 and gets a 400
Queued reply JoinResult.fromJson reads competitorId, race stays null (lines 17-22) %{status: "queued", competitorId: ..., message: ...}, lines 60-64
Matched reply status == 'matched' and race is Map, then MatchedRace.fromJson %{status: "matched", competitorId: ..., race: format_race(race)}, lines 69-73
Race shape app/lib/data/models/matched_race.dart:46-61 format_race/1, lines 153-175
Bad slug ApiException(statusCode: 400, messages: ['Invalid race type']) via toApiException put_status(:bad_request) |> json(%{error: "Invalid race type"}), line 24

Note the snake_case race_type in the request body. The repo’s CLAUDE.md calls it one of the backend’s snake_case leaks. The response keys are camelCase because the controller writes them that way by hand.

One field does not line up. format_race/1 now sends botSlug on each competitor (backend/lib/footrace_web/controllers/api/matchmaking_controller.ex:171). MatchedCompetitor.fromJson (app/lib/data/models/matched_race.dart:18-24) ignores it, and the fixture app/test/fixtures/matchmaking_matched.json predates it, so the round-trip test (app/test/data/fixture_contract_test.dart:58-65) cannot notice. The backend checkout was read as it stands, without a git fetch. Either way, the rule in the app’s CLAUDE.md is that fixtures are copied from real payloads, so this one needs refreshing.

ApiException 400 is not retryable. Day 8 shows QueueNotifier turning it into canRetry: false (app/lib/features/queue/queue_notifier.dart:116).

The bottom of the file (lines 93-94):

final matchmakingRepositoryProvider = Provider<MatchmakingRepository>(
(ref) => ApiMatchmakingRepository(ref.watch(dioProvider)));

Every repository has the same shape. There is an abstract class interface, an Api... implementation that takes a Dio in its constructor, and a provider typed as the interface. Dart has no separate interface keyword for this. Any class can be implemented with implements, and abstract class is the convention for “this is the contract”. It is a Kotlin interface plus a Hilt @Provides method.

Open app/lib/data/storage/token_store.dart. TokenStore has read, write and clear. SecureTokenStore (lines 12-27) wraps flutter_secure_storage, which the file’s comment describes as “the platform keychain / keystore”. On iOS that is the Keychain you know from Swift. InMemoryTokenStore (lines 29-41) is the test double. It lives in production code because it is small and tests across the repo need it.

app/lib/data/storage/user_cache.dart has the same three methods for the last User, stored as JSON in SharedPreferences (NSUserDefaults on iOS, SharedPreferences on Android). Look at read (lines 20-30). A corrupt value is deleted and treated as missing, so a bad cache write never locks someone out. This cache is what makes the offline sign-in from Day 5 possible.

The token goes in the keychain because it is a credential. The user goes in plain preferences because it is not secret and is read on every cold start. ActiveRaceStore (app/lib/data/storage/active_race_store.dart) is the third store, also on SharedPreferences. It remembers the race id so a killed app can go back to the race. The durable upload queue uses sembast, a small document database, and lives under lib/tracking/upload/. That is Day 10.

There are two ways to fake this layer, and the repo uses both.

Above the repository, widget and notifier tests replace the whole repository. testOverrides (app/test/support/fakes.dart:524-575) returns a list of overrides, for example:

authRepositoryProvider.overrideWithValue(auth ?? FakeAuthRepository()),
// ...
tokenStoreProvider.overrideWithValue(InMemoryTokenStore(token)),
userCacheProvider.overrideWithValue(InMemoryUserCache(cachedUser)),

FakeMatchmakingRepository (app/test/support/fakes.dart:170-198) records joins and leaves and exposes onJoin/onPoll closures that a test can swap. This is the hand-rolled fake style you know from Kotlin. Mocktail is in dev_dependencies but these fakes do not use it.

Below the repository, repository tests keep the real Dio and replace only the network. FakeApi (app/test/support/fake_api_client.dart:26-79) calls the real buildDio (line 28), attaches http_mock_adapter’s DioAdapter (line 31), and adds one more interceptor that records every outgoing request (lines 32-41). So the bearer header and error mapping are exercised for real. Read app/test/data/matchmaking_repository_test.dart:17-27:

api.postFixture('/api/matchmaking/join', 'matchmaking_queued');
final result = await repo.join('five_km');
expect(result.competitorId, '06eca78c-0df5-43a7-bba0-d2e5d3f59da2');
expect(result.isMatched, isFalse);
expect(api.lastCall.body, {'race_type': 'five_km'});
expect(api.lastCall.headers['Authorization'], 'Bearer jwt');

The reply comes from app/test/fixtures/matchmaking_queued.json, a real backend payload. FakeApi does not pass onUnauthorized, so a 401 in these tests does not try to reach an AuthNotifier. app/test/data/auth_repository_test.dart:15-25 passes a counter instead, and asserts on it at line 62.

1. Run the contract and repository tests.

Terminal window
flutter test test/data/fixture_contract_test.dart test/data/matchmaking_repository_test.dart

Then add "botSlug": null to both competitors in app/test/fixtures/matchmaking_matched.json and run the contract test again. MatchedCompetitor.toJson does not write botSlug, so the round trip fails at the POST /api/matchmaking/join, matched case. That is the test doing its job. You have two honest fixes: add the field to the model, or list competitors[0].botSlug and competitors[1].botSlug in that case’s ignore set. The source there is the inner race map, and list elements get an index in the path (app/test/support/json_contract.dart:40-46). Revert when done, or keep it as a real change in its own PR.

2. Trace one header. Set a breakpoint at app/lib/data/api/api_client.dart:40 and run the first test in matchmaking_repository_test.dart under the debugger in your editor. Step until the recording interceptor in fake_api_client.dart:33 sees the header. Note the order: the app’s interceptor runs first because FakeApi added its own after buildDio returned.

  1. Why does dioProvider read authProvider.notifier inside a closure rather than at build time?
  2. Login returns 404 for bad credentials. Why does that not sign the user out, and what message does the screen show?
  3. What happens if the backend starts sending "unit": "nautical" for a race type?
  4. Where would you change the API base URL for a staging build, and do you need to rebuild?
  5. A repository test wants to check that a call sends the bearer token. Which fake does it use, and why not FakeMatchmakingRepository?
Answers
  1. authProvider depends (through the auth repository) on dioProvider. Reading it eagerly while building dioProvider would be a cycle. The closure at app/lib/data/api/api_client.dart:103 defers the read to the moment of a 401.
  2. The status is 404, not 401, and login also carries kNoAuth, so onUnauthorized is skipped (app/lib/data/api/api_client.dart:47-50). ApiAuthRepository.login rewrites the 404 into “Invalid email or password.” (app/lib/data/repositories/auth_repository.dart:46-51).
  3. RaceUnit.parse falls through _ to RaceUnit.both (app/lib/data/models/race_type.dart:45-49). No crash. The contract test would fail on the round trip, since both is written back.
  4. --dart-define=API_BASE_URL=..., or env/staging.json through --dart-define-from-file. It is a compile-time constant (app/lib/core/config/app_config.dart:42-45), so yes, rebuild.
  5. FakeApi, because it runs the real buildDio interceptors. A fake repository replaces the code under test along with Dio, so there would be no header to check.

Day 7: Phoenix channels from the client side leaves HTTP for the socket, using the socketUrl you saw this morning.