flutter_env_switch

A lightweight, type-safe runtime environment & config switcher for Flutter.

pub.dev License: MIT


Table of contents

  1. The problem
  2. Why choose flutter_env_switch?
  3. Features
  4. Installation
  5. Quick start
  6. Reading config values
  7. Current environment
  8. Switching environments
  9. Reactive UI
  10. Debug panel
  11. Tap-count trigger
  12. On-switch callback
  13. Locking environments
  14. In-panel key browser
  15. Environment badge
  16. Soft app restart
  17. Dio integration
  18. EnvConfig — optional convenience type
  19. .env file syntax
  20. Error handling
  21. Testing
  22. Advanced: typed singleton access
  23. FAQ
  24. API reference

The problem

Most Flutter apps tie environment configuration to compile-time flags (--dart-define) or hard-coded constants. Changing from staging to production means a full rebuild. Debugging a production-only issue requires shipping a special build.

flutter_env_switch takes a different approach: all environment configs are loaded from .env asset files at startup, and the active environment can be switched at runtime — no rebuild needed.


Why choose flutter_env_switch?

Feature dev_env_switcher env_switcher flutter_env_switch
Env identification strings strings enum (compile-time safe)
Config source inline Dart maps inline Dart maps .env asset files
Typed accessors raw map getExtra<T>() getInt / getDouble / getBool / getOrElse
Locked environments YES — unique
Soft app restart manual AppRestarter
Dio integration YES
Reactive notifier addListener ValueNotifier
Gesture trigger widget toggle multi-tap (configurable) both long-press and tap-count
On-switch callback onEnvironmentChanged onSwitched
Release-mode guard manual enabled flag configurable via enableInRelease
In-panel key browser YES — with sensitive-key masking
On-screen env badge EnvBadge

Features

Multi-.env loading Load all environment configs in parallel at startup
Enum-driven Your own enum identifies environments — no magic strings
Runtime switching Switch the active env without rebuilding the app
Persistent selection The last chosen environment survives app restarts (configurable)
Gesture debug panel Long-press or tap-count to open a bottom-sheet switcher
On-switch callback onSwitched fires after every successful environment switch
Locked environments Prevent switching away from sensitive envs (e.g. production)
In-panel key browser Inspect all loaded key/value pairs — sensitive keys masked by default
Environment badge EnvBadge renders a persistent overlay corner badge that updates reactively
Release-safe Debug panel is always disabled in release mode
Dio integration Optional interceptor that injects BASE_URL per request
Reactive ValueNotifier lets any widget rebuild on env change
Minimal deps Only shared_preferences and optionally dio

Installation

Add flutter_env_switch to your app's pubspec.yaml:

dependencies:
  flutter_env_switch: ^1.1.5

shared_preferences is a transitive dependency — no separate entry needed. dio is also included for the optional Dio interceptor.


Quick start

1. Declare your .env assets

# pubspec.yaml
flutter:
  assets:
    - assets/env/.env.dev
    - assets/env/.env.staging
    - assets/env/.env.production

2. Create your .env files

# assets/env/.env.dev
BASE_URL=https://dev.api.example.com
APP_NAME=MyApp (Dev)
TIMEOUT=10
LOG_LEVEL=debug
FEATURE_ANALYTICS=false
FEATURE_DARK_MODE=true
# assets/env/.env.staging
BASE_URL=https://staging.api.example.com
APP_NAME=MyApp (Staging)
TIMEOUT=20
LOG_LEVEL=info
FEATURE_ANALYTICS=true
FEATURE_DARK_MODE=false
# assets/env/.env.production
BASE_URL=https://api.example.com
APP_NAME=MyApp
TIMEOUT=30
LOG_LEVEL=error
FEATURE_ANALYTICS=true
FEATURE_DARK_MODE=false

3. Initialise in main.dart

import 'package:flutter_env_switch/flutter_env_switch.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

enum Environment { dev, staging, production }

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Env.init<Environment>(
    defaultEnv: Environment.production,
    configs: {
      Environment.dev:        'assets/env/.env.dev',
      Environment.staging:    'assets/env/.env.staging',
      Environment.production: 'assets/env/.env.production',
    },
  );

  runApp(
    AppRestarter(
      child: EnvBadge<Environment>(        // ← shows active env name in corner
        child: EnvSwitcher<Environment>(
          child: const MyApp(),            // panel on by default in all modes
        ),
      ),
    ),
  );
}

