Day 1: Dart through lib/core and main.dart
Today you learn to read Dart by reading the smallest, least Flutter-heavy corner of the app:
the project file, the entry point, and lib/core/. These files hold build-time config, the
app-wide logger, an injectable clock, and every distance, pace and time string the app
shows. Nearly every other file imports one of them, so after today the imports at the top
of any screen will look familiar.
Before you start
Section titled “Before you start”This is the first day, so nothing comes before it. You need a working toolchain: the repo
pins Flutter 3.47.2-stable in .tool-versions (asdf), and flutter --version should
agree. From the footrace-flutter directory, run flutter pub get once.
Keep the Dart primer open in another tab for when a piece of
syntax needs more than the two sentences it gets here, and the
glossary for terms. The directory map
shows where lib/core/ sits among the other folders.
The project file: pubspec.yaml
Section titled “The project file: pubspec.yaml”Open app/pubspec.yaml. It is the package.json, Gemfile or mix.exs of the app, in
YAML.
name: footrace_flutterdescription: Footrace mobile app. Live 1v1 running races against the Phoenix backend.publish_to: none
version: 1.0.0+1
environment: sdk: ^3.0.0That is app/pubspec.yaml:1-8. Three things matter here:
nameis the package name, and it becomes the root of everypackage:import.lib/core/units.dartis importable from anywhere aspackage:footrace_flutter/core/units.dart. Note thatlib/disappears from the path:package:footrace_flutter/points atlib/.publish_to: nonestopsdart pub publishfrom ever uploading it to pub.dev. It is an app, not a library.version: 1.0.0+1is two numbers in one:1.0.0is the user-visible version (CFBundleShortVersionString/versionName),+1the build number (CFBundleVersion/versionCode). The release targets override the build number with--build-number=$(BUILD_NUMBER)(app/Makefile:83).
Dependencies follow at app/pubspec.yaml:10-32. The caret means what it means in npm:
flutter_riverpod: ^2.4.0 accepts any 2.x from 2.4.0 up. The odd one out is
dependencies: flutter: sdk: flutter(app/pubspec.yaml:11-12): Flutter itself is a package that comes from the installed SDK
rather than from pub.dev. dev_dependencies (app/pubspec.yaml:34-46) are test and build
tooling that never ship: flutter_test, mocktail, fake_async, the icon and splash
generators.
The flutter: block at app/pubspec.yaml:48-63 is not a dependency list. It configures the
Flutter build: uses-material-design bundles the Material icon font, and fonts: declares
the two bundled typefaces. The family names there, PlusJakartaSans and SpaceGrotesk,
are the strings the code uses later (Day 3). The last two blocks (app/pubspec.yaml:65-81)
are config for the icon and splash generators, the way a package.json carries a
"prettier" or "jest" key.
| You know | Dart / Flutter |
|---|---|
package.json, Gemfile, mix.exs |
pubspec.yaml |
package-lock.json, Gemfile.lock, mix.lock |
pubspec.lock (committed, because this is an app) |
npm install, bundle, mix deps.get |
flutter pub get (make deps also does widgetbook/) |
node_modules/, deps/ |
a global cache in ~/.pub-cache, plus .dart_tool/package_config.json mapping names to paths |
flutter pub get resolves versions, writes pubspec.lock and .dart_tool/, and that is all.
Most flutter commands run it for you when pubspec.yaml changed.
analysis_options.yaml
Section titled “analysis_options.yaml”Open app/analysis_options.yaml. It is the .eslintrc or .credo.exs: the analyzer reads it
and so does your editor. The only active line is
include: package:flutter_lints/flutter.yaml(app/analysis_options.yaml:10), the recommended lint set from the flutter_lints dev
dependency. Lines 12-20 exclude the native platform folders, and the rules: block at
lines 33-35 overrides nothing. The strictness comes from the command instead:
make analyze runs flutter analyze --fatal-infos (app/Makefile:16-17), so even an “info”
lint fails CI. make check (app/Makefile:34) is format check, analyze and test, the same
three steps CI runs.
dart format (app/Makefile:10-14) is gofmt for Dart: one style, no options worth
arguing about. One habit worth knowing: a trailing comma after the last argument makes the
formatter break the call one argument per line. That is why so much Flutter code ends lines
in ,.
The entry point: main.dart
Section titled “The entry point: main.dart”Open app/lib/main.dart. The whole file is 21 lines.
import 'package:flutter/foundation.dart';import 'package:flutter/material.dart';import 'package:flutter_foreground_task/flutter_foreground_task.dart';import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart';import 'core/logging.dart';
void main() { WidgetsFlutterBinding.ensureInitialized(); installErrorHandlers(); // ... FlutterForegroundTask.initCommunicationPort(); runApp( const ProviderScope( observers: [if (kDebugMode) LoggingProviderObserver()], child: FootraceApp(), ), );}Imports. Lines 1-4 are package: URIs: a package name, then a path inside that package’s
lib/. Lines 6-7 are relative paths, resolved against this file, like ./app/app.dart in
TypeScript. Both styles appear across the codebase: lib/ files mostly import each other
relatively, and tests use package:footrace_flutter/... because they live outside lib/.
An import pulls in every public name in the file, unqualified, much like Go’s dot-import or
Elixir’s import. To narrow it, Dart has show and as; you will see
import 'package:flutter/foundation.dart' show kDebugMode, kReleaseMode; at
app/lib/core/config/app_config.dart:1.
main is a top-level function. No class, no @main, no Application subclass. Dart
allows functions and variables at the top level of a file, the way Go and Kotlin do.
What it does, in order:
WidgetsFlutterBinding.ensureInitialized()(app/lib/main.dart:10) wires the Dart side to the engine. You need it before touching a platform plugin outside ofrunApp.installErrorHandlers()(app/lib/main.dart:11) routes uncaught errors to the logger. You will read it in a minute.FlutterForegroundTask.initCommunicationPort()(app/lib/main.dart:14) opens the channel the GPS isolate uses to send fixes back.CLAUDE.mdsays this must run before anything else; Day 9 explains why.runApp(...)(app/lib/main.dart:15-20) mounts the root widget and never returns in any useful sense. There is no run loop for you to write.
Two pieces of syntax on lines 16-17. const ProviderScope(...) builds the object at compile
time; const in Dart is deeper than JavaScript’s const binding. It means “this value is a
compile-time constant”, and identical const expressions share one instance. Flutter leans
on this: a const widget is never rebuilt. And [if (kDebugMode) LoggingProviderObserver()]
is a collection if: an if inside a list literal, which leaves the element out when the
condition is false. There is a matching collection for. Neither has a direct TypeScript
equivalent; the nearest is [...(debug ? [x] : [])].
ProviderScope is Riverpod’s container, the root of the dependency graph. The
Riverpod primer and Day 5
cover it. For today, read it as “the DI container, installed at the root”.
lib/core: config, logging, the clock
Section titled “lib/core: config, logging, the clock”AppConfig
Section titled “AppConfig”Open app/lib/core/config/app_config.dart. This is the only file that reads --dart-define
flags.
class AppConfig { const AppConfig({ required this.apiBaseUrl, required this.devTools, bool fakeGps = false, }) : _fakeGps = fakeGps;
final String apiBaseUrl; final bool devTools; final bool _fakeGps; // ... bool get fakeGps => !kReleaseMode && _fakeGps;That is lines 10-29, trimmed. A lot of Dart is packed in:
- Named parameters in braces.
{required this.apiBaseUrl, ...}makes every argument named at the call site:AppConfig(apiBaseUrl: '...', devTools: true).requiredmakes one mandatory; without it a named parameter is optional and needs a default or a nullable type. Kotlin and Swift readers: this is argument labels, opt-in per parameter. this.apiBaseUrlin the parameter list assigns the field directly, like a Kotlin primary constructorval.- The initializer list after the colon (
: _fakeGps = fakeGps, line 15) runs before the body. It is where you setfinalfields that are not straightthis.copies. finalfields are set once. Combined with aconstconstructor, the whole object can be a compile-time constant.- The underscore.
_fakeGpsis private, but private to the library (the file), not to the class. Any code inapp_config.dartcan read_fakeGpson anyAppConfig; code in other files cannot see it at all. This is the honest break from Kotlin and Swiftprivate, which are type-scoped. There is nopublickeyword: no underscore means public. - Getters.
bool get fakeGps => ...(line 29) is a computed property, the Swiftvar fakeGps: Bool { ... }. The=>is shorthand for{ return ...; }.
The getter is a small safety catch: even if someone builds a release with
FAKE_GPS=true, kReleaseMode is a compile-time constant and the getter returns false.
socketUrl (lines 33-39) derives the Phoenix socket URL from the HTTP base:
https becomes wss, the path becomes /socket/websocket. That path is Phoenix’s default
for socket "/socket", FootraceWeb.UserSocket, which is what the backend declares at
backend/lib/footrace_web/endpoint.ex:15.
The flags themselves are read 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 System.getenv. It is evaluated by the compiler from the
--dart-define flags passed to flutter run or flutter build, and only works in a const
context. The closest thing you know is Go’s -ldflags "-X main.version=...": baked into the
binary, invisible at runtime. Change a flag and you rebuild; hot reload will not pick it up.
Line 52 wraps it in a provider so tests can swap it:
final appConfigProvider = Provider<AppConfig>((_) => AppConfig.fromEnvironment);A top-level final variable holding a provider is the pattern you will see in nearly every
file from Day 5 on. The (_) => ... is a closure that ignores its argument.
Logging and the error handlers
Section titled “Logging and the error handlers”Open app/lib/core/logging.dart. Line 7 is a top-level variable:
final log = Logger( level: kDebugMode ? Level.debug : Level.warning, printer: SimplePrinter(colors: false, printTime: true),);Top-level and static variables in Dart are initialized lazily, on first read, so this costs
nothing until something logs. Anywhere in the app, log.w('...') and log.e(...) are the
calls; app/lib/features/auth/auth_notifier.dart:39 is a real one.
installErrorHandlers (lines 14-24) sets two global hooks:
final previous = FlutterError.onError;FlutterError.onError = (details) { previous?.call(details); log.e('Flutter error', error: details.exception, stackTrace: details.stack);};PlatformDispatcher.instance.onError = (error, stack) { log.e('Uncaught error', error: error, stackTrace: stack); return true;};FlutterError.onError catches errors the framework itself catches (a build() that throws,
a layout overflow). PlatformDispatcher.instance.onError catches everything else that
escapes, including errors in async code nobody awaited. Returning true marks the error as
handled. previous?.call(details) is a null-aware call: call it if it is not null, the
previous?.(details) you would write in TypeScript. Dart’s null safety is sound: a T can
never be null, a T? can, and the compiler makes you deal with it.
Lines 27-43 subclass Riverpod’s ProviderObserver to log any provider that fails.
@override is an annotation, but the analyzer enforces it like Kotlin’s override
keyword. main.dart installs this observer in debug builds only.
About “errors”. CLAUDE.md lists errors under lib/core/, but there is no errors file
there. Uncaught-error handling is the code above. The app’s error type, ApiException,
lives in app/lib/data/api/api_exception.dart:5-29. It is worth a glance now for its named
constructor, const ApiException.network() at lines 12-17: a class can have any number of
constructors, distinguished by name, much like Swift’s convenience init labels or
Kotlin’s companion factories. Day 6 reads
it properly.
The clock and relative time
Section titled “The clock and relative time”app/lib/core/clock.dart is seven lines:
typedef Clock = DateTime Function();
final clockProvider = Provider<Clock>((_) => DateTime.now);DateTime Function() is a function type: takes nothing, returns a DateTime. Note
DateTime.now without parentheses: that is a tear-off, the function itself rather
than a call to it, like passing Date.now in JavaScript. Screens read the clock with
ref.read(clockProvider)(), the trailing () being the actual call
(app/lib/features/activity/activity_tab.dart:53). Tests override clockProvider to freeze
time, which is the same trick as injecting a Clock in Kotlin or passing now into an
Elixir function.
app/lib/core/relative_time.dart shows the other style of the same idea:
String relativeTime(DateTime at, {DateTime? now}) { final reference = now ?? DateTime.now(); final diff = reference.difference(at); if (diff.inSeconds < 60) return 'just now'; if (diff.inMinutes < 60) return '${diff.inMinutes} min ago'; // ...}Positional at, then an optional named now in braces. ?? is null-coalescing, as in
TypeScript and Swift. reference.difference(at) returns a Duration, and '${...}' is
string interpolation ($name works for a bare identifier). The Activity feed calls it with
the injected clock: relativeTime(item.finishedAt!, now: now)
(app/lib/features/activity/activity_tab.dart:158). The ! there is the non-null assertion,
same as Kotlin’s !! and Swift’s !: it throws if finishedAt is null.
lib/core/units.dart
Section titled “lib/core/units.dart”This is the file you will change in the exercise. Open app/lib/core/units.dart.
An enum and a notifier. Line 7 is enum Units { metric, imperial }. Lines 9-23 are a
Riverpod Notifier that follows the user’s server-side settings. Skim it; Day 5 is about
notifiers. One line is worth pointing out now:
if (value != null) state = (value as dynamic).units as Units;(line 14). dynamic switches off static checking for that expression, the TypeScript any.
It is rare in this codebase. The listener is typed with AsyncValue<Object?> rather than
the settings type, so the code casts its way back to .units. I could not find a comment
saying why; since Dart allows circular imports, it is not forced.
The formatter. Lines 27-84 are the class every screen uses for distances, paces and times:
class UnitsFormatter { const UnitsFormatter(this.units);
final Units units;
static const _metersPerMile = 1609.344;
bool get isMetric => units == Units.metric;
String distance(num meters) { if (isMetric) { if (meters < 1000) return '${meters.round()} m'; return '${_trim(meters / 1000)} km'; } return '${_trim(meters / _metersPerMile)} mi'; }numis the supertype ofintanddouble. Passing5000or21097.5both work.meters / 1000is always adoublein Dart, even for two ints. Integer division is its own operator,~/, used in_clockat lines 66-67:total ~/ 3600. Go and Kotlin readers will want/to truncate; it does not.static const _metersPerMileis a class constant, private to the file.distanceAlt(lines 46-47) builds a second formatter for the other unit, using the arrow form for a one-line method.
_trim (lines 75-83) rounds to two decimals and drops trailing zeros, so 5000 m reads
5 km and a half marathon 21.1 km. _clock (lines 65-72) produces m:ss or h:mm:ss with
padLeft. Both are static: they need no instance.
Finally, unitsFormatterProvider (lines 86-88) rebuilds the formatter whenever the
unitsProvider changes, so a unit switch in Settings changes every string at once. That is
the doc comment’s promise at lines 25-26.
The tests in app/test/core/units_test.dart read like a spec. Note line 2:
import 'package:footrace_flutter/core/units.dart';. Tests live outside lib/, so they use
the package: form. main() (line 4) declares tests with test('name', () { ... }) and
expect(actual, matcher), which is Jest’s test and expect or ExUnit’s test and
assert. The const metric = UnitsFormatter(Units.metric); at line 5 works because the
constructor is const.
Try it
Section titled “Try it”1. Run one test file. From footrace-flutter:
flutter test test/core/units_test.dartYou should see four tests pass: metric distances, imperial distances, pace, durations. The first run takes a while because it compiles the test harness; later runs are quick.
2. Break a conversion on purpose. In app/lib/core/units.dart:39, change the metric
threshold from 1000 to 2000:
if (meters < 2000) return '${meters.round()} m';Before you run anything, predict which expectation fails. Then run the same command. The
failure should point at app/test/core/units_test.dart:10, expect(metric.distance(1000), '1 km'),
because 1000 now prints as 1000 m. Read the failure output: it shows Expected and
Actual side by side.
Revert the change. Then try one that should not break anything: change _metersPerMile
at line 32 to 1609.34. Run the test again. Do the imperial expectations still hold at two
decimals? (They should; the rounding absorbs it.) Revert that too.
3. Optional, if you have time. relativeTime is not tested under test/core/; its
cases sit in the Activity feature’s test, app/test/features/activity/activity_test.dart:71-81.
Read them, then change 'just now' at app/lib/core/relative_time.dart:6 to 'now' and run
only that file:
flutter test test/features/activity/activity_test.dartNote that a test for a lib/core/ function failing in a feature test is the cost of the
test living where the function is used, not where it is defined. Revert the change.
Check yourself
Section titled “Check yourself”- You see
import 'package:footrace_flutter/data/api/api_client.dart';. Which file on disk is that? - A field in
units.dartis called_metersPerMile. Canleaderboard_screen.dartread it? Can another class inunits.dart? - You change
API_BASE_URLin your run configuration and hot-reload. Does the app talk to the new URL? Why or why not? - What does
DateTime.now(no parentheses) mean inclock.dart, and why does the app pass it around instead of callingDateTime.now()everywhere? - What does
5000 / 1000evaluate to in Dart, and which operator gives you5as anint?
Answers
footrace-flutter/lib/data/api/api_client.dart. The package namefootrace_fluttercomes frompubspec.yaml:1, andpackage:footrace_flutter/maps tolib/.- No; the underscore makes it private to
units.dart. Yes; privacy in Dart is per library (file), not per class. - No.
String.fromEnvironmentis evaluated at compile time from--dart-define, in aconstcontext (app_config.dart:41-48). You need a full restart with the new flag, which rebuilds. - A tear-off: the function itself, not its result. Wrapping it in
clockProviderlets tests override “now” with a fixed time, so countdowns and the activity feed can be tested without the wall clock. 5.0, adouble:/always returns a double.5000 ~/ 1000gives theint5, as_clockdoes atunits.dart:66-67.
Day 2: Widgets and the tree reads the root widget, the theme and one real screen.