Mobile SDKs

Flutter SDK

Install and configure the Flutter SDK, and evaluate configs and feature flags on every platform Flutter targets

Introduction

The Flutter SDK is intended to be used by Flutter applications on any of the platforms Flutter targets: iOS, Android, web, macOS, Windows, and Linux.

The minimum supported version of Flutter is 3.22, and the minimum supported version of Dart is 3.4.

Telemetry collection runs on a background isolate, so preparing and sending telemetry stays off your application's main isolate. The web has no isolates and a package cannot ship a web worker of its own, so on web the reports are prepared on the main thread instead; they are still built one batch per flush interval rather than while a config is being evaluated.

Installation

The SDK can be installed from pub.dev: https://pub.dev/packages/configdirector_flutter_client_sdk

flutter pub add configdirector_flutter_client_sdk

Configure and initialize the client

  1. Create an instance of the client providing your client SDK key. You can retrieve a client SDK key for each environment under SDK keys in the dashboard's navigation panel.
  2. Initialize the client to initiate its connection lifecycle.
config_director_setup.dart (Initialize the client)
import 'package:configdirector_flutter_client_sdk/configdirector_flutter_client_sdk.dart';

final client = ConfigDirectorClient(clientSdkKey: 'YOUR-CLIENT-SDK-KEY');

await client.initialize();
Before the client is initialized, config values will evaluate to the default value provided in code.

Most applications create a single client instance, initialize it during startup, and dispose of it on shutdown. In a Flutter app that usually means owning it in a State:

app.dart
class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late final ConfigDirectorClient _client;

  @override
  void initState() {
    super.initState();
    _client = ConfigDirectorClient(clientSdkKey: 'YOUR-CLIENT-SDK-KEY');

    unawaited(_client.initialize());
  }

  @override
  void dispose() {
    _client.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => MaterialApp(home: const HomePage());
}
The SDK follows the app lifecycle through WidgetsBinding so it can release its network connection while the app is backgrounded. If you create the client before runApp, call WidgetsFlutterBinding.ensureInitialized() first. Without it the client still works, but the connection is not paused automatically and the SDK logs a warning.

Additional configuration options

These options can be passed in to ConfigDirectorClientOptions.

metadata

The metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.

Whichever of the two you leave unset is read from the platform — the application label and versionName on Android, the bundle display name and short version string on iOS and macOS, and version.json on web — so most applications do not need to set either one.

config_director_setup.dart
final client = ConfigDirectorClient(
  clientSdkKey: 'YOUR-CLIENT-SDK-KEY',
  options: const ConfigDirectorClientOptions(
    metadata: ConfigDirectorMetaContext(
      appName: 'YOUR-APP-NAME',
      appVersion: '1.0.2',
    ),
  ),
);

logger

By default, the SDK logs to the console and it is set to log warnings and errors only. You can configure a logger by either creating a ConfigDirector console logger with a different log level, or by implementing the ConfigDirectorLogger interface to provide your own logger. The interface can be used to create an adapter to another logging library.

Configure the ConfigDirector console logger to a different level:

config_director_setup.dart
final client = ConfigDirectorClient(
  clientSdkKey: 'YOUR-CLIENT-SDK-KEY',
  options: ConfigDirectorClientOptions(
    logger: ConsoleLogger(level: ConfigDirectorLogLevel.debug),
  ),
);

Implement your own logger adapter:

config_director_setup.dart
class MyLogger implements ConfigDirectorLogger {
  @override
  void debug(String message, [Object? error, StackTrace? stackTrace]) {
    // your specific logging library implementation here
  }

  @override
  void info(String message, [Object? error, StackTrace? stackTrace]) {
    // your specific logging library implementation here
  }

  @override
  void warn(String message, [Object? error, StackTrace? stackTrace]) {
    // your specific logging library implementation here
  }

  @override
  void error(String message, [Object? error, StackTrace? stackTrace]) {
    // your specific logging library implementation here
  }
}

final client = ConfigDirectorClient(
  clientSdkKey: 'YOUR-CLIENT-SDK-KEY',
  options: ConfigDirectorClientOptions(logger: MyLogger()),
);

connection