defaultEnv is used only when no previously persisted selection exists. Production release builds always start with their correct environment regardless of what a tester last chose on their device.

Set persistSelection: false to always start from defaultEnv on every launch — useful for CI/demo environments that should never remember a previous switch.


Reading config values

Import the package once at the top of any file:

import 'package:flutter_env_switch/flutter_env_switch.dart';

Then call the static accessors anywhere after Env.init completes:

// Raw string — throws EnvKeyNotFoundException if the key is absent
final baseUrl = Env.get('BASE_URL');

// Typed accessors
final timeout   = Env.getInt('TIMEOUT');          // int
final retryDelay = Env.getDouble('RETRY_DELAY');  // double
final analytics = Env.getBool('FEATURE_ANALYTICS'); // bool

// Safe fallback — never throws, returns fallback if key is absent
final logLevel = Env.getOrElse('LOG_LEVEL', 'info');

getBool truthy values

The following raw string values (case-insensitive) evaluate to true:

Raw value Result
true true
1 true
yes true
anything else false
FEATURE_NEW_UI=yes    # true
FEATURE_NEW_UI=1      # true
FEATURE_NEW_UI=True   # true
FEATURE_NEW_UI=false  # false
FEATURE_NEW_UI=0      # false

Choosing between get and getOrElse

// Explicit — your code cannot proceed without this key:
final apiKey = Env.get('BASE_URL');

// Optional — a sensible default exists:
final pageSize = Env.getOrElse('PAGE_SIZE', '20');

Current environment

// The active enum value:
final env = Env.current;         // Enum
print(env.name);                 // 'production'

// Cast back to your own type when you need it:
final typedEnv = Env.current as Environment;
if (typedEnv == Environment.dev) {
  // dev-only path
}

Switching environments

Programmatically

// Switch and persist — completes once SharedPreferences confirms the write.
await Env.switchTo(Environment.staging);

The switch takes effect immediately: Env.get(...) calls on the next line already return values from the new environment. Widgets that depend on Env.currentNotifier are notified synchronously after the persist completes.

If you need the whole widget tree to rebuild (e.g. to pick up a new BASE_URL injected into your Dio client at construction), trigger a soft restart:

await Env.switchTo(Environment.staging);
AppRestarter.restart(context); // rebuilds tree from the root

Via the debug panel

Long-press anywhere on the screen (when wrapped with EnvSwitcher) to open the panel. Tap any environment row to switch. The optional "restart after switch" toggle handles the AppRestarter.restart call automatically.

Guard against switching when locked

if (!Env.isLocked) {
  await Env.switchTo(Environment.staging);
}

Or handle the exception explicitly:

try {
  await Env.switchTo(Environment.staging);
} on EnvSwitchLockedException catch (e) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(e.toString())),
  );
}

Reactive UI

Env.currentNotifier is a ValueNotifier<dynamic>. Wrap any widget that should rebuild when the environment changes:

ValueListenableBuilder<dynamic>(
  valueListenable: Env.currentNotifier,
  builder: (context, env, child) {
    return Column(
      children: [
        Text('Active: ${env.name}'),
        Text('API: ${Env.get('BASE_URL')}'),
      ],
    );
  },
)

Rebuilding MaterialApp on switch

Wrapping MaterialApp itself lets the app re-read all config (e.g. theme, title) after a switch without a hard restart:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<dynamic>(
      valueListenable: Env.currentNotifier,
      builder: (context, env, _) {
        final isDark = Env.getBool('FEATURE_DARK_MODE');
        return MaterialApp(
          title: Env.get('APP_NAME'),
          themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
          // ...
        );
      },
    );
  }
}

Typed notifier via EnvManager

If you prefer working with the concrete enum type:

final manager = EnvManager.instanceOf<Environment>();

ValueListenableBuilder<Environment>(
  valueListenable: manager.currentNotifier,
  builder: (context, env, _) => Text(env.name),
)

Debug panel

The debug panel is a modal bottom sheet that lists all registered environments, highlights the active one, and lets testers switch at runtime.

Setup (already done in Quick start)

