Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13 — State Management (Feature Flags Module)

Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) plus the module-wide FeatureFlagsCubit — the global gating state every other module consumes (00-shared/06 §4: "FeatureFlagsCubit gates UI per tenant (biometric, SMS, WhatsApp channels)"; also referenced at auth/13 §9: "FeatureFlagsCubit not used (auth module runs pre-feature-gate)"). Backed by FeatureFlagsRepository (dio) hitting the endpoints in 12_API_Mapping.md.


1. FeatureFlagsCubit (global gating — owns the enabled set)

stateDiagram-v2
    [*] --> initial
    initial --> loading : boot / app resume / TTL tick
    loading --> loaded : GET /feature-flags/enabled 200
    loading --> staleError : 401 (refresh fail → sessionExpired) / 5xx
    staleError --> loading : retry / next tick (keep last-good set)
    loaded --> refreshing : pull-to-refresh / TTL expiry
    refreshing --> loaded : 200 (set replaced)
    loaded --> refreshing : admin toggled a flag (admin flow)
    loaded --> loaded : set unchanged
  • State: FeatureFlagsState { status, enabledKeys: Set<String>, moduleIndex: Map<String, Set<String>>, flags: Map<String, FlagMeta>, lastUpdated }.
  • Data source: GET /feature-flags/enabled (feature-flags.controller.ts:30-34, feature-flag.repository.ts:28-30) → keys of enabled:true docs. The admin surfaces also fetch full docs (GET /feature-flags, :key) — the cubit keeps a light FlagMeta mirror (key, enabled, module, label) so gated widgets can show labels without an extra fetch.
  • Synchronous gate API: bool isEnabled(String key)enabledKeys.contains(key)fail-closed: missing key = disabled, mirroring server flag?.enabled ?? false (feature-flags.service.ts:24). No async at widget build time.
  • TTL (proposed, mirrors blueprint): refresh every 30 s ((proposed) client value aligned with CACHE_ARCHITECTURE.md:48 30 s poll; server cache is (planned)) while app foregrounded; also refresh on app.resume, on connectivity restore, and on RefreshIndicator (bypasses TTL). When offline: keep last-good set + AppOfflineBanner (reads for gating continue from memory; writes blocked).
  • Realtime: no WS topic for flags exists (00-shared/07 §8); (planned) topic featureflag.changed (user room) once a domain event exists (OQ-7) — until then TTL polling is the propagation mechanism. On any admin toggle (list/detail), the cubit optimistically updates its own set on 200 and schedules an immediate refresh.
  • Registry (proposed): static map key → List<ScreenRef> in the app (used by AppAffectedMapCard, 07/05 §5). Used only for the affected-features map; gating itself needs no registry — any widget calls isEnabled(key).

2. Per-screen Cubits

