flag_override_panel 0.2.0
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.

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.enabledis true, which defaults tokDebugMode. A forgotten override cannot change behaviour in a release build. - Provider agnostic. One closure connects it to whatever you already use.

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.

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:
- the local override, if one is set and
FlagManager.enabledis true; - the value returned by
source, if it parses to the flag's type; - 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.

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