runApp(
  AppRestarter(               // ← enables soft restart after switch
    child: EnvSwitcher<Environment>(
      // Panel is active by default in all build modes.
      // Set enableInRelease: false to restrict to debug/profile only.
      child: const MyApp(),
    ),
  ),
);

Opening the panel

Method How
Long-press gesture Long-press any part of the screen wrapped by EnvSwitcher (default)
Tap-count gesture Set triggerMode: EnvTriggerMode.tapCount — tap tapCount times (default 5) within tapWindowMs
Programmatically Call showEnvDebugPanel<Environment>(context) from a button, drawer, etc.
// From a settings screen, drawer, or debug menu:
TextButton(
  onPressed: () => showEnvDebugPanel<Environment>(context),
  child: const Text('Switch Environment'),
),

Hiding the restart toggle

showEnvDebugPanel<Environment>(context, showRestartToggle: false);

Release mode

By default EnvSwitcher is active in all build modes (debug, profile, and release). To restrict it to debug and profile builds only, set enableInRelease: false:

EnvSwitcher<Environment>(
  enableInRelease: false, // hidden in release builds
  child: const MyApp(),
)

To disable the gesture unconditionally (e.g. based on a remote config flag), set enabled: false.

Locked state

When the active environment is locked, the panel still opens (so developers can see the lock state rather than getting a silent no-op). It renders a red LOCKED badge in the header, shows an explanatory subtitle, and disables all environment tiles. See Locking environments for details.


Tap-count trigger

The industry-standard "hidden QA panel" gesture — popularised by Flutter's own diagnostic tools — is available via EnvTriggerMode.tapCount. Configure the tap count and time window:

EnvSwitcher<Environment>(
  triggerMode: EnvTriggerMode.tapCount,
  tapCount: 5,        // default 5
  tapWindowMs: 3000,  // default 3000 ms
  child: const MyApp(),
)

Each tap resets the window if more than tapWindowMs milliseconds have elapsed since the first tap in the sequence. When tapCount consecutive taps arrive within the window, the panel opens.

longPress remains the default to avoid breaking existing integrations:

// Default (long-press) — unchanged from before
EnvSwitcher<Environment>(child: const MyApp())

Choosing a trigger mode

longPress tapCount
Discoverability Low — accidental long-presses can occur High — the hidden tap-count pattern is familiar to QA teams
Accessibility Works with voice-over long-press Works with repeated taps
Risk of accidental trigger Higher on scrollable areas Lower — requires deliberate rapid taps

On-switch callback

onSwitched fires after the environment switch (and optional app restart) completes. Use it to show a confirmation snackbar, log an analytics event, or update external state.

Via EnvSwitcher

EnvSwitcher<Environment>(
  onSwitched: () {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Environment switched!')),
    );
  },
  child: const MyApp(),
)

Via showEnvDebugPanel

showEnvDebugPanel<Environment>(
  context,
  onSwitched: () => analytics.track('env_switched'),
);

Pass lockedEnvironments to Env.init to prevent switching away from specific environments at runtime. The canonical use-case is locking production so that no in-app tooling can redirect a live app to a staging server.

await Env.init<Environment>(
  defaultEnv: Environment.production,
  configs: { ... },
  lockedEnvironments: {Environment.production},
);

How the lock works

The lock applies to the currently active environment, not to the target. When production is locked:

dev        ──────────────────▶  staging      (allowed)
staging    ──────────────────▶  dev          (allowed)
dev        ──────────────────▶  production   (allowed — transitioning IN is fine)
staging    ──────────────────▶  production   (allowed — transitioning IN is fine)
production ──────────────────▶  dev          (BLOCKED — EnvSwitchLockedException)
production ──────────────────▶  staging      (BLOCKED — EnvSwitchLockedException)

Key points:

  • You cannot switch away from a locked environment (the critical guard).
  • You can reach a locked environment from any unlocked one.
  • Once in a locked environment, the only way out is a new Env.init call (e.g. after a fresh install / cleared storage).

Checking the lock state

// Static shortcut:
if (Env.isLocked) {
  // Hide or disable any switch UI.
}

// Via the typed manager:
final manager = EnvManager.instanceOf<Environment>();
if (manager.isCurrentLocked) { ... }

Locking multiple environments

lockedEnvironments: {Environment.staging, Environment.production},

Debug panel in locked state

