---
title: "Flutter SDK"
description: "Install and configure the Flutter SDK, and evaluate configs and feature flags on every platform Flutter targets"
canonical_url: "https://docs.configdirector.com/sdks/mobile/flutter"
---
# Flutter SDK

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

<div hidden="true" dataTitleIcon="flutter">



</div>

<external-badges package-url="https://pub.dev/packages/configdirector_flutter_client_sdk" registry="pub.dev" repo="flutter-client-sdk">



</external-badges>

## 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](https://pub.dev/packages/configdirector_flutter_client_sdk)

<code-group>

```bash [flutter]
flutter pub add configdirector_flutter_client_sdk
```

```yaml [pubspec.yaml]
dependencies:
  configdirector_flutter_client_sdk: ^1.7.2
```

</code-group>

## 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.

```dart [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();
```

<note>

Before the client is initialized, config values will evaluate to the default value provided in code.

</note>

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`:

```dart [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());
}
```

<note>

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.

</note>

### 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.

```dart [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:

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

Implement your own logger adapter:

```dart [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 six optional values:

- `mode`
  - The connection mode, which can be `streaming`, `polling` or `offline`. It is recommended to use the default of `streaming` unless you have a specific need to use `polling` instead. In `offline` mode the client never connects to ConfigDirector and serves the values you give it; see [Run without a connection](#run-without-a-connection).
  - Defaults to `ConnectionMode.streaming`
- `values`
  - The values to serve in `offline` mode, keyed by config key. It can be given only in `offline` mode. See [Run without a connection](#run-without-a-connection).
- `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](https://docs.configdirector.com/getting-started/architecture#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 in-code default values. The client will continue to attempt to connect and retrieve config values in the background. It has no effect in `offline` mode.
  - 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`. It has no effect in `offline` mode.
  - 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. It has no effect in `offline` mode.

```dart [config_director_setup.dart]
final client = ConfigDirectorClient(
  clientSdkKey: 'YOUR-CLIENT-SDK-KEY',
  options: const ConfigDirectorClientOptions(
    connection: ConnectionOptions(
      mode: ConnectionMode.streaming,
      timeout: Duration(seconds: 2),
    ),
  ),
);
```

#### `caching`

`CachingOptions` decides whether the client keeps the last values it received on the device and serves them on the next launch until live values arrive, and where. It is on by default; see [Values kept on the device](#values-kept-on-the-device) for its two fields.

### 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:

```dart [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 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 context.
  - `context` - The 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.
- `onCachedValuesLoaded: StreamCachedValuesLoadedEvent`
  - Emitted when the values kept on the device from the last payload are loaded and served, before live values arrive. See [Values kept on the device](#values-kept-on-the-device). The event carries nothing.
- `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`. A value served from the values kept on the device, before live values arrive, reports `cached`; see [Values kept on the device](#values-kept-on-the-device). 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.

### Run without a connection

Set the connection `mode` to `ConnectionMode.offline` to run your application without connecting to ConfigDirector, for example in a suite of UI tests where each run pins every flag to a known value. An offline client opens no connection and sends nothing, so the application is invisible to the ConfigDirector dashboard: no evaluations are counted and no config's lifecycle state changes. Every context receives the same value and targeting rules are not applied. An offline client reads nothing from the platform either, so it also runs under `flutter test`.

An offline client needs no client SDK key. Leave `clientSdkKey` out, as below, or pass an empty one, such as the value `String.fromEnvironment` returns for a key that was not given with `--dart-define`.

Give the values inline, with one value per config key. Your application decides where they come from, for example a map in the test build or a JSON asset it bundles:

```dart [config_director_setup.dart]
final client = ConfigDirectorClient(
  options: const ConfigDirectorClientOptions(
    connection: ConnectionOptions(
      mode: ConnectionMode.offline,
      values: {'new-checkout': true, 'max-items': 20},
    ),
  ),
);

await client.initialize();
```

```dart [config_director_setup.dart]
import 'dart:convert';

import 'package:flutter/services.dart';

final values = jsonDecode(
  await rootBundle.loadString('assets/flags/new-checkout-on.json'),
) as Map<String, dynamic>;

final client = ConfigDirectorClient(
  options: ConfigDirectorClientOptions(
    connection: ConnectionOptions(mode: ConnectionMode.offline, values: values),
  ),
);

await client.initialize();
```

```json [assets/flags/new-checkout-on.json]
{
  "new-checkout": true,
  "max-items": 20,
  "greeting": "hello",
  "theme": { "color": "blue" }
}
```

The config type of each value follows from its Dart type: a `bool` (`boolean`), an `int` (`integer`), a `double` (`float`), a `String` (`string`), or a `Map` with `String` keys or a `List` (`json`). The result of `jsonDecode` can be passed as it is, so `20` in a JSON asset is an integer and `0.15` a float. On the web, where Dart integers are JavaScript numbers, `2.0` is an `int` and an integer beyond 2<sup>

53

</sup>

 − 1 in either direction is refused. The values are read when the client is created. A value read with an in-code default value of another type returns the in-code default value with the `typeMismatch` reason, as it does against ConfigDirector.

`initialize` completes once the values are loaded, with the client ready and `onClientReady` emitted once. A `watch` stream subscribed before `initialize` emits the in-code default value and then the offline value once `initialize` loads it; one subscribed after emits the offline value. Neither emits again, since offline values do not change. `updateContext` takes the new context at once and changes no value, and `pauseNetwork` and `resumeNetwork` do nothing. `timeout`, `pollingInterval`, `baseUrl` and `pauseWhileBackgrounded` have no effect.

With `ConnectionMode.offline` and no `values`, the client is ready at once and every config returns its in-code default value with the `configStateMissing` reason.

`initialize` throws a `ConfigDirectorInitializationException`, which the SDK exports, when the values cannot be loaded: a value that is `null`, a number that is not finite, on the web an integer beyond 2<sup>

53

</sup>

 − 1 in either direction, a value of a type not listed above, a blank key, or `values` given in `streaming` or `polling` mode. The client stays not ready, so a test run with a wrong value fails instead of passing on in-code default values.

If you come from LaunchDarkly, a map of values is what LaunchDarkly calls "flags from files", and `offline` with no values is its "offline mode".

### Values kept on the device

By default, the client keeps the last values it received from ConfigDirector on the device, through the `shared_preferences` plugin, and serves them on the next launch of the same context until live values arrive. An app opened without a network, after at least one successful connection, shows the values ConfigDirector last sent instead of the in-code default values. Every evaluation served this way reports the reason `cached`, with `isDefaultValue` set to `false`.

The client is not ready while it serves values kept on the device: `isReady` stays `false`, `isInitializing` stays `true`, `initialize` still waits for live values or its `timeout`, and `onClientReady` emits once live values arrive and replace them. `onCachedValuesLoaded` emits when the kept values are loaded, followed by `onConfigsUpdated` listing their keys, and `watch` streams emit the kept values.

The values are kept for the context they were evaluated for, and served on the next launch only when the launch context has the same `kind` and `id`. A launch with another context serves the in-code default values until live values arrive, which then replace the kept values. A context without an `id` matches on the same install, as long as the app has not been uninstalled, because ConfigDirector derives its identifier from the instance ID the SDK stores; a context without an `id` keeps its percentage rollout bucket for as long as that instance ID lasts. Name, traits, app name and app version take no part in the match: live values correct them within a second when there is a network. Once the client is running, updating the context keeps serving the current values until the new context's values arrive, as it does with live values.

Turn it off, or supply your own store, with the `caching` option:

- `enabled`
  - Whether to keep the last values received. With `false`, nothing is read or written, even when a `provider` is given.
  - Defaults to `true`
- `provider`
  - A store of your own, for example an encrypted one, implementing `CachingProvider`: `read()` returns the stored entry or `null`, `write(value)` replaces it, `remove()` deletes it, and `sizeLimit()` returns the largest entry, in bytes, the store accepts. `read`, `write` and `remove` return a `Future`. One provider instance serves one client.
  - Defaults to `shared_preferences`, under a key derived from the client SDK key, with a size limit of 256 KB

```dart [config_director_setup.dart]
final client = ConfigDirectorClient(
  clientSdkKey: 'YOUR-CLIENT-SDK-KEY',
  options: ConfigDirectorClientOptions(
    caching: CachingOptions(enabled: true, provider: MyEncryptedStore()),
  ),
);

await client.initialize();
```

The entry is one string whose format belongs to the SDK and may change between SDK versions; an entry a newer SDK cannot read is removed. Before every write the SDK measures the entry against `sizeLimit()`; above it, the entry is removed and a warning is logged once, so the device never serves values that no longer reflect the set. A provider that fails to read, write or remove is logged once and never stops the client: the worst outcome is the in-code default values. Keeping values on the device is ignored in `offline` mode, and the test client in `testing.dart` keeps nothing.

If you come from Firebase Remote Config, this is what activated values persisting across restarts gives you; if you come from LaunchDarkly, it is what its mobile SDKs do with the flags they cache on the device.

## 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.

```dart [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.

```dart [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:

```dart [home_page.dart]
StreamBuilder<bool>(
  stream: client.watch('dark-mode', false),
  initialData: false,
  builder: (context, snapshot) => MyHomePage(darkMode: snapshot.data ?? false),
)
```

<warning>

`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.

</warning>

## Update the context

A context can be provided when initializing the client:

```dart [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 context can also be updated via `updateContext`:

```dart [main.dart]
import 'package:configdirector_flutter_client_sdk/configdirector_flutter_client_sdk.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. ConfigDirector derives an identifier that lasts until the app is
// uninstalled, or on the web until the site's data is cleared.
await client.updateContext(const ConfigDirectorContext());
```

To evaluate configs for an entity other than a user, such as a tenant, set the context kind with `kind`:

```dart [main.dart]
await client.updateContext(
  const ConfigDirectorContext(
    id: 'acme',
    kind: 'tenant',
    name: 'Acme Inc.',
    traits: {'plan': 'enterprise'},
  ),
);
```

Leaving `kind` out means `user`. A context kind is 1 to 64 lowercase letters, digits, dashes, or underscores. A context with an invalid context kind is ignored: the SDK logs an error, and configs are evaluated as if no context had been given. Context kinds are available from version 1.6.0 of the SDK.

<note>

In client SDKs (browser and mobile), updating the 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 context.<br />


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.

</note>

The SDK keeps an instance ID through the `shared_preferences` plugin until the app is uninstalled, or on the web until the site's data is cleared, and ConfigDirector derives the identifier of a context without an `id` from it. A person who uses the app without signing in keeps that identifier, and their percentage rollout bucket, from one app start to the next, and counts as one [monthly active user](https://docs.configdirector.com/getting-started/pricing-and-billing#billing-metrics) however often they open the app.

## 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.

```dart [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 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 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.

To show a loading state on a slow connection rather than switching from the in-code default value to the evaluated value, end it when `initialize` completes: it completes once the values arrive, and at the latest once the `timeout` elapses. Don't end it when `isInitializing` turns `false`, because that can happen long after the timeout, for example when the app starts without a network.

### `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.

```dart [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.

```dart [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 2<sup>

53

</sup>

 − 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

<table>
<thead>
  <tr>
    <th>
      Control
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

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

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.

```dart [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.


## Sitemap

See the full [sitemap](https://docs.configdirector.com/sitemap.md) for all pages.
