announcements

Introducing the Zenmanage Flutter SDK

The Zenmanage Flutter SDK is a Dart package on pub.dev that evaluates flags on the device, ships widgets for app-wide setup, and keeps rollouts stable across iOS, Android, web, and desktop.

The Zenmanage Flutter SDK is a Dart package on pub.dev. Run flutter pub add zenmanage, read flags on the device against locally cached rules, and use the same flags in your iOS, Android, web, and desktop builds.

Flutter gives you one codebase for every screen. Releasing doesn't work that way. The iOS build waits on App Store review, the Android build rolls out through Play, the web build ships when your pipeline runs, and a desktop build ships when someone updates it. Put a risky change in all of them at once and you have several places for it to break, each with a different way back.

A flag gives you one lever for all of them. Wrap the change, ship it switched off, then turn it on for 1% of users from the dashboard. Your Dart code reads the flag, so the same line runs on every target. The Flutter SDK covers the parts of that which are specific to Flutter: widgets that need a flag before they paint, async reads, and a phone that was offline when the app opened.

What it is

zenmanage evaluates flags locally. It fetches your rules, then reads each flag against them on the device, so a read doesn't wait on a per-flag network call. It's the same local-evaluation model as our iOS and Android SDKs, and it evaluates rules in the same order. It needs Dart 3.0 and Flutter 3.0 or later.

The package is plain Dart on top of http and shared_preferences, so it runs wherever those do. pub.dev lists Android, iOS, web, macOS, Windows, and Linux. It also reports flag usage to Zenmanage in the background, so you can see in the dashboard which flags your app reads. You can turn that off with withUsageReporting(false).

Getting started

Add the package:

flutter pub add zenmanage

That adds zenmanage: ^1.0.0 to your pubspec.yaml and runs flutter pub get.

Create one Zenmanage when the app starts and share it. It keeps the loaded rules in memory, so building one per screen throws that work away. ZenmanageInitializer fetches the rules once and makes the instance available to everything below it:

import 'package:flutter/material.dart';
import 'package:zenmanage/zenmanage.dart';

void main() {
  final zenmanage = Zenmanage(
    ConfigBuilder.create()
        .withEnvironmentToken('mob_your_mobile_key_here')
        .build(),
  );

  runApp(ShopApp(zenmanage: zenmanage));
}

class ShopApp extends StatelessWidget {
  const ShopApp({super.key, required this.zenmanage});

  final Zenmanage zenmanage;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ZenmanageInitializer(
        zenmanage: zenmanage,
        loadingBuilder: (_) => const Center(child: CircularProgressIndicator()),
        errorBuilder: (_, error, stackTrace) => const LegacyHome(),
        child: const ShopHome(),
      ),
    );
  }
}

ZenmanageInitializer shows loadingBuilder while it loads and builds your child once the rules are in. If the load fails and nothing is saved on the device, it shows errorBuilder instead. That screen sits outside the provider, so it can't read flags. Make it the version of your screen that you'd show with every flag at its default. If you don't pass an errorBuilder, the user gets a blank screen.

Use a mobile key (mob_) in iOS and Android apps. Flutter web has no mobile key, so use a client key (cli_) there. Server keys (srv_) are rejected when you build the config, because anything bundled in an app can be pulled out by someone with the file.

Read a flag with async/await

Outside a widget, read a flag with single. It's async because the first read may need to fetch rules. After that it's a local lookup:

Future<bool> useNewCheckout(Zenmanage zenmanage) async {
  try {
    final flag = await zenmanage
        .flags()
        .single('new-checkout', defaultValue: false);

    return flag.isEnabled();
  } on ApiException {
    // The rules couldn't load and nothing is saved. Show the old checkout.
    return false;
  }
}

The defaultValue is what you get when the flag doesn't exist. It isn't a catch-all for a failed load. If the rules can't be fetched and nothing is cached, single throws an ApiException, so catch it as above. singleOrDefault does the catching for you and returns the default on any error.

Flags aren't only booleans. Each one has a matching accessor: asString(), asInt(), asDouble(), asJson() for an object, and asList<T>() for an array. Calling the wrong one doesn't throw. It converts the value where it can and otherwise returns a zero value ({}, [], "", or 0), so check the flag's type in the dashboard if a value looks empty.

Read a flag in a widget