The panel still opens on long-press (to provide visibility), but:

  • The header badge changes from DEV ONLY (blue) to LOCKED (red).
  • A subtitle explains that switching is disabled.
  • All environment tiles are non-interactive (grey text, no tap handler).
  • The "restart after switch" toggle is hidden.

Handling the exception

try {
  await Env.switchTo(Environment.dev);
} on EnvSwitchLockedException catch (e) {
  // e.envName — the name of the locked environment
  debugPrint(e.toString());
  // EnvSwitchLockedException: switching is disabled
  //   while the active environment is "production".
}

In-panel key browser

The debug panel includes a collapsible "View loaded keys" section. Tap it to expand a scrollable list of all key/value pairs loaded for the active environment — no debugger needed.

Sensitive-key masking

Keys whose names contain KEY, SECRET, TOKEN, PASSWORD, PASS, PWD, AUTH, or CREDENTIAL are automatically masked with bullet characters. An eye-toggle reveals the real value. The copy button always copies the original (unmasked) value to the clipboard.

API_KEY       ••••••••••••  [👁] [copy]   ← masked by default
BASE_URL      https://dev.api.example.com  [copy]
TIMEOUT       10                            [copy]

The key browser requires no configuration — it is always present in the debug panel and shows the data for whichever environment is currently active.


Environment badge

EnvBadge<E> renders a persistent overlay showing the active environment. It reacts to switches automatically via ValueNotifier.

Basic usage

Wrap any widget (typically MaterialApp) to get a corner badge:

EnvBadge<Environment>(
  child: MaterialApp(...),
)

This renders a semi-transparent pill in the top-right corner with the environment name in uppercase (e.g. DEV, STAGING).

Custom position

EnvBadge<Environment>(
  alignment: Alignment.bottomLeft,
  child: MaterialApp(...),
)

Custom badge widget

EnvBadge<Environment>(
  badgeBuilder: (env) => Chip(
    label: Text(env.name.toUpperCase()),
    backgroundColor: env == Environment.dev ? Colors.orange : Colors.blue,
  ),
  child: MaterialApp(...),
)

Padding

EnvBadge<Environment>(
  padding: const EdgeInsets.all(16),
  child: MaterialApp(...),
)

Release-mode visibility

By default the badge is hidden in release builds. Set visibleInRelease: true for internal distribution builds (e.g. TestFlight):

EnvBadge<Environment>(
  visibleInRelease: kProfileMode, // visible in profile, hidden in release
  child: MaterialApp(...),
)

Full example with AppRestarter and EnvSwitcher

runApp(
  AppRestarter(
    child: EnvBadge<Environment>(
      child: EnvSwitcher<Environment>(
        triggerMode: EnvTriggerMode.tapCount,
        child: const MyApp(),
      ),
    ),
  ),
);

Soft app restart

AppRestarter is a StatefulWidget that rebuilds its entire subtree by assigning a new UniqueKey to its child. This achieves a "soft restart" — re-running all initState calls and reading fresh config — without a cold process restart.

Setup

Place AppRestarter above MaterialApp in the widget tree (ideally at the very root as shown in Quick start):

runApp(
  AppRestarter(
    child: EnvSwitcher<Environment>(
      child: const MyApp(),
    ),
  ),
);

Triggering a restart

// From any widget inside the AppRestarter subtree:
AppRestarter.restart(context);

If no AppRestarter ancestor is found, the call is a silent no-op — safe to call unconditionally.

Re-initialising services on restart

When you switch environments, services set up in main() before runApp (Sentry, Dio clients, routers) are not automatically re-created because main() does not re-run. Use the onRestart callback to re-initialise them. The new environment is already active when onRestart fires, so Env.get(...) returns the new values:

runApp(
  AppRestarter(
    onRestart: () async {
      // New env values are active here — Env.get(...) already reflects the switch.
      await Sentry.close();
      await SentryFlutter.init(
        options: SentryFlutterOptions()..dsn = Env.get('SENTRY_DSN'),
      );
    },
    child: SentryWidget(child: MyApp()),
  ),
);

Dynamic subtree re-creation with builder

When a widget in the tree holds a reference to an object that must itself be re-created (e.g. a GoRouter instance), use builder instead of child. The builder is called fresh on every restart:

// Make router a global/static so onRestart can update it.
GoRouter router = AppRouter.create();

