15 — Flutter Implementation Guide (Feature Flags Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. FeatureFlagsRepository (single)
- 5. Navigation
- 6. Gating (the module's cross-cutting deliverable)
- 7. Theme
- 8. Extensions
- 9. Localization keys
- 10. Testing
- 11. Performance
- 12. Proposals flagged to the team
How to build the Feature Flags feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. Backend surface is complete (
src/modules/feature-flags/**).
1. Folder structure
lib/features/feature_flags/
├── domain/
│ ├── models/
│ │ ├── flag_doc.dart # key, enabled, label?, description?, module?, version, createdAt, updatedAt
│ │ └── flag_group.dart # module? + flags (null module = "Ungrouped")
│ └── gating/
│ └── flag_gate.dart # FlagGate widget + registry (see §6)
├── data/
│ ├── dto/
│ │ └── update_flag_dto.dart # key, enabled, label?, description?, module?
│ └── repositories/
│ └── feature_flags_repository.dart
└── presentation/
├── cubit/
│ ├── feature_flags_cubit.dart # GLOBAL gating cubit (app-level, not feature-scoped)
│ ├── flags_list_cubit.dart
│ ├── flag_detail_cubit.dart
│ ├── flag_editor_cubit.dart
│ └── bulk_update_cubit.dart
├── pages/
│ ├── flags_list_page.dart
│ ├── flag_detail_page.dart
│ └── flag_editor_page.dart
└── widgets/
├── flag_row.dart
├── flag_key_chip.dart
├── flag_group_header.dart
├── flag_editor_form.dart
├── bulk_update_sheet.dart
└── affected_map_card.dart
FeatureFlagsCubit is registered in the app-level get_it (single instance) so any
module can gate; screen cubits live in this feature folder.
2. Dependencies
flutter_bloc, dio (AppDio), go_router, get_it, intl. No new packages — switches,
chips, sheets are Material 3 core.
3. Cubits
FeatureFlagsCubit(§13): state{status, enabledKeys, flags, lastUpdated}; API:refresh(),isEnabled(String key),forceRefresh(). Timer-driven TTL 30 s(proposed); subscribe toConnectivityCubitfor refresh-on-reconnect; reset on tenant switch (listen toAuthCubit).FlagsListCubit: filters (module, enabledOnly), groups, per-row pending/error, selection mode for bulk;Toggle(key, value)→ optimistic + rollback;Delete(key).FlagDetailCubit: Load/Toggle/Delete/Save.FlagEditorCubit: create vs edit; on submit, keepnotSavedflags for description/module (server drops them —feature-flag.repository.ts:40).BulkUpdateCubit: Apply → sequential results; RetryFailed subset.
4. FeatureFlagsRepository (single)
class FeatureFlagsRepository {
// throws ApiException(code,status)
Future<List<FlagDoc>> list({String? module}); // GET /feature-flags[?module=]
Future<List<FlagDoc>> listEnabled(); // GET /feature-flags/enabled
Future<FlagDoc> byKey(String key); // GET /feature-flags/:key
Future<FlagDoc> upsert(UpdateFlagDto dto); // PUT /feature-flags
Future<List<FlagDoc>> bulkUpsert(List<UpdateFlagDto> dtos); // PUT /feature-flags/bulk
Future<void> remove(String key); // DELETE /feature-flags/:key
}
// org overlay (settings module or here):
Future<Map<String, bool>> orgFlags(String orgId); // GET /organizations/:id/feature-flags
Future<Map<String, bool>> patchOrgFlags(String orgId, Map<String, bool> merged); // PATCH …
DTO → model: FlagDoc.fromJson maps FeatureFlagDocument fields exactly
(feature-flag.schema.ts:9-22 + base.schema.ts timestamps); isDeleted docs never arrive
(scoped queries).
5. Navigation
go_router routes (proposed in 04): /settings/feature-flags, :key, :key/edit; guards:
authGuard + permissionGuard('feature-flags.read') (target contract — server doesn't
enforce yet, rbac.guard.ts:29, OQ-5). Bulk = sheet, not route. Deep links:
studylyon://settings/feature-flags?module= and :key.
6. Gating (the module's cross-cutting deliverable)
// shared/gating/flag_gate.dart — used by ANY module
class FlagGate extends StatelessWidget {
const FlagGate({required this.key, required this.builder, this.fallback});
final String key; final Widget Function() builder; final Widget? fallback;
Widget build(context) {
final flags = context.watch<FeatureFlagsCubit>();
switch (flags.status) {
case initial: case loading: return fallback ?? const AppSkeleton.rect();
default: return flags.isEnabled(key) ? builder() : (fallback ?? const SizedBox.shrink());
}
}
}
- Fail-closed:
isEnabledreturns false for missing keys (feature-flags.service.ts:24mirrored); duringloadingrender a skeleton or nothing — never a flash of the feature, never a flash of a "disabled" empty state. - Fallback pattern for admin docs:
FlagGate(key:'channels.sms', fallback: AppBanner(...)). - Registry (proposed, for
AppAffectedMapCard):const kFlagScreens = { 'channels.sms': [ScreenRef('SMS compose', '/messages/sms')], … }— static list, reviewed by product.
7. Theme
No new tokens; component mappings in 11_Design_System_Mapping.md all reference existing
AppTokens (00-shared/02). Switches follow M3 defaults with primary active.
8. Extensions
String.dotToSpaces()/ key prettifier for list subtitles(proposed).DateTime.toRelative()for "Updated 2 h ago".bool → Semantics.toggledhelper for switch rows.
9. Localization keys
featureflags.list.*, featureflags.detail.*, featureflags.edit.*,
featureflags.bulk.*, featureflags.gate.hidden ("Ask your admin to enable this feature"),
featureflags.delete.warning (re-create caveat). Server messages mapped to keys for known
404/400 business text; fallback rules per 00-shared/11 §9.
10. Testing
- Unit:
FeatureFlagsCubitstate machine + fail-closedisEnabled+ TTL timer (fake-async);FlagsListCubittoggle rollback;BulkUpdateCubitpartial results; DTO→model mapper (including absentmodule→ "Ungrouped"). - Widget: list filter/toggle/selection; editor warning banner; bulk results;
FlagGatethree states (loading → shown / hidden). - Golden: components × light/dark × 3 sizes (
00-shared/10 §9). - Integration: admin toggle → teacher gated screen updates within TTL (mock server); offline gating stays fail-closed.
- E2E (P0): admin browse → toggle → bulk → delete; teacher gating journey.
11. Performance
ListView.builderfor groups (flatten groups → index);constconstructors; selector (BlocSelector) onenabledKeysso gating rebuilds are scoped to watching widgets.- TTL timer pauses in background (app lifecycle) and fires on resume; no timers leaking in tests.
- List small (tenant catalogs are tens of rows) — no virtualization beyond builder.
12. Proposals flagged to the team
- When the server enforces
feature-flags.*(OQ-5), wirepermissionGuards — UI is already permission-shaped. - When the Redis flag cache + invalidation land (
CACHE_ARCHITECTURE.md:33,48, OQ-7), enable theAppFlagToggleBanner"~30 s" copy and drop the client TTL to match. - When
description/modulepersist (OQ-3) and rollout fields exist (OQ-6), extend the editor form (one-line enablement per 08 §1) + percentage/audience controls. - When a WS
featureflag.changedtopic exists (OQ-7), subscribe inFeatureFlagsCubitand bypass the TTL. - Analytics wiring waits the shared
AnalyticsServiceinterface (00-shared/10 §8).