Zenmanage Flutter SDK

pub package CI Coverage Gate Dart SDK Version Flutter SDK Version

The official Dart/Flutter SDK for Zenmanage feature flags and experiments.

Add feature flags to your Flutter app in minutes. Control feature rollouts, A/B test, and manage configurations without deploying code.

Why Zenmanage?

  • ๐Ÿš€ Fast: Rules cached locally - ~1ms evaluation time
  • ๐ŸŽฏ Targeted: Roll out features to specific users, organizations, or segments
  • ๐Ÿ›ก๏ธ Safe: Graceful fallbacks and error handling built-in
  • ๐Ÿ“Š Insightful: Automatic usage tracking (optional)
  • ๐Ÿงช Testable: Easy to mock in tests
  • โšก Async-first: Designed for mobile with async/await throughout

Installation

Add this to your pubspec.yaml:

dependencies:
  zenmanage: ^1.0.0

Then run:

flutter pub get

Requirements:

  • Dart 3.0+
  • Flutter 3.0+

Quick Start

1. Get Your Mobile Token

Get your mobile token (mob_...) from zenmanage.com. Use a client token (cli_...) instead for Flutter web. Server tokens are not accepted in Flutter apps.

2. Initialize the SDK

import 'package:zenmanage/zenmanage.dart';

final zenmanage = Zenmanage(
  ConfigBuilder.create()
      .withEnvironmentToken('mob_your_token_here')
      .build(),
);

// Optional: prefetch rules during startup
await zenmanage.initialize();

3. Check a Feature Flag

final flag = await zenmanage.flags().single('new-dashboard');

if (flag.isEnabled()) {
  showNewDashboard();
} else {
  showOldDashboard();
}

That's it! ๐ŸŽ‰

Flutter Bindings

Use ZenmanageInitializer for app-wide async initialization and ZenmanageFlagBuilder for widget-level consumption.

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

  final Zenmanage zenmanage;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ZenmanageInitializer(
        zenmanage: zenmanage,
        loadingBuilder: (_) => const CircularProgressIndicator(),
        child: ZenmanageFlagBuilder(
          flagKey: 'new-dashboard',
          defaultValue: false,
          builder: (context, snapshot) {
            if (!snapshot.hasData) {
              return const SizedBox.shrink();
            }
            return snapshot.data!.isEnabled()
                ? const NewDashboard()
                : const OldDashboard();
          },
        ),
      ),
    );
  }
}

Common Use Cases

Roll Out a New Feature Gradually

import 'package:zenmanage/zenmanage.dart';

// Create user context
final userContext = Context.user(
  id: 'user_123',
  name: 'John Doe',
  attributes: {'country': 'US', 'plan': 'premium'},
);

// Check if user has access to beta features
final betaAccess = await zenmanage.flags()
    .withContext(userContext)
    .single('beta-program');

if (betaAccess.isEnabled()) {
  enableBetaFeatures();
}

A/B Testing

final userContext = Context.user(id: userId);

final variant = await zenmanage.flags()
    .withContext(userContext)
    .single('checkout-flow');

if (variant.asString() == 'test_v1') {
  showCheckoutV1();
} else if (variant.asString() == 'test_v2') {
  showCheckoutV2();
} else {
  showCheckoutControl();
}

Multiple Contexts

final userContext = Context.user(id: 'user_123');
final orgContext = Context.organization(id: 'org_456');

final enterpriseFeature = await zenmanage.flags()
    .withContexts([userContext, orgContext])
    .single('enterprise-analytics');

Handling Missing Flags

// Option 1: Use singleOrDefault
final flag = await zenmanage.flags().singleOrDefault(
  'optional-feature',
  Flag(key: 'optional-feature', value: false, type: 'boolean'),
);

// Option 2: Use catchError
final flag = await zenmanage.flags()
    .single('optional-feature')
    .catchError((e) => Flag(
      key: 'optional-feature',
      value: false,
      type: 'boolean',
    ));

Type-Safe Value Access

Flags support type-safe accessors:

final flag = await zenmanage.flags().single('my-flag');

// Boolean access
if (flag.asBool()) { }

// String access
final theme = flag.asString();

// Number access
final limit = flag.asInt();
final timeout = flag.asDouble();

// JSON/Map access (a json flag whose decoded value is a JSON object)
final config = flag.asJson();

// List access (a json flag whose decoded value is a JSON array)
final items = flag.asList<String>();

// Raw value
final value = flag.getValue();

Value Types & Cross-Type Coercion

A flag's type is one of boolean, string, number, or json. A json flag's decoded value is either map-shaped (a JSON object, read with asJson()) or list-shaped (a JSON array, read with asList<T>()).