runApp(
  AppRestarter(
    onRestart: () async {
      router = AppRouter.create(); // re-build with new env values
    },
    builder: (ctx) => MyApp(router: router), // evaluated anew each restart
  ),
);

child and builder are mutually exclusive — exactly one must be provided.

When to use it

Scenario Restart needed?
Changing a feature flag read inside build No — ValueListenableBuilder handles it
Changing BASE_URL used by a Dio client built once in initState Yes
Changing the app theme driven by an env key No — wrap MaterialApp with ValueListenableBuilder
Changing any config that is read in main() Yes — use onRestart

The debug panel's "restart after switch" toggle automates this for testers — they do not need to understand this distinction.


Dio integration

flutter_env_switch ships an optional Dio interceptor. Since dio is already a dependency of the package, no extra pubspec.yaml entry is required.

import 'package:flutter_env_switch/integrations/dio_interceptor.dart';

final dio = Dio()..interceptors.add(EnvDioInterceptor());

The interceptor reads BASE_URL from the active environment on every request, so it always reflects the currently selected environment — including after a runtime switch.

Custom key

final dio = Dio()
  ..interceptors.add(EnvDioInterceptor(baseUrlKey: 'API_ENDPOINT'));

How it works

On each request, EnvDioInterceptor.onRequest:

  1. Calls Env.get(baseUrlKey) to resolve the current base URL.
  2. Rewrites options.baseUrl in-place.
  3. Passes the modified options to the next interceptor via handler.next(options).

If the key is absent from the active environment, EnvKeyNotFoundException is thrown, which Dio surfaces as a DioException.

With environment switching

Because the interceptor reads the value on every request, you do not need to recreate the Dio instance after switching environments:

await Env.switchTo(Environment.staging);
// Next Dio request automatically uses the staging BASE_URL.

EnvConfig — optional convenience type

EnvConfig<E> pairs an enum value with its asset path. It is entirely optional — Env.init accepts a plain Map<E, String> and that is the recommended approach for most apps.

EnvConfig is useful for teams that want to declare environment descriptors in a separate file and iterate over them:

// env_configs.dart
const envConfigs = [
  EnvConfig(env: Environment.dev,        assetPath: 'assets/env/.env.dev'),
  EnvConfig(env: Environment.staging,    assetPath: 'assets/env/.env.staging'),
  EnvConfig(env: Environment.production, assetPath: 'assets/env/.env.production'),
];
// main.dart
await Env.init<Environment>(
  defaultEnv: Environment.production,
  configs: Map.fromEntries(
    envConfigs.map((c) => MapEntry(c.env, c.assetPath)),
  ),
);

.env file syntax

The parser handles all standard .env idioms:

Syntax Behaviour
KEY=value Standard pair
KEY="quoted value" Quotes stripped; content used verbatim
KEY='single quoted' Same as double-quoted
KEY=a=b=c = inside a value is preserved
# full-line comment Line ignored
KEY=value # inline note Inline comment stripped (unquoted values only)
KEY="value # not a comment" Quoted values — # inside quotes is not stripped
KEY= Empty string value — key present, value is ""
Blank lines Ignored
CRLF / CR line endings Normalised transparently

Examples

# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=myapp_dev

# URLs with = signs in query strings are safe
CALLBACK_URL=https://auth.example.com/callback?redirect=https://app.example.com

# Quoted value preserves internal # character
REGEX_PATTERN="^[a-z]#[0-9]+$"

# Inline comment is stripped from unquoted values
TIMEOUT=30  # seconds — this comment is stripped; value is "30"

# Empty value
OPTIONAL_KEY=

# Boolean flags
FEATURE_X=true
FEATURE_Y=1
FEATURE_Z=yes

Key name rules

  • Leading and trailing whitespace around the key is stripped.
  • Lines without a = delimiter are silently ignored.
  • Lines with an empty key (e.g. =value) are silently ignored.
  • Duplicate keys: the last occurrence wins.

Error handling

All exceptions implement Exception and have descriptive toString() messages suitable for logging.

Exception When thrown Common cause
EnvNotInitializedException Any accessor called before Env.init Forgot await Env.init(...) before runApp
EnvLoadException Env.init — an asset file cannot be loaded Asset path typo, not declared in pubspec.yaml
EnvKeyNotFoundException get, getInt, getDouble, getBool Key missing from the active .env file
EnvSwitchLockedException switchTo while current env is locked Attempted to switch away from a locked environment
ArgumentError switchTo called with an unregistered enum Passed an enum value not in configs map
FormatException getInt, getDouble Value exists but is not a valid number

