zenmanage 1.0.0
zenmanage: ^1.0.0 copied to clipboard
Official Dart/Flutter SDK for Zenmanage feature flags with local evaluation. Control feature rollouts, A/B test, and manage configurations without deploying code.
Zenmanage Flutter SDK #
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:
- simple_flags.dart - Basic flag operations
- context_based_flags.dart - Using context for personalization
- ab_testing.dart - A/B testing setup
- percentage_rollouts.dart - Gradual feature rollouts
- caching.dart - Caching strategies
- defaults.dart - Default values and error handling
- lib/main.dart - Runnable Flutter app example
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 #
- ๐ Documentation
- ๐ Issue Tracker
- ๐ฌ Discussions
- ๐ Zenmanage Website