ConnectionOptions accepts five optional values:

  • mode
    • The connection mode, which can be streaming or polling. It is recommended to use the default of streaming unless you have a specific need to use polling instead.
    • Defaults to ConnectionMode.streaming
  • pollingInterval
    • How often to re-fetch config state when mode is ConnectionMode.polling. It has no effect in any other mode. A value below the minimum of 30 seconds is raised to 30 seconds and a warning is logged. See Polling intervals.
    • Defaults to Duration(seconds: 60)
  • timeout
    • The timeout to be used in initialization and when updating the context. This is how long initialize will wait for data from ConfigDirector services before completing its Future. If the timeout is reached, initialize will return but the client will still be in an unready status and returning default values. The client will continue to attempt to connect and retrieve config values in the background.
    • If your application is used in environments where the users frequently have poor or no connection, you may want to use a lower timeout, or simply don't await the initialize method call
    • Defaults to Duration(seconds: 3)
  • pauseWhileBackgrounded
    • Whether to pause the connection while the app is in the background and resume it when the app returns to the foreground. Mobile operating systems terminate background connections on their own, so this is enabled by default. Set it to false to manage the connection yourself with pauseNetwork and resumeNetwork.
    • Defaults to true
  • baseUrl
    • The base URL used to connect to ConfigDirector services
    • This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services
config_director_setup.dart
final client = ConfigDirectorClient(
  clientSdkKey: 'YOUR-CLIENT-SDK-KEY',
  options: const ConfigDirectorClientOptions(
    connection: ConnectionOptions(
      mode: ConnectionMode.streaming,
      timeout: Duration(seconds: 2),
    ),
  ),
);

Events

Events notify you of some key actions from the client. Each one is exposed as a Stream you can listen to, and every stream is closed when the client is disposed:

config_director_setup.dart
client.onConfigEvaluated.listen((event) {
  debugPrint('Received onConfigEvaluated: ${event.evaluation}');
});

The following events are available:

  • onClientReady: StreamClientReadyEvent
    • Emitted when the client is initialized, or after reconnection due to a context update or a resumed network connection. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs.
    • action - The action that triggered the client to connect/reconnect, which can be ClientConnectAction.initialization, ClientConnectAction.contextUpdate, or ClientConnectAction.networkResume.
  • onContextUpdated: StreamContextUpdatedEvent
    • Emitted whenever the user context is updated, including during initialization. It is emitted before onClientReady, therefore should not be relied upon as a lifecycle event but rather as an informational event to track updates to the user context.
    • context - The user context the client is being updated to. It can be null.
  • onConfigsUpdated: StreamConfigsUpdatedEvent
    • Emitted when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization or context update when an entire config payload is received. After initialization or context update, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
    • keys - A List<String> listing the config keys that were included in the payload from the server. On a delta update, these are only the configs that changed.
    • removedKeys - A List<String> listing the keys of configs the payload removed. A config is removed when a full payload no longer includes it. Empty when nothing was removed, and always empty on a delta update.
  • onConfigEvaluated: StreamConfigEvaluatedEvent
    • Emitted whenever a config is evaluated. This includes calls to getValue and evaluations delivered to listeners via watch.
    • evaluation - A ConfigEvaluation object containing the details of the evaluation:
      • key: String - The config key that was evaluated
      • value: Object - The value the config evaluated to. It can be the default value provided to getValue or watch. For example, if the config was evaluated before the client was initialized.
      • valueId: String? - The value ID, which is a stable hash of the value that can be used for analytics or other third parties rather than sending the value. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It may be null if the evaluation had to fall back to the default value.
      • isDefaultValue: bool - Whether or not the evaluation fell back to the default value provided in code.
      • context: ConfigDirectorContext? - The context the config was evaluated against, if one was set.
      • reason: EvaluationReason - The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, it will be foundMatch. If the evaluation had to fall back to the default value, the reason will encode why the fallback was required:
        • clientNotReady - The evaluation happened before the client finished initialization
        • configStateMissing - The requested config key was not present in the payload received from the server. This could be due to an incorrect config key, or a config that was not enabled to be available to client SDKs.
        • valueMissing - The config had no value for the current context.
        • invalidNumber - The config was requested as a number but the value received from the server could not be converted to a number.
        • invalidBoolean - The config was requested as a boolean but the value received from the server could not be converted to a boolean.
        • invalidJson - The config was requested as JSON but the value received from the server could not be converted to a JSON document.
        • typeMismatch - The config was requested with a data type that did not match the type of the config and no reasonable type conversion was possible.

