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.dart—ContestPolicy.validateReason(graphemes)with 10–2000 bounds usingpackage:charactersapplication/contest/contest_draft.dart— draft persistence viaContestDraftstableapplication/contest/charge_concurrency.dart—expectedStateEpoch + expectedVersionplumbingapplication/ports/charge_ports.dart—ChargePortsabstract repopresentation/pages/charge_detail_page.dart— watchesLocalStore.chargesStream
Shared kernel
lib/shared/domain/ids.dart — typed IDs (BookingId, ChargeId, InspectionId, …). StorageNamespace — fixture 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.dartreplaysassets/fixtures/app_state.jsonand validates against golden examples.anchored_demo_clock.dart/persisted_demo_clock.dart+server_adjusted_clock.dartgive deterministicnowfor deadline tests.AppConfig.fixtureDefaults()avoids env parsing in widget tests.
Invariants
- No
print,TODO, dead code;flutter analyze+dart format --set-exit-if-changedare 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 directrouter.go.