Defensive patterns

// 1. Safe read with fallback — no exception possible
final timeout = Env.getOrElse('TIMEOUT', '30');
final timeoutMs = int.tryParse(timeout) ?? 30;

// 2. Guarded switch
if (!Env.isLocked) {
  await Env.switchTo(Environment.staging);
}

// 3. Full error handling
try {
  await Env.switchTo(Environment.staging);
} on EnvSwitchLockedException catch (e) {
  logger.warning(e.toString());
} on ArgumentError catch (e) {
  logger.error('Unknown environment: $e');
}

// 4. Catching missing keys during debug
try {
  final apiKey = Env.get('STRIPE_KEY');
} on EnvKeyNotFoundException catch (e) {
  // e.key      → 'STRIPE_KEY'
  // e.envName  → 'development'
  throw StateError('Missing required key: ${e.key} in ${e.envName}');
}

Testing

Unit testing code that reads Env

Inject a fake loader and an in-memory store to keep tests hermetic:

import 'package:flutter_env_switch/core/env_loader.dart';
import 'package:flutter_env_switch/core/env_manager.dart';
import 'package:flutter_env_switch/core/env_store.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

enum TestEnv { dev, prod }

class FakeBundle extends Fake implements AssetBundle {
  FakeBundle(this._assets);
  final Map<String, String> _assets;

  @override
  Future<String> loadString(String key, {bool cache = true}) async {
    final content = _assets[key];
    if (content == null) throw Exception('Asset not found: $key');
    return content;
  }
}

void main() {
  setUp(() {
    SharedPreferences.setMockInitialValues({});
    EnvManager.reset(); // clear singleton between tests
  });

  tearDown(EnvManager.reset);

  test('reads BASE_URL from active env', () async {
    await EnvManager.init<TestEnv>(
      defaultEnv: TestEnv.dev,
      configs: {
        TestEnv.dev:  'assets/.env.dev',
        TestEnv.prod: 'assets/.env.prod',
      },
      loader: EnvLoader(bundle: FakeBundle({
        'assets/.env.dev':  'BASE_URL=https://dev.example.com',
        'assets/.env.prod': 'BASE_URL=https://example.com',
      })),
      store: EnvStore(), // SharedPreferences is mocked above
    );

    expect(Env.get('BASE_URL'), 'https://dev.example.com');
  });

  test('switching env updates the value', () async {
    final manager = await EnvManager.init<TestEnv>(
      defaultEnv: TestEnv.dev,
      configs: {
        TestEnv.dev:  'assets/.env.dev',
        TestEnv.prod: 'assets/.env.prod',
      },
      loader: EnvLoader(bundle: FakeBundle({
        'assets/.env.dev':  'BASE_URL=https://dev.example.com',
        'assets/.env.prod': 'BASE_URL=https://example.com',
      })),
      store: EnvStore(),
    );

    await manager.switchTo(TestEnv.prod);
    expect(Env.get('BASE_URL'), 'https://example.com');
  });
}

Widget testing

testWidgets('shows correct env badge', (tester) async {
  SharedPreferences.setMockInitialValues({});
  EnvManager.reset();

  await EnvManager.init<TestEnv>(
    defaultEnv: TestEnv.dev,
    configs: { ... },
    loader: EnvLoader(bundle: FakeBundle({ ... })),
    store: EnvStore(),
  );

  await tester.pumpWidget(
    const MaterialApp(home: MyHomePage()),
  );

  expect(find.text('DEV'), findsOneWidget);
});

Key rules for tests

  1. Always call SharedPreferences.setMockInitialValues({}) before each test.
  2. Always call EnvManager.reset() in setUp and tearDown so tests are isolated.
  3. Pass a FakeBundle (or any AssetBundle implementation) to EnvLoader — do not rely on the real asset bundle in unit tests.
  4. The @visibleForTesting loader and store parameters on both Env.init and EnvManager.init exist specifically for this injection pattern.

Advanced: typed singleton access

