Skip to main content

State Management

Stack: bloc 9.1 + flutter_bloc, equatable, global AppBlocObserver for logging.

Cubits (app-scoped)

CubitStateTriggerPersisted
BookingCubit{selectedBookingId, hubTab}booking selector, hub tab toggleSharedPreferences via PreferencesService
ThemeCubit`ThemeMode.systemlightdark`
LocaleCubit`Locale('en')Locale('cy')`settings / header
ConnectivityCubit`onlineoffline`connectivity_plus + navigator.onLine web
SyncCubit`idlesyncingerror(message)`
NotificationsCubit{unreadCount, items}LocalStore.notificationsStreamDrift 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 — MaintenanceHubCubit subscribes to LocalStore.hubSnapshotStream(bookingId) + store.chargesStream + tasksStream; derives Open vs History lists via ChargeStatus policy identical to charge_state.py.
  • Inspection — InspectionCubit subscribes to single inspectionStream.
  • Charge — ChargeDetailCubit watches chargeStream(id); Accept/Contest commands go through ChargePortsSyncService outbox → LocalStore optimistic apply.
  • Contest — ContestCubit holds ContestDraft{reason, attachments} with contest_policy.validateReason(graphemes) debounced; contest_draft.dart autosaves to ContestDrafts table so reload survives.
  • Statement — StatementCubit computes Map<currency, amountMinor> totals over unpaid charges; mock Pay writes status=paid via ChargePorts.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/* cover transition, deadline, contest gating, outbox drain, and optimistic rollback.
  • Widget tests observe via BlocBuilder + pumpAndSettle; Mocktail mocks KxApi but not LocalStore.

Rule of thumb: if a widget needs BlocListener to do navigation or show a SnackBar, keep that bloc at the page level; if multiple pages share state, lift it to app/cubit.