Skip to main content

Frontend Architecture

Layer cake

UI (features/*/presentation) // widgets, pages, _view.dart files
│ uses application facades + cubits

Application (features/*/application) // contest_policy, contest_draft, charge_concurrency,
// ports (ChargePorts), use-cases
│ depends on core ports

Core (lib/core/*) // config, time, network, sync, database, analytics
│ implements ports, exposes streams

Data (lib/core/database, lib/core/network)
// Drift local_store, fixture_http_adapter, kx_api
│ reads/writes

Platform (lib/core/platform, storage) // uuid, random, preferences, attachment bytes

Forbidden imports are enforced by test/architecture/*design_system never imports features, features never imports core/database directly without a port.

graph TD
Router[router/app_router.dart\n+ feature_slots] --> Shell[kx_app_shell]
Shell --> Features[features/*]
Features --> AppCubits[app/cubit/*]
Features --> CoreSync[core/sync\nSyncService + Outbox + LiveSync]
CoreSync --> LocalStore[(Drift\nlocal_store + kx_database)]
CoreSync --> KxApi[kx_api\nDio or fixture adapter]
KxApi --> Backend[(FastAPI)]
KxApi --> Fixtures[(assets/fixtures)]
DesignSystem[design_system] -. tokens .-> Features

Composition root

bootstrap.dart:bootstrap() is the only place that touches PlatformDispatcher:

final config = AppConfig.parse(environmentValues); // throws AppConfigException on bad input
await registerDependencies(config); // GetIt + Drift open + fixture bundle load
runApp(KxApp(router: createAppRouter(...)));

registerDependencies wires DiModule chain: FixtureFeatureModule or RemoteFeatureModule registers ChargeRepository, TaskRepository, etc. Switching DATA_SOURCE swaps the KxApi adapter alone.

Feature slicing

Each feature is vertical:

lib/features/<feature>/
application/ // pure Dart: policy, draft, ports interface
data/ // repository impl, dtos
presentation/ // widgets + pages + bloc/cubit

Example charge:

  • application/contest/contest_policy.dartContestPolicy.validateReason(graphemes) with 10–2000 bounds using package:characters
  • application/contest/contest_draft.dart — draft persistence via ContestDrafts table
  • application/contest/charge_concurrency.dartexpectedStateEpoch + expectedVersion plumbing
  • application/ports/charge_ports.dartChargePorts abstract repo
  • presentation/pages/charge_detail_page.dart — watches LocalStore.chargesStream

Shared kernel

lib/shared/domain/ids.dart — typed IDs (BookingId, ChargeId, InspectionId, …). StorageNamespacefixture vs remote(apiBaseUrl) fingerprint so two datasources never collide. lib/core/config/contract_version.dart — frozen 1.

Drift database

kx_database.dart defines tables Bookings, InventoryReports, Inspections, Tasks, Charges, ChargePhotos, ContestDrafts, AttachmentBlobs, OutboxRows, SyncMeta… via @DataClassName. kx_database.g.dart is generated (525 kB). local_store.dart is the transactional façade; repository_stream.dart exposes broadcast Stream<List<T>> per entity.

Sync seam

SnapshotCoordinator seeds Drift from fixture_bundle.dart or GET /sync-snapshot; SyncService drains Outbox (Accept/Contest tasks persisted with syncState=pending/synced), LiveSyncService subscribes to GET /events via SseEntityCommitter, with SyncLeadership ensuring one tab commits.

Testing seams

  • fixture_http_client_adapter.dart replays assets/fixtures/app_state.json and validates against golden examples.
  • anchored_demo_clock.dart / persisted_demo_clock.dart + server_adjusted_clock.dart give deterministic now for deadline tests.
  • AppConfig.fixtureDefaults() avoids env parsing in widget tests.

Invariants

  • No print, TODO, dead code; flutter analyze + dart format --set-exit-if-changed are gates.
  • Every user string is in ARB; no hardcoded prose in design_system.
  • Every route is named in KxRoutes; deep link /charges/{id} + /charges/{id}/contest + /charges/{id}/photo/{i} is tested via direct router.go.