EnvManager<E> is the underlying singleton. The Env static class delegates to it. For advanced use (e.g. subscribing to the typed notifier, inspecting allValues), access it directly:

// Typed access — throws if not initialised
final manager = EnvManager.instanceOf<Environment>();

// All registered envs in declaration order
final envs = manager.allValues; // List<Environment>

// Typed ValueNotifier — no dynamic cast needed
manager.currentNotifier.addListener(() {
  print('Switched to: ${manager.current.name}');
});

// Direct typed switch (same as Env.switchTo but typed)
await manager.switchTo(Environment.staging);

// Lock state
print(manager.isCurrentLocked); // bool

EnvManager.instance (untyped, returns EnvManager<dynamic>) is available if you need access before knowing the concrete type:

final raw = EnvManager.instance; // EnvManager<dynamic>
print(raw.current.name);         // still works via Enum.name

FAQ

Can I add flutter_env_switch to an existing app without changing CI or build scripts?

Yes. Env.init is a runtime call, not a build-time one. Pass your production enum value as defaultEnv — it applies only when no persisted selection exists (i.e. a fresh install). Your existing release pipeline and builds are unaffected.


Does flutter_env_switch read .env files from the device filesystem?

No. It reads from the Flutter asset bundle (declared under flutter.assets in pubspec.yaml). This is intentional — filesystem paths are sandboxed on mobile and the asset bundle is the correct mechanism for shipping static files with the app.


Is it safe to store secrets in .env files?

No. Assets are bundled inside the app binary and can be extracted. flutter_env_switch is designed for non-secret runtime config: API base URLs, feature flags, timeouts, app names, log levels. Do not store API keys, private keys, or credentials in .env files.


What happens if a key is missing from an .env file?

get, getInt, getDouble, and getBool throw EnvKeyNotFoundException. Use getOrElse to supply a fallback without throwing. This is intentional — silent misconfigurations are harder to debug than loud exceptions.


Can two environments share some keys while overriding others?

Yes — each .env file is completely independent. There is no inheritance or merging. Include all required keys in every file; use getOrElse in your code for keys that may not always be present.


What is defaultEnv actually used for?

It is the environment selected on first launch (or after SharedPreferences is cleared). On subsequent launches, flutter_env_switch restores whatever was last persisted. In production, set defaultEnv to your production enum value so a fresh install starts in the correct state.


The debug panel does not appear. What should I check?

  1. Confirm EnvSwitcher wraps the widget you are long-pressing.
  2. Confirm enabled is true (default).
  3. Confirm EnvSwitcher is typed with your enum: EnvSwitcher<Environment>.
  4. If you set enableInRelease: false, confirm you are running in debug or profile mode.

AppRestarter.restart does nothing. Why?

AppRestarter must be an ancestor of the widget calling restart. Ensure it is placed above your MaterialApp (ideally as the outermost widget in runApp). If no ancestor is found, the call is a silent no-op.


Can I use flutter_env_switch without the debug panel at all?

Yes. Just call Env.init, Env.get, and Env.switchTo directly. AppRestarter and EnvSwitcher are optional UI helpers; the core package works fine without them.


Does switching persist across hot restarts?

Yes, when persistSelection: true (the default). The selected environment is stored in SharedPreferences and restored on the next Env.init call. Set persistSelection: false in Env.init to always start from defaultEnv each launch — the debug panel's "Persist env selection" toggle lets testers change this at runtime.


Which trigger mode should I use — longPress or tapCount?

Use longPress (default) for small internal teams where discoverability is not a concern. Use tapCount for QA teams and external testers who should not accidentally open the panel — the 5-tap pattern is familiar and deliberate. You can also offer both in different builds.


How do I hide the EnvBadge for certain environments?

Use badgeBuilder to return an empty widget:

EnvBadge<Environment>(
  badgeBuilder: (env) =>
      env == Environment.production ? const SizedBox.shrink() : null,
  child: MaterialApp(...),
)

Or set visibleInRelease: false (the default) so it is always hidden in release builds, which already covers production in most CI setups.


The key browser shows no keys. Why?

The panel uses EnvManager.currentEnvData which is populated by Env.init. If the map is empty the active .env file itself is empty or blank. Verify the asset file path in pubspec.yaml and that the file contains valid KEY=value lines.


Can I read currentEnvData outside the panel (e.g. for export)?

