State Management
Stack: bloc 9.1 + flutter_bloc, equatable, global AppBlocObserver for logging.
Cubits (app-scoped)
| Cubit | State | Trigger | Persisted |
|---|---|---|---|
BookingCubit | {selectedBookingId, hubTab} | booking selector, hub tab toggle | SharedPreferences via PreferencesService |
ThemeCubit | `ThemeMode.system | light | dark` |
LocaleCubit | `Locale('en') | Locale('cy')` | settings / header |
ConnectivityCubit | `online | offline` | connectivity_plus + navigator.onLine web |
SyncCubit | `idle | syncing | error(message)` |
NotificationsCubit | {unreadCount, items} | LocalStore.notificationsStream | Drift derived |
Each cubit is hydrated from PreferencesService or Drift on boot and writes back on change; AppBlocObserver.onChange mirrors to maybeLogBlocTransition when ENABLE_ANALYTICS_LOG=true.
Feature blocs
Features keep local bloc/cubit per screen when async work stays in the page:
- Hub —
MaintenanceHubCubitsubscribes toLocalStore.hubSnapshotStream(bookingId)+store.chargesStream+tasksStream; derivesOpenvsHistorylists viaChargeStatuspolicy identical tocharge_state.py. - Inspection —
InspectionCubitsubscribes to singleinspectionStream. - Charge —
ChargeDetailCubitwatcheschargeStream(id);Accept/Contestcommands go throughChargePorts→SyncServiceoutbox →LocalStoreoptimistic apply. - Contest —
ContestCubitholdsContestDraft{reason, attachments}withcontest_policy.validateReason(graphemes)debounced;contest_draft.dartautosaves toContestDraftstable so reload survives. - Statement —
StatementCubitcomputesMap<currency, amountMinor>totals over unpaid charges; mock Pay writesstatus=paidviaChargePorts.pay.
Data flow for a mutation
sequenceDiagram
participant User
participant Page as ChargeDetailPage
participant Bloc as ChargeDetailCubit
participant Ports as ChargePorts
participant Outbox
participant Drift as LocalStore
participant Api as KxApi
User->>Page: tap Accept
Page->>Bloc: accept(chargeId)
Bloc->>Ports: accept(chargeId, expectedEpoch, version)
Ports->>Outbox: enqueue ChargeOperation.accept
Ports->>Drift: optimistic mark accepted (syncState=pending)
Drift-->>Page: stream emits accepted snapshot
Outbox->>Api: POST /charges/{id}/accept (when online, with Idempotency-Key)
alt 2xx or idempotent replay
Api-->>Outbox: CommitResult
Outbox->>Drift: mark synced, apply server version
else 409/422/429
Api-->>Outbox: error
Outbox->>Drift: rollback or keep pending per policy + RetryPolicy
end
Drift-->>Page: stream re-emits authoritative charge
Offline queueing: Accept/Contest never throw Synchronously; if ConnectivityCubit=offline or DioException, the row stays pending and RetryPolicy (exponential with jitter) retriggers on online.
Concurrency
charge_concurrency.dart captures expectedStateEpoch + expectedVersion at interaction time; RetryPolicy re-reads latest snapshot on store.epoch_mismatch before re-issuing with a new Idempotency-Key (payload changed epoch → new digest).
Analytics
analytics_store.dart + analytics_sinks.dart + analytics_validator.dart define the typed event catalog (mock.charge.accept, mock.charge.contest, route.view, etc.); AppBlocObserver and AnalyticsRouteObserver are the two producers. ENABLE_ANALYTICS_LOG=true prints to debugPrint.
Immutability
All states extend Equatable; freezed is available for union types but cubits stay lean — copyWith per state; tests use bloc_test:expect sequence assertions.
Testing
test/app/cubit/*+test/features/*/application/*covertransition, deadline, contest gating, outbox drain, and optimistic rollback.- Widget tests observe via
BlocBuilder+pumpAndSettle;MocktailmocksKxApibut notLocalStore.
Rule of thumb: if a widget needs
BlocListenerto do navigation or show aSnackBar, keep that bloc at the page level; if multiple pages share state, lift it toapp/cubit.