Retrieve config values

To synchronously retrieve config values, use the client's getValue method. It requires two arguments. The first argument is the config key, and the second is the default value to be returned if the client has not yet received config values from ConfigDirector services.

You can also subscribe to config value changes via the watch method. It requires two arguments, the config key and the default value, and returns a Stream that emits the config's current value on subscription and then every time the evaluated value changes. Consecutive identical values are not re-emitted. Cancel the subscription to stop watching, or call the unwatch method to close every stream watching a specific config key.

main.dart (Use the client)
import 'config_director_setup.dart';

// Retrieve the current value
final value = client.getValue('my-config-key', false);

// Subscribe to value updates
final subscription = client.watch('my-config-key', false).listen((newValue) {
  debugPrint('Value updated: $newValue');
});

await subscription.cancel(); // Cancel the subscription to remove the listener

client.unwatch('my-config-key'); // Closes every stream watching that key

Both getValue and watch infer their type from the default value, and accept any type supported by that specific config (bool, String, int, double, num, or any type a JSON config decodes into). A string config requested as a bool or a number is converted when its value spells one. A boolean, integer, or float config requested as a String, or any config requested as a type its value cannot be converted to, evaluates to the default value with the typeMismatch reason. Mismatched types at runtime are captured by the telemetry collector and will surface as warnings in the ConfigDirector dashboard.

main.dart
client.getValue('my-string-config-key', 'Default');

client.getValue('my-integer-config-key', 100);

client.getValue('my-boolean-config-key', false);

client.getValue<Map<String, dynamic>>('my-json-config-key', const {});

In widgets, watch pairs with a StreamBuilder so the widget rebuilds whenever the config changes, whether because it was edited in the ConfigDirector dashboard or because the context was updated. When a full payload no longer includes the watched config, the stream emits the default value, as getValue would now return it:

home_page.dart
StreamBuilder<bool>(
  stream: client.watch('dark-mode', false),
  initialData: false,
  builder: (context, snapshot) => MyHomePage(darkMode: snapshot.data ?? false),
)
watch hands out a new stream on every call, so call it when its inputs change rather than on every build, from initState or didChangeDependencies, holding onto the stream in between. Calling it directly in build resubscribes on each rebuild.

Update the user context

A user context can be provided when initializing the client:

config_director_setup.dart
import 'package:configdirector_flutter_client_sdk/configdirector_flutter_client_sdk.dart';

final client = ConfigDirectorClient(clientSdkKey: 'YOUR-CLIENT-SDK-KEY');

await client.initialize(
  const ConfigDirectorContext(
    id: '12345',
    name: 'Example User',
    traits: {
      // Any arbitrary traits which can be referenced in targeting rules
      'region': 'North America',
    },
  ),
);

The user context can also be updated via updateContext:

main.dart
import 'config_director_setup.dart';

await client.updateContext(
  const ConfigDirectorContext(
    id: '654321',
    name: 'Another User',
    traits: {'region': 'Australia'},
  ),
);

// Update it to a context with no `id` (anonymous user context) when a user
// signs out. The server derives a stable identifier from this SDK instance.
await client.updateContext(const ConfigDirectorContext());
In client SDKs (browser and mobile), updating the user context re-establishes a new connection to ConfigDirector servers with the new context. While the new connection is in flight, config values will continue to evaluate to the currently cached values from the prior user context.
Awaiting updateContext will wait until the new config values are downloaded or the connection times out. In the case of a timeout, the client will continue to attempt to connect with the new context in the background.

Other useful client features

The client provides additional properties and methods.

evaluate

Evaluates a config the way getValue does and returns the ConfigEvaluation behind it — the value, whether it is the default, the reason, and the valueId — instead of the value alone. It is the same object onConfigEvaluated emits, so use it where the reason matters at the point of the read, such as when reporting an evaluation to another system; getValue is enough everywhere else. Like every read, it is emitted on onConfigEvaluated and counted in telemetry.

