Skip to content

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.

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.

Open app/pubspec.yaml. It is the package.json, Gemfile or mix.exs of the app, in YAML.

name: footrace_flutter
description: Footrace mobile app. Live 1v1 running races against the Phoenix backend.
publish_to: none
version: 1.0.0+1
environment:
sdk: ^3.0.0

That is app/pubspec.yaml:1-8. Three things matter here:

  • name is the package name, and it becomes the root of every package: import. lib/core/units.dart is importable from anywhere as package:footrace_flutter/core/units.dart. Note that lib/ disappears from the path: package:footrace_flutter/ points at lib/.
  • publish_to: none stops dart pub publish from ever uploading it to pub.dev. It is an app, not a library.
  • version: 1.0.0+1 is two numbers in one: 1.0.0 is the user-visible version (CFBundleShortVersionString / versionName), +1 the 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.

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 ,.

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:

  1. WidgetsFlutterBinding.ensureInitialized() (app/lib/main.dart:10) wires the Dart side to the engine. You need it before touching a platform plugin outside of runApp.
  2. installErrorHandlers() (app/lib/main.dart:11) routes uncaught errors to the logger. You will read it in a minute.
  3. FlutterForegroundTask.initCommunicationPort() (app/lib/main.dart:14) opens the channel the GPS isolate uses to send fixes back. CLAUDE.md says this must run before anything else; Day 9 explains why.
  4. 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”.

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). required makes 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.apiBaseUrl in the parameter list assigns the field directly, like a Kotlin primary constructor val.
  • The initializer list after the colon (: _fakeGps = fakeGps, line 15) runs before the body. It is where you set final fields that are not straight this. copies.
  • final fields are set once. Combined with a const constructor, the whole object can be a compile-time constant.
  • The underscore. _fakeGps is private, but private to the library (the file), not to the class. Any code in app_config.dart can read _fakeGps on any AppConfig; code in other files cannot see it at all. This is the honest break from Kotlin and Swift private, which are type-scoped. There is no public keyword: no underscore means public.
  • Getters. bool get fakeGps => ... (line 29) is a computed property, the Swift var 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.

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.

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.

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';
}
  • num is the supertype of int and double. Passing 5000 or 21097.5 both work.
  • meters / 1000 is always a double in Dart, even for two ints. Integer division is its own operator, ~/, used in _clock at lines 66-67: total ~/ 3600. Go and Kotlin readers will want / to truncate; it does not.
  • static const _metersPerMile is 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.

1. Run one test file. From footrace-flutter:

Terminal window
flutter test test/core/units_test.dart

You 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:

Terminal window
flutter test test/features/activity/activity_test.dart

Note 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.

  1. You see import 'package:footrace_flutter/data/api/api_client.dart';. Which file on disk is that?
  2. A field in units.dart is called _metersPerMile. Can leaderboard_screen.dart read it? Can another class in units.dart?
  3. You change API_BASE_URL in your run configuration and hot-reload. Does the app talk to the new URL? Why or why not?
  4. What does DateTime.now (no parentheses) mean in clock.dart, and why does the app pass it around instead of calling DateTime.now() everywhere?
  5. What does 5000 / 1000 evaluate to in Dart, and which operator gives you 5 as an int?
Answers
  1. footrace-flutter/lib/data/api/api_client.dart. The package name footrace_flutter comes from pubspec.yaml:1, and package:footrace_flutter/ maps to lib/.
  2. No; the underscore makes it private to units.dart. Yes; privacy in Dart is per library (file), not per class.
  3. No. String.fromEnvironment is evaluated at compile time from --dart-define, in a const context (app_config.dart:41-48). You need a full restart with the new flag, which rebuilds.
  4. A tear-off: the function itself, not its result. Wrapping it in clockProvider lets tests override “now” with a fixed time, so countdowns and the activity feed can be tested without the wall clock.
  5. 5.0, a double: / always returns a double. 5000 ~/ 1000 gives the int 5, as _clock does at units.dart:66-67.

Day 2: Widgets and the tree reads the root widget, the theme and one real screen.