Reference documentation for contributors. For agent rules and day-to-day commands, see AGENTS.md. For end-user information and how to clone/run the app, see README.md. For the rationale behind specific architectural choices, see the Architecture Decision Records in adrs/.
RingDrill is a Flutter application for planning, synchronizing and running station-based drills (ring exercises) used in tactical, emergency and operational training. The repo contains:
- The Flutter app under
lib/(Android, iOS, web/PWA, macOS, Linux, Windows targets). - A Dart admin CLI under
bin/ringdrill.dart, published as theringdrillexecutable viapubspec.yaml. - A small Netlify backend under
netlify/functions/(Node.js) that hosts drill file storage, deep links and a market feed, served atapi.ringdrill.app. - A static Astro site under
site/(the public site atringdrill.app), deployed to Cloudflare Pages. - A Cloudflare Worker under
workers/apex-proxy/that reverse-proxies the dynamic apex paths (/api,/d,/i,/brief,/.netlify/functions) to the API subdomain, because Cloudflare Pages cannot 200-proxy to an external origin. See ADR-0039. - Generated localization, freezed and JSON serialization code (do not edit by hand).
Owner: DISCOOS (github.com/DISCOOS/ringdrill). Distribution channels: Google Play (Android, via Shorebird), Apple App Store (iOS), and the web PWA. Public origins are split per ADR-0039: the site on ringdrill.app and the PWA on web.ringdrill.app (both Cloudflare Pages), and the API on api.ringdrill.app (Netlify functions, Cloudflare-proxied). The dynamic apex paths are reverse-proxied to the API by the workers/apex-proxy/ Worker.
- Flutter SDK
^3.8.0, Dart 3 with sealed classes. - Code generation:
freezed,json_serializable,build_runner. - Routing:
go_router(entry pointbuildRouterinlib/views/main_screen.dart). - State: plain
ChangeNotifier/streams plusshared_preferencesfor persistence. No Bloc, Riverpod or Provider. - Maps:
flutter_mapwithlatlong2,osm_nominatimfor geocoding,proj4dartfor UTM projection. - Telemetry:
sentry_flutter, opt-in only (see consent handling inlib/main.dart). - Local notifications:
flutter_local_notifications(non-web only). - OTA updates: Shorebird (
shorebird.yaml,shorebird_code_push). - Drill files: custom zipped format with MIME
application/vnd.ringdrill+zip, extension.drill. Seelib/data/drill_file.dart.
lib/
main.dart app bootstrap, themes, Sentry/consent gating
data/ drill file format + HTTP client + repository
data/source/ DESIGN-014 source format: field table, parser,
builder, decompiler, analyzer, JSON Schema, scaffold
models/ freezed/JSON models (program, exercise, station, team)
services/ long-lived runtime services (exercise, notifications, program, file channel)
views/ all UI screens and widgets (flat folder, no feature grouping)
web/ web-only widgets and PWA update handling
utils/ pure-Dart helpers (projection, time, config, sentry)
l10n/ .arb sources + generated AppLocalizations
bin/ringdrill.dart admin CLI + the source-format commands
(create/build/decompile/analyze/render/schema)
mcp/ MCP server (stdio) and the tool table both transports share
skills/ agent skills (widget preview, plan authoring)
netlify/functions/ Node.js backend (drill upload/head, deep links, admin, market feed,
the hosted MCP endpoint) — api.ringdrill.app
site/ static Astro site (ringdrill.app), deployed to Cloudflare Pages
workers/apex-proxy/ Cloudflare Worker reverse-proxying dynamic apex paths to the API (ADR-0039)
test/ Flutter and pure-Dart tests
assets/ app icons, splash images
android/, ios/, macos/, platform projects
linux/, windows/, web/
Conditional imports follow the standard pattern, e.g. import 'package:foo/x.dart' if (dart.library.io) 'package:foo/x_io.dart';. Web-only code lives under lib/web/ with stub counterparts (e.g. pwa_update_stub.dart vs pwa_update_web.dart).
Domain vocabulary and the English-vs-Norwegian naming rule live in glossary.md.
- Every model in
lib/models/is@freezed sealed class X with _$X. Add new models the same way and runmake build. - Each model has
fromJson/toJsonviajson_serializable. Do not add custom serializers unless absolutely needed. - Behavior on models is added via Dart extensions (
extension ExerciseX on Exercise { ... }), not by inheritance or methods inside the freezed class. - Use the project's own
SimpleTimeOfDay(inlib/models/exercise.dart) instead of Flutter'sTimeOfDaywhenever the value crosses serialization or non-Flutter (CLI, isolate) boundaries.TimeOfDayitself is not JSON-serializable.
- Services are long-lived singletons constructed in
lib/main.dart(e.g.ProgramService().init()). Keep them framework-free (noBuildContext) and expose streams/ValueNotifiers for UI. ProgramService.eventsis a fire-on-every-mutation contract. UI surfaces subscribe to the broadcasteventsstream and rebuild on any emission — they do not filter byProgramEventType. So every mutating method must emit an event before returning (guarded onactiveProgram != null), or dependent widgets go stale. ReuseprogramRefreshedwhen no specific type fits (as the reorder methods do). Entity edits that persist throughsaveExercise/replacePrograminherit those events; a method that writes via_repodirectly (e.g.deleteRolePlay,saveActor) must emit its own.- Consumer side: long-lived detail viewers subscribe too. List views already do. Any screen that caches an entity (
_exercise,_rolePlay, a team read inbuild) and can stay open while that entity is mutated elsewhere —CoordinatorScreen,StationExerciseScreen,RolePlayScreen,TeamScreen— mustlisten(_programService.events, …)(via theSubscriptionBagmixin) and re-read, not rely on localsetStateafter its own actions alone. Otherwise it shows stale data in the wide master/detail layout. NotificationServiceis non-web only and is gated by user preferences fromAppConfig.- New persistent settings keys go in
lib/utils/app_config.dartwith akeyXconstant. Use the prefixapp:<feature>. Append a:v<n>suffix when the value may need a future migration (seekeyIsFirstLaunch = 'app:isFirstLaunch:v1').
- All screens and widgets live directly under
lib/views/. Do not introduce a feature-folder structure without coordinating with the maintainer. - Theming:
ringDrillThemeandringDrillDarkThemeinmain.dartare the source of truth. ReuseTheme.of(context).colorSchemerather than hard-coded colors. - All user-visible strings go through
AppLocalizations.of(context)!.<key>and are defined inapp_en.arbfirst, then translated inapp_nb.arb. Untranslated keys are reported inlib/l10n/untranslated-messages.json(gitignored). - Cross-cutting UI conventions — marker icons, row edit affordances, active-filter visibility, design tokens, map slot props, and form "Save"/"Done" labels — live in
ui-conventions.md.
- Anything that touches
dart:html/package:webmust live underlib/web/behind a conditional import with an io stub. Importingpackage:webdirectly from a file that is also compiled on mobile will break the Android/iOS build.
- Wrap Sentry calls in
if (Sentry.isEnabled)checks. Sentry is only initialized when the user has grantedanalyticsConsent(seelib/main.dart). - Never log PII or drill content to Sentry. Errors only.
Localization files are generated automatically by Flutter (flutter: generate: true in pubspec.yaml, configured via l10n.yaml). After editing lib/l10n/app_en.arb or app_nb.arb, the next flutter run/flutter build/flutter test will regenerate app_localizations*.dart.
l10n.yaml points at lib/l10n/ for ARB sources, writes app_localizations.dart as the entry point, and emits a gitignored untranslated-messages.json to flag missing translations.
flutter test is the canonical command. The suite under test/ has grown to cover models, data/, utils/, services (including the brief renderer and catalog refresh), and a range of views and widgets. Mirror the layout of lib/ under test/ when adding new files.
test/projection_test.dartcoverslib/utils/projection.dart. Keep this passing when you touch projection or UTM code.- The default Flutter counter-app
test/widget_test.darthas been removed. Do not reintroduce it; add realRingDrillApp-level tests instead.
When adding tests, prefer pure-Dart unit tests against models/, data/ and utils/ over widget tests. Widget tests should be added only for non-trivial UI logic.
- Android release builds go through Shorebird (
make release-android). The commentedflutter build appbundleblock in the Makefile is the manual fallback. - iOS release builds go through Shorebird (
make release-ios, withmake patch-iosfor code-push patches), mirroring the Android targets. They run only on a macOS host with Xcode and rely on the signing configured inios/Runner.xcodeproj(DISCOOS team,app.ringdrill, automatic — see ADR-0021). Shorebird drivesflutter build ipaunder the hood. - Web is built by Netlify on every push to the configured branch using
netlify.toml. Theflutter_service_worker.jsandindex.htmlare servedno-cache; everything else underassets/,canvaskit/andmain.dart.jsis immutable. .drillfiles served by Netlify are forced toContent-Disposition: attachmentwith the custom MIME type. Do not change this without also updating the share/import handlers inlib/data/drill_file.dartandlib/views/shared_file_widget.dart.- The Shorebird
app_idinshorebird.yamlis public and safe to commit.sentry.propertiesis gitignored and must not be committed.
The backend runtime, hosting topology (the three ADR-0039 origins) and local dev live in backend.md; the HTTP API reference — endpoints, auth, examples — is in api.md.
The .drill file format and the drill library bundle format live in drill-file-format.md.
A drill plan can be authored as one YAML source document and compiled to a
.drill by the CLI: ringdrill create | build | decompile | analyze | render | schema. The compiler is pure Dart in lib/data/source/, so the app can reuse it
and the CLI stays free of Flutter, and the format is described exactly once — the
field table in source_fields.dart drives all six commands plus the generated
JSON Schema. The contract is that build(decompile(d)) preserves d's
contentHash.
The design is DESIGN-014 with
ADR-0058; legacy normalization
is the migration ladder of
ADR-0059. The agent-facing
deployment is mcp/ plus the
ringdrill-plan-authoring skill, in
two forms: a local stdio server, and a hosted endpoint at /mcp on the API origin
(ADR-0060) that runs the same compiler
cross-compiled to JavaScript by make mcp-bundle. Both read one tool table
(mcp/tools.mjs); only the transport and the backend differ.
A built archive is handed over as a handle, not as bytes in the response
(ADR-0070): a written file locally, and on
the hosted side a short-lived download URL served by netlify/functions/mcp-artifact.js
at /mcp/artifact/<contentHash>.drill. That is a second function on purpose — /mcp
itself stays POST-only and does not load the compiler bundle to move bytes. Both
retention windows the hosted endpoint has (the opt-in document cache of
ADR-0064 and this one) are content-addressed and
expiring, and mcp/README.md → Hosted or local? states exactly what is kept.
Three generated files exist because a caller cannot reach Flutter assets or a Dart
SDK: lib/l10n/headless_labels.g.dart (make labels),
lib/services/brief/brief_templates.g.dart (make templates) and
netlify/functions/lib/mcp-compiler-bundle.js (make mcp-bundle, for the hosted
endpoint — a Netlify build has no Dart SDK). The first two are prerequisites of
make build; all three have tests that fail when the copy drifts from its source.
The brief is a projection of the entities, rendered on demand through a versioned mustache template. The template format, registration and audiences live in template.md; the {{var.*}} and cross-reference tokens an author types inside fields — with their typed values, the copy-chip convention, the resolution pipeline and the resolve-scope model — live in variables.md.
- Bootstrap and theming:
lib/main.dart. - Routing:
buildRouterandMainScreeninlib/views/main_screen.dart. - Domain core:
lib/models/exercise.dart(rotation math is inteamIndex/stationIndexextensions). - Drill timer/phase engine:
lib/services/exercise_service.dart. - File import/export pipeline:
lib/data/drill_file.dartpluslib/services/shared_file_channel.dart. - Backend contract:
netlify.tomlfor routes,netlify/functions/*.jsfor handlers,lib/data/drill_client.dartfor the Dart-side client.
lib/views/is a single flat folder. Keep it that way unless the maintainer asks otherwise.lib/web/program_page_controller.dart,platform_widget.dartandsettings_page.dartshadow files of the same name inlib/views/. Imports pick the right one via conditional import.- The Makefile is intentionally tiny. Most workflows are plain
flutter/dartcommands; the Makefile only wraps the few non-obvious ones (codegen, Shorebird). sentry.propertiesis in.gitignore. The Sentry plugin block inpubspec.yamlreferences it for source upload during release builds. Local builds work without it.untranslated-messages.jsonregenerates on every build. If it shows up ingit status, ignore it.flutter_test's defaultMediaQuerysize (~800×600) reads asWindowSizeClass.medium(hasMasterDetail: true), not compact. A widget test asserting compact-only chrome (a bottom sheet's drag handle,find.byType(BottomSheet)) must pintester.view.physicalSizeexplicitly or it silently exercises the medium/expanded dialog path instead — seeringdrill_picker_test.dartand ADR-0049/ADR-0052.