main.dart
final evaluation = client.evaluate('my-config-key', false);
if (evaluation.reason == EvaluationReason.clientNotReady) {
  // Config state has not arrived yet.
}

context

Returns the current user context (which may be null). When calling updateContext, the context is not immediately updated. The context is updated after the new connection for the new context succeeds (or times out and goes on retry).

isReady

Returns a bool indicating if the client has been successfully initialized and is ready to evaluate configs for the given user context. It is initially false and becomes true after initialize succeeds. Upon calling updateContext it becomes false again and it is set to true once updateContext succeeds.

isInitializing

Returns a bool indicating whether the client is trying to get its very first config state from the server. It is false on creation, becomes true when initialize is called on a client that has never received config state, stays true through timeouts and retries, and becomes false when the first config state arrives, when an unrecoverable connection error stops the retries, or on dispose. updateContext and resumeNetwork never set it. This is useful to show a loading state instead of transitioning from the in-code default value to the evaluated value on a slow connection.

unwatchAll

Closes every stream that was previously created via watch, for all config keys.

pauseNetwork and resumeNetwork

pauseNetwork releases the network connection without discarding config state, event listeners, or watch streams, and resumeNetwork re-establishes it using the last context given to initialize or updateContext.

The client already does this on its own while the app is backgrounded, so these are only needed if you set pauseWhileBackgrounded to false in order to manage the connection yourself.

dispose

Closes the connection, every watch stream, and every event stream, and reports whatever telemetry is left. Only call dispose when your application shuts down and it will no longer make use of the client instance. The client cannot be used afterwards.

Test your code

Use the SDK's testing library, package:configdirector_flutter_client_sdk/testing.dart, to test the code that reads your configs and flags. It creates a test client: the SDK's real client connected to an in-memory server that your test controls. Only the connection to ConfigDirector, the telemetry, and the app lifecycle and platform lookups are replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened, no telemetry is sent, and no platform channel is used. The library ships in the package, so there is nothing else to install, and it runs under flutter test on the VM and in a browser alike.

checkout_test.dart
import 'package:configdirector_flutter_client_sdk/testing.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  test('enables the new checkout when the flag is on', () async {
    final testClient = createTestClient(
      values: {'new-checkout': true, 'max-items': 20},
    );
    addTearDown(testClient.client.dispose);
    await testClient.client.initialize();

    final checkout = Checkout(testClient.client);
    expect(checkout.isNewCheckoutEnabled, isTrue);

    testClient.setValue('new-checkout', false);
    await pumpEventQueue();
    expect(checkout.isNewCheckoutEnabled, isFalse);
  });
}

testClient.client is a ConfigDirectorClient, so it goes anywhere your code accepts one. It starts uninitialized, like a production client, because the code under test usually owns the call to initialize; with nothing held or failed, initialize completes at once with the client ready. Dispose it as your production code would, for example with addTearDown.

Widget tests

Hand the client to your widgets the way your app does, through an InheritedWidget, a constructor parameter, or your dependency injection of choice. A StreamBuilder over watch then rebuilds whenever a value changes. Updates are delivered asynchronously, so pump after every change with await tester.pumpAndSettle(): it runs the delivery and then the frame the rebuild schedules, whereas a single tester.pump() only draws a frame that was already scheduled.

home_page_test.dart
testWidgets('shows the greeting from the config', (tester) async {
  final testClient = createTestClient(values: {'greeting': 'hello'});
  addTearDown(testClient.client.dispose);
  await tester.pumpWidget(MyApp(client: testClient.client));

  unawaited(testClient.client.initialize());
  await tester.pumpAndSettle();
  expect(find.text('hello'), findsOneWidget);

  testClient.setValue('greeting', 'hi');
  await tester.pumpAndSettle();
  expect(find.text('hi'), findsOneWidget);
});

In testWidgets, time is fake and does not advance on its own, and the check for pending timers runs right after the test body. A held initialize keeps a timer pending, so end it inside the test body by completing it, failing it, or disposing the client, and never await a held initialize directly, which hangs the test body. The client measures elapsed time with a real Stopwatch, which fake time does not advance, so a hold that is meant to time out needs about twice the timeout of fake time.

