A comprehensive feature flags system for enabling/disabling features dynamically in your Flutter app.
The feature flags system provides:
- Local Feature Flags: Compile-time, environment-based, and debug/release flags
- Remote Feature Flags: Firebase Remote Config integration with real-time updates
- Type-Safe Access: Centralized flag definitions with compile-time safety
- Debug Tools: UI for managing flags during development
- Analytics Support: Track flag usage and A/B testing
The feature flags system follows Clean Architecture principles:
┌─────────────────────────────────────┐
│ Presentation Layer │
│ - Debug Screen, Widgets │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Domain Layer │
│ - FeatureFlag Entity │
│ - FeatureFlagsRepository │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Data Layer │
│ - LocalFeatureFlagsService │
│ - RemoteDataSource (Firebase) │
│ - LocalDataSource (Storage) │
│ - Repository Implementation │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Core Layer │
│ - FeatureFlagsManager │
│ - FeatureFlagKey Definitions │
└─────────────────────────────────────┘
Feature flags are resolved in the following priority order (highest to lowest):
- Local Override - Set via debug menu or programmatically
- Remote Config - Firebase Remote Config values
- Environment -
.envfile or--dart-defineflags - Build Mode - Debug vs Release mode defaults
- Compile-Time - Hardcoded default values
All feature flags should be defined in lib/core/feature_flags/feature_flags_manager.dart:
class FeatureFlags {
FeatureFlags._();
static const FeatureFlagKey newFeature = FeatureFlagKey(
value: 'enable_new_feature',
defaultValue: false,
description: 'Enable the new experimental feature',
category: 'Features',
);
}Use FeatureFlagBuilder for conditional rendering:
FeatureFlagBuilder(
flag: FeatureFlags.newFeature,
enabledBuilder: (context) => NewFeatureWidget(),
disabledBuilder: (context) => OldFeatureWidget(),
)For simple show/hide scenarios:
FeatureFlagWidget(
flag: FeatureFlags.newFeature,
child: NewFeatureWidget(),
fallback: OldFeatureWidget(), // Optional
)For complex logic, access flags directly:
final isEnabled = ref.watch(
isFeatureEnabledProvider(FeatureFlags.newFeature),
);
isEnabled.when(
data: (enabled) {
if (enabled) {
// Show new feature
}
},
loading: () => CircularProgressIndicator(),
error: (error, stack) => ErrorWidget(error),
);Use FeatureFlagsManager for programmatic access:
final manager = ref.read(featureFlagsManagerProvider);
final isEnabled = await manager.isEnabled(FeatureFlags.newFeature);Define flags with compile-time defaults:
static const FeatureFlagKey myFlag = FeatureFlagKey(
value: 'my_flag',
defaultValue: false, // Compile-time default
description: 'My feature flag',
);Set flags via environment variables:
# In .env file
FEATURE_ENABLE_NEW_FEATURE=trueOr via --dart-define:
flutter run --dart-define=FEATURE_ENABLE_NEW_FEATURE=trueFlags can have different defaults for debug and release builds:
// In LocalFeatureFlagsService
LocalFlagDefinition(
key: 'enable_debug_menu',
compileTimeDefault: false,
debugDefault: true, // Enabled in debug
releaseDefault: false, // Disabled in release
description: 'Enable debug menu',
),-
Add Firebase to your project (if not already done):
- Follow Firebase setup guide
- Add
google-services.json(Android) andGoogleService-Info.plist(iOS)
-
Initialize Firebase in
main.dart:
import 'package:firebase_core/firebase_core.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// ... rest of initialization
}- Configure Remote Config in Firebase Console:
- Go to Firebase Console > Remote Config
- Add your feature flags as boolean parameters
- Set default values
Set default values for remote config in feature_flags_remote_datasource_provider:
final defaultValues = <String, dynamic>{
'enable_new_feature': false,
'enable_premium_features': false,
// ... other flags
};Remote flags are automatically fetched during app initialization. To manually refresh:
await ref.read(featureFlagsManagerProvider).refresh();Remote Config supports real-time updates. The system will fetch new values when:
- App starts
refresh()is called- Based on the minimum fetch interval (default: 1 hour)
The debug menu allows developers to:
- View all feature flags and their current values
- See the source of each flag (local override, remote, environment, etc.)
- Toggle flags for testing
- Clear local overrides
-
Via App Bar (if
AppConfig.enableDebugFeaturesis true):- Tap the bug icon in the app bar
-
Programmatically:
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const FeatureFlagsDebugScreen(),
),
);- Toggle Flags: Tap the switch to enable/disable a flag
- Clear Override: Long press a flag to clear its local override
- Refresh: Tap refresh icon to fetch latest remote flags
- Clear All: Tap clear icon to remove all local overrides
Feature flags can be used for A/B testing:
- Define Variants:
static const FeatureFlagKey abTestVariant = FeatureFlagKey(
value: 'ab_test_variant',
defaultValue: false,
description: 'A/B test variant',
);-
Configure in Firebase:
- Set up user targeting in Firebase Remote Config
- Define percentage rollouts
- Use user properties for targeting
-
Track Usage:
if (await manager.isEnabled(FeatureFlags.abTestVariant)) {
// Track variant A
analytics.logEvent('ab_test_variant_a');
} else {
// Track variant B
analytics.logEvent('ab_test_variant_b');
}Track feature flag usage:
final flag = await manager.getFlag(FeatureFlags.newFeature);
analytics.logEvent('feature_flag_accessed', parameters: {
'flag_key': flag.key,
'flag_value': flag.value,
'flag_source': flag.source.name,
});- Centralize Definitions: Always define flags in
FeatureFlagsclass - Use Type-Safe Keys: Use
FeatureFlagKeyconstants, not strings - Document Flags: Always provide descriptions
- Set Sensible Defaults: Default to
falsefor new features - Test Both States: Test your app with flags enabled and disabled
- Remove Dead Code: Remove feature flag checks once a feature is fully rolled out
- Monitor Usage: Track which flags are being used and their impact
See lib/features/feature_flags/presentation/examples/feature_flags_example_screen.dart for complete examples.
- Check if local override exists (via debug menu)
- Verify Firebase Remote Config is initialized
- Check network connectivity for remote flags
- Verify environment variables are set correctly
The system gracefully falls back to local flags if Firebase is not initialized. Check:
- Firebase is properly initialized
google-services.json/GoogleService-Info.plistare present- Firebase Remote Config is enabled in Firebase Console
If a flag is not found:
- Verify it's defined in
FeatureFlagsclass - Check the flag key matches exactly (case-sensitive)
- Ensure default value is set
isEnabled(FeatureFlagKey key): Check if a flag is enabledgetFlag(FeatureFlagKey key): Get full flag detailsgetFlags(List<FeatureFlagKey> keys): Get multiple flagsrefresh(): Refresh remote flagssetLocalOverride(FeatureFlagKey key, bool value): Set local overrideclearLocalOverride(FeatureFlagKey key): Clear local overrideclearAllLocalOverrides(): Clear all local overrides
featureFlagsManagerProvider: Main manager instanceisFeatureEnabledProvider(FeatureFlagKey): Check if flag is enabledfeatureFlagProvider(FeatureFlagKey): Get flag detailsallFeatureFlagsProvider: Get all flagsfeatureFlagsInitializationProvider: Initialize system
FeatureFlagBuilder: Conditional rendering with buildersFeatureFlagWidget: Simple show/hide widgetFeatureFlagsDebugScreen: Debug menu screen