Skip to main content

flag_override_panel 0.2.0 copy "flag_override_panel: ^0.2.0" to clipboard
flag_override_panel: ^0.2.0 copied to clipboard

A debug-only panel for viewing and overriding feature flags on-device during development and QA, independent of your remote flag provider.

flag_override_panel #

A debug-only panel for viewing and overriding feature flags on-device, independent of whatever serves your flags in production.

Every team that ships feature flags eventually hand-rolls the same debug drawer: a list of flags, a switch next to each one, and a reset button. This is that drawer, as a package. It does not replace Firebase Remote Config, LaunchDarkly or your own backend — it sits in front of them so QA can force a flag on without waiting for a config to propagate.

The override panel beside the app it drives

Why #

  • Force any flag from inside the app. No backend change, no new build, no propagation delay.
  • See every flag in one place, with the value actually in effect and where it came from.
  • Overrides persist across restarts, and clear in one tap.
  • Safe by default. Overrides only apply when FlagManager.enabled is true, which defaults to kDebugMode. A forgotten override cannot change behaviour in a release build.
  • Provider agnostic. One closure connects it to whatever you already use.

Toggling flags in the panel while the app updates live

Every change above is made from inside the running app. Nothing was rebuilt, redeployed, or waited on — and one tap on Reset all puts it back.

Install #

dependencies:
  flag_override_panel: ^0.2.0

Usage #

1. Declare your flags #

import 'package:flag_override_panel/flag_override_panel.dart';

const BoolFlag newCheckout = BoolFlag(
  'new_checkout',
  description: 'Use the rebuilt checkout flow',
  group: 'Checkout',
);
const IntFlag pageSize = IntFlag('page_size', defaultValue: 20);
const StringFlag themeMode = StringFlag(
  'theme_mode',
  defaultValue: 'system',
  options: <String>['system', 'light', 'dark'],
);

const List<Flag<Object>> appFlags = <Flag<Object>>[
  newCheckout,
  pageSize,
  themeMode,
];

group becomes a section heading in the panel. A StringFlag with options renders as a dropdown instead of a text field, so QA cannot type a value your app does not handle.

2. Create the manager at startup #

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final FlagManager manager = await FlagManager.init(
    flags: appFlags,
    store: const SharedPreferencesFlagOverrideStore(),
    source: (String key) => FirebaseRemoteConfig.instance.getValue(key).asString(),
  );
  runApp(FlagScope(manager: manager, child: const MyApp()));
}

source is optional. Without it, flags resolve to their declared defaults until overridden.

3. Read flags #

// Rebuilds when the flag is overridden.
final bool enabled = context.flagValue(newCheckout);

// Or, scoped to a subtree:
FlagBuilder<bool>(
  flag: newCheckout,
  builder: (BuildContext context, bool enabled) =>
      enabled ? const NewCheckout() : const LegacyCheckout(),
)

// Outside the widget tree:
final int size = FlagManager.instance.valueOf(pageSize);

4. Open the panel #

IconButton(
  icon: const Icon(Icons.flag_outlined),
  onPressed: () => showFlagOverridePanel(context),
)

Wire it to a debug menu, a shake gesture, or an internal-only build. FlagOverridePanel is a plain widget, so you can also drop it into a Scaffold body as a full screen.

Every registered flag grouped by section

Flags are grouped by group, filterable by key, description or group, and each override shows the value it reverts to. A bool renders as a switch, a String with options as a dropdown, and int/double as numeric fields.

How a value is resolved #

For each flag, in order:

  1. the local override, if one is set and FlagManager.enabled is true;
  2. the value returned by source, if it parses to the flag's type;
  3. the flag's defaultValue.

A value that does not parse is skipped rather than thrown — a remote returning "maybe" for a BoolFlag falls through to the default instead of crashing. bool flags accept true/false, "true"/"false" and 1/0, since providers disagree about encoding.

baseValueOf tells you what a flag reverts to, which the panel shows next to every override.

Release builds #

enabled defaults to kDebugMode. In a release build overrides are still stored and listed, but source wins, and the panel shows a banner saying so.

The panel in a build where overrides are disabled

If you ship an internal or QA flavour where overrides should apply, pass it explicitly:

FlagManager.init(flags: appFlags, enabled: isInternalBuild);

Persistence #

SharedPreferencesFlagOverrideStore keeps every override in a single JSON preference, so clearing them touches exactly one key. A corrupt payload is discarded rather than thrown.

Implement FlagOverrideStore to store them elsewhere:

abstract interface class FlagOverrideStore {
  Future<Map<String, Object>> load();
  Future<void> save(Map<String, Object> overrides);
}

MemoryFlagOverrideStore is the default and is what you want in tests.

Sharing overrides #

The panel's ⋮ menu copies the current overrides to the clipboard as JSON, and pastes a payload back. A tester drops theirs into a bug report; whoever picks it up pastes it in and lands on the same state.

{
  "new_checkout": true,
  "page_size": 5
}

Only flags someone deliberately changed appear, so importing reproduces the overrides without pinning everything else to whatever the other device happened to resolve. Both ends work without the UI:

final String payload = manager.exportOverrides();

final FlagImportResult result = await manager.importOverrides(payload);
print(result.describe()); // Imported 2 overrides, 1 unknown

importOverrides replaces the override set rather than merging it, because reproducing someone's state means ending up with exactly their flags and no others. An empty object therefore clears everything.

Keys no flag declares, and values that do not parse to their flag's type, are skipped and reported in FlagImportResult instead of failing the import — a payload written against an older build of the app still applies everything it legitimately can. Values are stored parsed, so "true" for a BoolFlag lands as true and exported payloads are canonical.

For the other direction — every flag and the value actually in effect, rather than just the overridden ones — use snapshot().

Testing with flags #

testWidgets('shows the new checkout when the flag is on', (WidgetTester tester) async {
  final FlagManager manager = FlagManager(flags: appFlags, enabled: true);
  await manager.setOverride(newCheckout, true);

  await tester.pumpWidget(FlagScope(manager: manager, child: const MyApp()));
  expect(find.byType(NewCheckout), findsOneWidget);
});

FlagManager.snapshot() returns every resolved value, which is worth attaching to bug reports so you know which flags were on when something broke.

Example #

A runnable app is in example/, where the flags drive the theme, the list length and the card styling so you can watch the UI change as you toggle them.

The images above are generated from that example rather than captured by hand, so they cannot drift from what the package actually renders:

cd example
flutter test tool/capture_test.dart   # writes doc/*.png and the animation frames
./tool/make_media.sh                  # stitches doc/demo.gif, needs ffmpeg

License #

MIT

1
likes
160
points
200
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A debug-only panel for viewing and overriding feature flags on-device during development and QA, independent of your remote flag provider.

Repository (GitHub)
View/report issues

Topics

#feature-flags #debugging #devtools #testing

License

MIT (license)

Dependencies

flutter, shared_preferences

More

Packages that depend on flag_override_panel