asJson() and asList<T>() never throw. Each returns the decoded value when the flag's json value has the matching shape, and a safe zero value ({} or []) for every other case โ€” including a json flag whose value has the other shape (e.g. calling asJson() on a list-shaped json flag), or a flag that isn't json-typed at all:

Called on โ†’
Flag type โ†“
asBool asString asNumber asJson (map) asList (list)
boolean the bool value-derived* value-derived* {} []
string value-derived* the string value-derived* {} []
number value-derived* value-derived* the number {} []
json (map-shaped) true "" 0 the decoded map []
json (list-shaped) true "" 0 {} the decoded list

* asBool(), asString(), and asNumber() on a boolean/string/number flag convert the underlying value directly (e.g. asBool() on the string "true" returns true, asNumber() on "42" returns 42) โ€” see the accessor doc comments in lib/src/flag/flag.dart for the exact rules. Calling asBool(), asString(), or asNumber() on a json-typed flag never attempts a lossy conversion of the decoded map/list: it always falls back to the same safe zero value the table shows (true/""/0), the same way asJson()/asList() do for non-json flags.

Caching Strategies

In-Memory Cache (Default)

Fastest but lost on app restart:

final zenmanage = Zenmanage(
  ConfigBuilder.create()
      .withEnvironmentToken('mob_your_token_here')
      .withCache(InMemoryCache())
      .build(),
);

SharedPreferences Cache (Persistent)

Persists rules across app restarts:

import 'package:zenmanage/zenmanage.dart';

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

final zenmanage = Zenmanage(
  ConfigBuilder.create()
      .withEnvironmentToken('mob_your_token_here')
      .withCache(cache)
      .withCacheTtl(86400) // 24 hours
      .build(),
);

No Cache

Always fresh from API (not recommended for production):

final zenmanage = Zenmanage(
  ConfigBuilder.create()
      .withEnvironmentToken('mob_your_token_here')
      .withCache(NullCache())
      .build(),
);

Configuration

Environment Token

Use a mobile key (mob_*) in iOS and Android apps, or a client key (cli_*) for Flutter web, where there is no mobile key. Server keys (srv_*) are rejected when the config is built: a key bundled into an app can be extracted by anyone.

ConfigBuilder.create()
    .withEnvironmentToken('mob_your_token_here') // Required
    .build()

API Endpoint

Override the API endpoint:

ConfigBuilder.create()
    .withEnvironmentToken('mob_your_token_here')
    .withApiEndpoint('https://api.example.com') // Default: https://api.zenmanage.com
    .build()

Cache TTL

Control how long rules are cached (in seconds):

ConfigBuilder.create()
    .withEnvironmentToken('mob_your_token_here')
    .withCacheTtl(7200) // 2 hours
    .build()

Usage Reporting

Enable/disable usage reporting (default: enabled):

ConfigBuilder.create()
    .withEnvironmentToken('mob_your_token_here')
    .withUsageReporting(false) // Disable usage tracking
    .build()

Request Timeout and Retries

A rules request waits up to 10 seconds by default. If it fails, the SDK tries again, up to 3 attempts in total with 100ms and then 200ms of backoff. A rejected token (HTTP 401 or 403) is not retried, since another attempt with the same token can't succeed. This is the same behavior as the other Zenmanage SDKs.

ConfigBuilder.create()
    .withEnvironmentToken('mob_your_token_here')
    .withRequestTimeout(const Duration(seconds: 5)) // Default: 10 seconds
    .build()

Usage reports are fire-and-forget and wait at most 5 seconds (or the request timeout, if that is shorter).

Error Handling

The SDK throws typed exceptions for different error conditions:

import 'package:zenmanage/zenmanage.dart';

try {
  final flag = await zenmanage.flags().single('my-flag');
} on ConfigurationException catch (e) {
  print('Configuration error: ${e.message}');
} on ApiException catch (e) {
  print('API error (HTTP ${e.statusCode}): ${e.message}');
} on FlagNotFoundException catch (e) {
  print('Flag not found: ${e.message}');
} on TypeConversionException catch (e) {
  print('Type conversion error: ${e.message}');
} on ZenmanageException catch (e) {
  print('General Zenmanage error: ${e.message}');
}

Logging

The SDK logs nothing by default. To see what it is doing (rule fetch attempts, retries, failures), pass a ZenmanageLogger to the config:

import 'package:zenmanage/zenmanage.dart';

class AppLogger implements ZenmanageLogger {
  @override
  void debug(String message, [Map<String, Object?>? metadata]) =>
      debugPrint('[zenmanage] $message ${metadata ?? ''}');

  @override
  void info(String message, [Map<String, Object?>? metadata]) =>
      debugPrint('[zenmanage] $message ${metadata ?? ''}');

