flag_override_panel 0.1.0
flag_override_panel: ^0.1.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.
Install #
dependencies:
flag_override_panel: ^0.1.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.
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.
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.
License #
MIT