Values

values, setValue, and replaceValues take native values, and the config type follows from each value's type: a bool; an int (an integer config); a double (a float config); a String; or a Map with String keys or a List (a JSON config). A String is always a string config, so JSON text meant as a JSON config is given as jsonDecode(text). Every value is served to every context; a test that needs different values per context is written as one test per value. null, a blank key, a non-finite number, a value of any other type, a Map with a non-String key, or JSON contents of any other type throw a ConfigDirectorValidationException, and nothing changes. On the web, where Dart integers are JavaScript numbers, 2.0 is an int and an integer beyond 253 − 1 in either direction is rejected.

Reads behave as they do in production: setValue('k', true) read as a String returns the in-code default value with the typeMismatch reason, setValue('k', 2.5) read with an int default returns 2, and setValue('k', '') serves the in-code default value with the valueMissing reason. JSON objects keep their insertion order.

Controls

ControlEffect
setValue(key, value)Stores the value and, once the client is connected, delivers an update carrying only key. watch streams of key, onConfigsUpdated listeners, and reads see it after the next pumpEventQueue().
removeValue(key)Removes the value and delivers a full update without it. Reads return the in-code default value with the configStateMissing reason, watch streams of key emit the default, and onConfigsUpdated lists key in removedKeys.
replaceValues(values)Replaces every stored value, disarms any armed hold or failure, and delivers a full update. Use it to reset a test client shared across tests.
holdInitialization()The next initialize waits until completeInitialization() or failInitialization(), or until the client's timeout elapses.
completeInitialization()Delivers the stored values to the held initialize, which completes with the client ready. Called while a hold is armed but not picked up, it disarms the hold.
failInitialization()Fails the held initialize the way an invalid SDK key does: it completes promptly, the client is not ready, and an error is logged. Called with no initialize held, it arms the next one to fail.
holdContextUpdate(), completeContextUpdate(), failContextUpdate()The same for the next updateContext.
contextUpdatesThe context of every initialize and updateContext call, in order. initialize without a context records an empty context, and resumeNetwork is not recorded.

The initialization controls act only on initialize and the context update controls only on updateContext; resumeNetwork is never held or failed. After a failed attempt, the next attempt succeeds and delivers the values stored in the meantime.

loading_test.dart
final testClient = createTestClient(
  values: {'new-checkout': true},
  timeout: const Duration(milliseconds: 500),
);
addTearDown(testClient.client.dispose);
testClient.holdInitialization();

final initialization = testClient.client.initialize();
await pumpEventQueue();
expect(testClient.client.isInitializing, isTrue);

testClient.completeInitialization();
await initialization;
expect(testClient.client.isReady, isTrue);

Options

createTestClient takes values, timeout (the client's connection timeout, which bounds how long a held attempt waits; defaults to the SDK's production timeout of 3 seconds), and logger (defaults to the SDK's ConsoleLogger).

What to expect

  • Under the test client, initialize emits onContextUpdated, then onClientReady, then onConfigsUpdated. The first update arrives in a microtask after the connection is established, so await client.initialize() returns with the client ready, and listeners run after the next pumpEventQueue().
  • A held attempt that times out, or that is interrupted by pauseNetwork, dispose, or a new attempt, leaves the client not ready until the next attempt and logs the SDK's timeout warning, which says the client will keep retrying, as production streaming would. A held initialize ended by dispose logs it too.
  • watch streams emit on subscription and then only when the evaluated value changes, as in production, so a setValue to the value already served emits nothing, while onConfigsUpdated still fires. removeValue and replaceValues deliver a full update, and only the streams whose value changed emit.
  • A watch listener that throws surfaces as an uncaught error in the test's zone and fails the test.
  • pauseWhileBackgrounded has no effect: the test client does not follow the app lifecycle, so backgrounding the app in a widget test does not pause the connection. Call pauseNetwork and resumeNetwork to test that path.
  • After dispose, the test client's controls are silent no-ops, and no timer or other resource from the SDK is left running.