  @override
  void warn(String message, [Map<String, Object?>? metadata]) =>
      debugPrint('[zenmanage] WARN $message ${metadata ?? ''}');

  @override
  void error(String message, [Map<String, Object?>? metadata]) =>
      debugPrint('[zenmanage] ERROR $message ${metadata ?? ''}');
}

final config = ConfigBuilder.create()
    .withEnvironmentToken('mob_your_token_here')
    .withLogger(AppLogger())
    .build();

Testing

Mock the API

import 'package:mocktail/mocktail.dart';
import 'package:zenmanage/zenmanage.dart';

void main() {
  test('shows feature when flag is enabled', () async {
    // Use InMemoryCache or NullCache for deterministic tests
    final zenmanage = Zenmanage(
      ConfigBuilder.create()
          .withEnvironmentToken('mob_test_token')
          .withCache(NullCache())
          .build(),
    );

    // Your test here
  });
}

Use Default Values

void main() {
  test('handles missing flags gracefully', () async {
    final flag = await zenmanage.flags().singleOrDefault(
      'optional-feature',
      Flag(key: 'optional-feature', value: false, type: 'boolean'),
    );

    expect(flag.isEnabled(), isFalse);
  });
}

Examples

See the example/ directory for complete examples:

Run a script example from the package root with flutter test (plain dart can't load a Flutter package), or run the example app on a device:

flutter test example/simple_flags.dart

cd example && flutter run

API Reference

Zenmanage

Main SDK class:

class Zenmanage {
  Future<void> initialize({List<Context>? contexts}); // Async startup prefetch
  FlagManager flags(); // Get the flag manager
  String get cacheBackendName; // Get current cache backend name
  Future<void> clearCache(); // Clear all cached rules
}

Flutter Widgets

class ZenmanageInitializer extends StatefulWidget {
  const ZenmanageInitializer({
    required Zenmanage zenmanage,
    required Widget child,
    List<Context> contexts = const <Context>[],
    WidgetBuilder? loadingBuilder,
    Widget Function(BuildContext, Object, StackTrace?)? errorBuilder,
  });
}

class ZenmanageFlagBuilder extends StatefulWidget {
  const ZenmanageFlagBuilder({
    required String flagKey,
    required Widget Function(BuildContext, AsyncSnapshot<Flag>) builder,
    dynamic defaultValue,
    Context? context,
  });
}

ConfigBuilder

Fluent configuration builder:

class ConfigBuilder {
  static ConfigBuilder create();
  ConfigBuilder withEnvironmentToken(String token);
  ConfigBuilder withCache(Cache cache);
  ConfigBuilder withApiEndpoint(String endpoint);
  ConfigBuilder withCacheTtl(int ttl);
  ConfigBuilder withUsageReporting(bool enabled);
  ConfigBuilder withRequestTimeout(Duration timeout);
  ConfigBuilder withLogger(ZenmanageLogger logger);
  Config build();
}

FlagManager

Flag evaluation:

class FlagManager {
  Future<Flag> single(String flagKey);
  Future<List<Flag>> all();
  Future<Flag> singleOrDefault(String flagKey, Flag defaultFlag);
  FlagManager withContext(Context context);
  FlagManager withContexts(List<Context> contexts);
  FlagManager clearContext();
  Future<void> clearCache();
}

Flag

Feature flag value:

class Flag {
  String get key;
  dynamic get value;
  String get type;
  
  bool isEnabled();
  dynamic getValue();
  bool asBool();
  String asString();
  num asNumber();
  int asInt();
  double asDouble();
  Map<String, dynamic> asJson();
  List<T> asList<T>();
}

Context

Entity context for targeting:

class Context {
  factory Context({
    required String type,
    required String identifier,
    String? name,
    Map<String, dynamic>? attributes,
  });
  
  factory Context.user({
    required String id,
    String? name,
    Map<String, dynamic>? attributes,
  });
  
  factory Context.organization({
    required String id,
    String? name,
    Map<String, dynamic>? attributes,
  });
  
  factory Context.account({
    required String id,
    String? name,
    Map<String, dynamic>? attributes,
  });
}

Troubleshooting

"Invalid environment token"

Ensure you're using a mobile token that starts with mob_ (or a client token that starts with cli_ for Flutter web). Server tokens (srv_) are rejected. Get one from zenmanage.com.

"Flag not found"

Check that the flag key matches exactly (case-sensitive). Use singleOrDefault or catchError for optional flags.

No cache persisting

Use SharedPreferencesCache instead of InMemoryCache for persistence across app restarts.

Tests fail with API errors

Use NullCache and provide default values with singleOrDefault.

Contributing

We welcome contributions! Please see CONTRIBUTING.md for guidelines.

License

MIT - See LICENSE file for details

Support

Libraries

zenmanage
Zenmanage Dart/Flutter SDK