Most flags decide what a widget draws. ZenmanageFlagBuilder reads one flag and hands your builder an AsyncSnapshot<Flag>, the same shape a FutureBuilder gives you:

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

  @override
  Widget build(BuildContext context) {
    return ZenmanageFlagBuilder(
      flagKey: 'one-tap-checkout',
      defaultValue: false,
      builder: (context, snapshot) {
        final oneTap = snapshot.data?.isEnabled() ?? false;

        return oneTap ? const OneTapButton() : const CartButton();
      },
    );
  }
}

It needs a ZenmanageInitializer above it in the tree. With a defaultValue, a failed read falls back to that value, so the snapshot always has data once it resolves. Leave the defaultValue out and a failed read puts the error in snapshot.error, which your builder has to handle.

Phased rollouts across every platform

A phased rollout is risk management. You want a small group to hit any problem before everyone else does, and you want to widen or stop the rollout without shipping a build. Set the percentage on the flag, and pass the app a stable identifier for the user:

final userContext = Context.user(
  id: userId,
  attributes: {'country': 'US', 'plan': 'premium'},
);

final flag = await zenmanage
    .flags()
    .withContext(userContext)
    .single('new-checkout', defaultValue: false);

The SDK hashes the rollout's salt together with that identifier using CRC32 and maps the result to a bucket from 0 to 99. The user is in the rollout when their bucket falls below the percentage. The same identifier and salt always give the same bucket, so a user stays in or out across launches, and raising the percentage only adds people. Attributes let your rules target by plan, country, or anything else you pass in.

This is where one codebase pays off. The bucket depends only on the salt and the identifier, not on the platform, so a user who is in your 10% on their iPhone is in it on their laptop too. The hash is the same one our other SDKs use, so that holds even if part of your product is native Swift or Kotlin. The bucketing post explains why that stability matters, and the progressive rollouts post covers the stages themselves.

Use an identifier that doesn't change when a user reinstalls the app or signs in on a new device. A random ID generated on each install would move people between buckets.

Keep the rules between launches

By default the SDK keeps rules in memory, so they're gone when the app closes. SharedPreferencesCache saves them on the device, which matters on a phone that opens in a tunnel or on airplane mode:

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

  final cache = SharedPreferencesCache(namespace: 'zenmanage');
  await cache.initialize();

  final zenmanage = Zenmanage(
    ConfigBuilder.create()
        .withEnvironmentToken('mob_your_mobile_key_here')
        .withCache(cache)
        .withCacheTtl(900)
        .build(),
  );

  runApp(ShopApp(zenmanage: zenmanage));
}

It helps to know how the two settings work together:

  1. On launch, the SDK fetches fresh rules and saves a copy to the device.
  2. While the app runs, it reuses the rules it holds in memory until withCacheTtl runs out. The default is 3600 seconds, and the value is in seconds.
  3. If a fetch fails, it retries up to three times in total. If it still fails, it evaluates against the copy on the device.
  4. If there's no saved copy either, single throws, and ZenmanageInitializer shows your errorBuilder.

The saved copy has no expiry of its own. It's the fallback, not the first choice, so a launch with a working connection always starts from fresh rules. Call zenmanage.clearCache() when you want the next read to fetch again instead of waiting for the TTL.

Things to know before you ship

Startup waits on the network

ZenmanageInitializer doesn't build your child until the rules load or the load fails. Each request waits up to 10 seconds, with up to three attempts, so a dead connection can hold the loading screen for a while before the fallback kicks in. If that's too long for your app, lower it with withRequestTimeout(const Duration(seconds: 3)).

The context sticks to the shared manager

zenmanage.flags() returns the same manager every time, and withContext stores the context on it. It stays there until you set another one or call clearContext(), so a later read without withContext still uses it. Two reads in flight with different contexts can also overwrite each other. Set the context once when a user signs in, clear it when they sign out, and keep to one user at a time.

No stable identifier means no rollout

A read with no context, or a context with an empty identifier, never lands in a percentage rollout. It gets the value the flag has outside the rollout. If your 10% rollout looks like it's doing nothing, check that the context you pass carries the same user ID every time.

A change reaches a running app when its rules refresh

A flag you change in the dashboard reaches a running app the next time it fetches, which is after the TTL runs out or when the app starts again. That's usually what you want, but it means the rollout you started this morning might not be what every open session sees until then.

Where to go next

The Flutter quickstart covers the setup in more detail, including JSON flags and handling errors by type. The zenmanage package on pub.dev lists every released version to pin, and the zenmanage-flutter repository has the full README and the example app. The SDK overview lists every language we support.

Enjoyed this article?

Share it with your network.