ScreenCubitEvents → State
ListFlagsListCubitLoad, LoadAll, LoadModule(m), LoadEnabledOnly(bool), Refresh, Toggle(key, enabled), EnterSelection, ToggleSelect(key), SelectAll, ClearSelection, Delete(key){listState: LoadState, filters{module, enabledOnly}, groups, rows{key: {doc, pending, error}}, selection{Set<key>, mode}}
DetailFlagDetailCubitLoad(key), Toggle(enabled), Delete(), Save(dto), Refresh{LoadState, flag?, pending, deleted}
EditorFlagEditorCubitInit(flag?, createMode), Submit(dto){form, saving, fieldErrors, saved(doc), notSaved: {description, module}}
BulkBulkUpdateCubitApply(keys, state, label?), RetryFailed(), Dismiss() → `{applying, progress, results: Map<key, ok
Org overlay (context)OrgFlagsCubitLoad(orgId), Patch(mergedMap){LoadState, map, saving} — merge-first discipline (organizations.service.ts:149-151)
  • Loading/caching: list caches last-good per filter key (sl:cache:featureflags:{module} (proposed)); RefreshIndicator bypasses cache (00-shared/06 §3.3). Detail: no cache (server truth; 404 handling).

3. State objects (concise)

class FeatureFlagsState {
  FeatureFlagStatus status;            // initial | loading | loaded | staleError
  Set<String> enabledKeys;             // gate set (fail-closed)
  Map<String, FlagMeta> flags;         // key → meta (module, label, enabled)
  DateTime? lastUpdated;
}

class FlagMeta { String key; bool enabled; String? label; String? module; }

class FlagDoc extends FlagMeta {
  String? description; String? module; int version;
  DateTime createdAt, updatedAt;
  // maps FeatureFlagDocument 1:1 (feature-flag.schema.ts:9-22 + base.schema.ts:10-34)
}

class FlagGroup { String? module; List<FlagDoc> flags; } // null module → "Ungrouped"

class BulkResult { String key; bool ok; String? reason; }

4. Events & actions map (UI → Cubit → API)

UI eventCubit methodRepository call
app boot / resume / TTLflagsCubit.refresh()GET /feature-flags/enabled
screen open (admin)list.load()GET /feature-flags or ?module= / /enabled
row switch fliplist.toggle(key, v)PUT /feature-flags {key, enabled:v, label}
detail toggledetail.toggle(v)PUT /feature-flags …
editor saveeditor.submit(dto)PUT /feature-flags
bulk applybulk.apply(keys, state, label)PUT /feature-flags/bulk (array)
bulk retry failedbulk.retryFailed()PUT /feature-flags/bulk (failed subset)
delete confirmlist.delete(key) / detail.delete()DELETE /feature-flags/:key
org overlay saveorgFlags.patch(merged)PATCH /organizations/:id/feature-flags

All through FeatureFlagsRepository; widgets never call dio (00-shared/06 §2).

5. Caching & refresh

  • FeatureFlagsCubit: in-memory enabled set (single source for gating); no persistence — on cold boot it refetches before first gate decisions; until loaded, UI shows skeletons in gated areas (never a wrong "disabled" flash — gate states are loading|loaded).
  • List: last-good per filter; detail: none; editor/bulk: none.
  • TTL 30 s (proposed); RefreshIndicator and admin toggle success force immediate refresh.

6. Realtime

  • No realtime today. (planned): WS topic featureflag.changed to user rooms → FeatureFlagsCubit applies the new state immediately (subscribes via repository subscribe(channel) per 00-shared/06 §3.4). Until then, worst-case propagation = client TTL (30 s) + server cache TTL (30 s (planned) CACHE_ARCHITECTURE.md:48).

7. Error states per action

ActionErrorState →
gating refresh401refresh → sessionExpired (gates keep last-good during refresh)
gating refresh5xxstaleError; keep last-good; retry next tick — never gate on error
toggle5xx/400rollback + row error
save400fieldErrors
save404"flag removed" banner → reopen list
bulk400/5xxpartial results + retry
delete404treat-as-removed
org patch404org missing → settings error

8. Testing hooks (00-shared/06 §6)

  • Pure-Dart cubits: FeatureFlagsCubit state machine (initial→loading→loaded→staleError), fail-closed isEnabled, TTL timer, merge-on-admin-toggle.
  • Widget tests: list filter/toggle/rollback; bulk partial results; gated widget appears/ disappears on set change (fake async TTL).

9. Cross-cutting interplay

  • ConnectivityCubit: offline → gating uses in-memory set, admin writes blocked; on reconnect → immediate refresh.
  • AuthCubit: on login/tenant switch → FeatureFlagsCubit reset + load for the new tenant; on sessionExpired → clear set.
  • Other modules: biometric/notifications/payments screens call flagsCubit.isEnabled(key) to hide/show features — no consumer exists in the backend today (OQ-1); the cubit is the client-side counterpart of the server's isEnabled() primitive (feature-flags.service.ts:22-25) and of the roadmap's useFeatureFlags() hook (IMPLEMENTATION_PLAN.md:810-817, (planned)).
  • Permission changes (00-shared/06 §3.6) rebuild admin routes; flag state is orthogonal.