Yes — Env.currentEnvData is a plain Map<String, String> available anywhere after Env.init:

final data = Env.currentEnvData;
final json = jsonEncode(data); // for export/logging

API reference

Env — static facade

Member Description
Env.init<E>(...) Loads all .env assets and initialises the singleton
Env.get(key) String — throws EnvKeyNotFoundException if absent
Env.getInt(key) int — throws EnvKeyNotFoundException or FormatException
Env.getDouble(key) double — throws EnvKeyNotFoundException or FormatException
Env.getBool(key) booltrue/1/yestrue, else false
Env.getOrElse(key, fallback) String — returns fallback if key absent; never throws
Env.current Enum — the active environment enum value
Env.isLocked booltrue when the active env is in lockedEnvironments
Env.persistSelection booltrue when env selection is saved across sessions
Env.setPersistSelection(bool) Future<void> — change persist mode at runtime
Env.currentEnvData Map<String, String> — all key/value pairs for the active env
Env.currentNotifier ValueNotifier<dynamic> — emits on every switch
Env.switchTo(env) Future<void> — switches (persists if persistSelection is true); throws if locked or unregistered

Env.init parameters

Parameter Type Required Description
defaultEnv E Environment used on first launch
configs Map<E, String> Maps each enum value to its asset path
lockedEnvironments Set<E>? Envs from which switching is forbidden
persistSelection bool Save and restore env across sessions (default true); false always starts from defaultEnv
loader EnvLoader? Override for testing
store EnvStore? Override for testing

EnvManager<E> — typed singleton

Member Description
EnvManager.init<E>(...) Initialises (same parameters as Env.init)
EnvManager.instance EnvManager<dynamic> — untyped access
EnvManager.instanceOf<E>() EnvManager<E> — typed access
EnvManager.reset() @visibleForTesting — clears the singleton
manager.current E — active env (typed)
manager.allValues List<E> — all registered envs
manager.isCurrentLocked bool
manager.persistSelection booltrue when selection is saved across sessions
manager.setPersistSelection(bool) Future<void> — change persist mode at runtime
manager.currentEnvData Map<String, String> — all key/value pairs for the active env
manager.currentNotifier ValueNotifier<E> — typed notifier
manager.get(key) Delegates to Env.get
manager.switchTo(env) Delegates to Env.switchTo

UI components

Component Description
AppRestarter StatefulWidget — place at root to enable soft restarts
AppRestarter.onRestart Future<void> Function()? — async hook fired before rebuild; use for service re-init
AppRestarter.builder WidgetBuilder? — alternative to child; re-evaluated on every restart
AppRestarter.restart(context) Static method — triggers rebuild from root; no-op if no ancestor
EnvSwitcher<E> Wraps child with a gesture (long-press or tap-count) to open the debug panel
EnvTriggerMode Enum — longPress (default) or tapCount
showEnvDebugPanel<E>(context, {...}) Imperative — shows the panel as a modal bottom sheet
EnvBadge<E> Overlay widget — persistent env badge that updates reactively on switch

EnvSwitcher parameters

Parameter Type Default Description
child Widget required Widget to wrap
enabled bool true Whether the gesture is active (set false to unconditionally disable)
enableInRelease bool true Allow panel in release builds; set false for debug/profile only
showRestartToggle bool true Show restart-after-switch toggle in panel
triggerMode EnvTriggerMode longPress Gesture type
tapCount int 5 Taps required (tapCount mode only)
tapWindowMs int 3000 Ms window for tap sequence (tapCount mode only)
onSwitched VoidCallback? null Called after a successful switch

showEnvDebugPanel parameters

Parameter Type Default Description
showRestartToggle bool true Show restart-after-switch toggle
onSwitched VoidCallback? null Called after a successful switch

EnvBadge parameters

Parameter Type Default Description
child Widget required Widget to overlay
alignment AlignmentGeometry Alignment.topRight Badge corner position
badgeBuilder Widget Function(E env)? null Custom badge widget
padding EdgeInsetsGeometry EdgeInsets.all(12) Badge edge padding
visibleInRelease bool false Show in release builds

Exceptions

Exception Key properties
EnvNotInitializedException
EnvLoadException message: String
EnvKeyNotFoundException key: String, envName: String?
EnvSwitchLockedException envName: String

License

MIT — see LICENSE.