Data Layer
Two adapters, one store
graph LR
Widget --> LocalStore[(Drift LocalStore)]
LocalStore --> KxApi
KxApi --> FixtureAdapter[FixtureHttpClientAdapter\nassets/fixtures/app_state.json]
KxApi --> DioAdapter[Dio + KxApi (HTTP)]
DioAdapter --> Backend[(FastAPI /api/v1)]
DataSource.fixture | remote is chosen once at bootstrap; only the KxApi binding swaps — Drift, repositories and widgets stay identical.
Drift schema
lib/core/database/kx_database.dart defines:
| Table | Key | Indexes |
|---|---|---|
Bookings | id PK | — |
InventoryReports | id PK | bookingId FK |
Inspections | id PK | bookingId |
Tasks | id PK | bookingId, syncState (`synced |
Charges | id PK | bookingId, inspectionId |
ChargePhotos | id PK | chargeId FK |
ContestDrafts | chargeId PK | — |
AttachmentBlobs | id PK | — |
OutboxRows | id PK | createdAt |
SyncMeta | singleton | stateEpoch, storeRevision, streamEpoch, streamCursor |
kx_database.g.dart (525 kB) is generated; DataClassName('BookingRow') etc. control naming. FKs carry KeyAction.cascade so deleting a booking drops its reports/inspections/tasks/charges.
LocalStore façade
lib/core/database/local_store.dart (1.1 kLOC) exposes:
Stream<List<BookingRow>> bookingsStream(),hubSnapshotStream(bookingId),chargesStream(bookingId),inspectionsStream,tasksStream,notificationsStreamFuture<void> applySnapshot(SyncSnapshotData + SyncMeta)— transactional replace ofBookings/...Future<void> applyRemoteChanges(List<StoredEvent> batch)— incremental patch from SSEFuture<void> enqueueOutbox(OutboxRow)+drain()hooks
domain_mapper.dart (21 kLOC) maps between Drift companions and domain types with strict validation mirroring StrictModel.
Fixture bundle
lib/core/database/fixture_bundle.dart loads assets/fixtures/app_state.json (canonical HubSnapshot-like aggregate) + manifest.json (per-file SHA-256) + media/ + vectors/. verify_fixture_manifest.py-equivalent logic runs at build: mismatch aborts gen-l10n/build_runner.
KxApi
lib/core/network/kx_api.dart:
abstract class KxApi {
Future<Envelope<HealthData>> health();
Future<Envelope<HubSnapshot>> hub(String bookingId);
Future<Envelope<List<Charge>>> charges({String? bookingId, List<String>? status});
Future<Envelope<Charge>> charge(String id);
Future<Envelope<Charge>> accept({required String chargeId, required String expectedStateEpoch, required int expectedVersion, required String idempotencyKey});
Future<Envelope<Charge>> contest({required String chargeId, required ContestMetadata metadata, required List<AttachmentBlob> attachments, required String idempotencyKey});
Stream<SseFrame> events({String? lastEventId});
}
Impls:
- Fixture adapter (
fixture_http_client_adapter.dart) — replays bundle bytes, fabricatesstateEpoch/storeRevisiondeterministically, writes to Drift directly. - Dio adapter — real HTTP;
failure_mapper.dartmapsDioException→ typedKxFailure;api_models.dartisjson_serializablewire DTOs.
Both sides attach Idempotency-Key: uuid for commands; exception_mapper.dart normalizes contract errors to union.
Persistence demos
- Contest draft autosave:
ContestCubitdebouncesreasonedits and upsertsContestDrafts(chargeId, reason, updatedAt)so reload restores the field. - Optimistic Accept/Contest:
LocalStoreimmediately flipsstatus+ version; on409 epoch_mismatchthe optimistic layer rolls back andSyncServicefetches fresh snapshot. - Attachment bytes:
AttachmentBlobsholds staged bytes untilOutboxdrains viaPOST .../contest.
Config seam
storage_namespace.dart:StorageNamespace.fixture | remote(Uri) fingerprints apiBaseUrl so a fixture run and a remote(http://127.0.0.1:8000) run never pollute each other's Drift file. database_connection.dart picks file name from it.
Testing
test/core/database/local_store_test.dartasserts streams emit afterapplySnapshot;domain_mapper_testround-trips each entity.- Repository contract tests run against both adapters with same expectations.