Flutter SDK
Feature flags for Flutter, evaluated by Abrova Flags for the current user and cached on the device. Typed getters, anonymous id, offline cache. Public API key only.
Uses the public API key only. Safe to ship in an app
The app receives values for this user. Targeting rules never reach the device
getBool, getString, getNumber and getJson, each with a default
The last values are stored on the device and served at start and offline
Percentage rollouts also work before sign-in
iOS, Android, web, macOS, Windows and Linux
This page describes abrova_flutter_flags 0.2.0. It is a breaking release: the entry class is now AbrovaFlags, getters are synchronous, and flags are evaluated by the service instead of on the device. Coming from 0.1.x? See Upgrading from 0.1.x at the end of this page.
Flutter 3.13 or higher. Dart 3.0 or higher.
Using an AI assistant? See AI assistants for how to give it these docs.
Installation#
Install from Abrova Center in two steps. First add the package to your pubspec.yaml with the Abrova Center address:
dependencies:
abrova_flutter_flags:
hosted: https://center.abrova.ir/api/packages/abrova/pub
version: ^0.2.0Then run:
flutter pub get
Initialization#
Call AbrovaFlags.init once, before runApp. Use your public API key (ab_live_...).
import 'package:flutter/material.dart';
import 'package:abrova_flutter_flags/abrova_flutter_flags.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await AbrovaFlags.init(const AbrovaFlagsConfig(
apiKey: 'ab_live_xxx',
environment: 'production', // leave out to use the Default values
));
runApp(const MyApp());
}init serves the values cached on the device right away and waits up to initTimeout (5 seconds) for the first answer from the network. If the network is slow, init returns anyway and the fetch continues in the background.
Read flags#
Getters are synchronous and never throw. Each one takes the default to use when the flag is unknown, when no values are available yet, or when the flag has another type.
final flags = AbrovaFlags.instance;
if (flags.isEnabled('new-checkout')) {
// Show the new checkout
}
final dark = flags.getBool('dark-mode', false);
final theme = flags.getString('theme', 'light');
final maxItems = flags.getNumber('max-items', 10);
final layout = flags.getJson('home-layout', {'columns': 2});| Method | Returns |
|---|---|
| isEnabled(key, [defaultValue = false]) | true when the flag is served on for this user |
| getBool(key, defaultValue) | Value of a boolean flag |
| getString(key, defaultValue) | Value of a string flag |
| getNumber(key, defaultValue) | Value of a number flag, as num |
| getJson(key, defaultValue) | Value of a json flag: a map, list, string, number, bool or null |
| getAll() | Every value held, as Map<String, AbrovaFlagResult> |
Details and reasons
Each typed getter has a ...Detail form that also tells you why you got the value.
final d = flags.getStringDetail('theme', 'light');
print('${d.value} ${d.enabled} ${d.variant} ${d.reason} ${d.version}');getBoolDetail, getStringDetail, getNumberDetail and getJsonDetail check the flag type. getDetail(key, [defaultValue]) returns the stored value without a type check.
| Reason | Meaning |
|---|---|
| ON | The flag is on and has no variants. The value is true |
| VARIANT | The flag is on and a variant was served |
| DISABLED | The flag is off in this environment. The flag off value is served |
| NOT_TARGETED | The targeting rules did not match this user. The off value is served |
| OUTSIDE_ROLLOUT | The user is outside the rollout percentage. The off value is served |
| PREREQUISITE_FAILED | A flag this one depends on did not give the required result. The off value is served |
| NO_BUCKETING_ID | A rollout or split needed a user id or anonymous id and none was sent |
| ERROR | The flag could not be evaluated. The off value is served |
| FLAG_NOT_FOUND | No such flag. Your default is returned |
| NOT_READY | No cached or fetched values yet. Your default is returned |
| TYPE_MISMATCH | For example getString on a boolean flag. Your default is returned |
The constants are on AbrovaFlagReason (for example AbrovaFlagReason.notReady). isEnabled is true only for ON and VARIANT.
Identify the user#
Tell the SDK who the user is and what you know about them. Targeting rules and percentage rollouts are evaluated against this context.
// After sign-in
await flags.identify('user-123', attributes: {
'plan': 'pro',
'country': 'IR',
'beta': true,
});
// Replace all attributes
await flags.setAttributes({'plan': 'business'});
// Signed out, attributes stay
await flags.setUserId(null);
// Sign-out: clears the user id, the attributes and the cached values
await flags.reset();- Each change fetches new values after a short pause (250 ms), so several calls in a row send one request.
setAttributesand theattributesofidentifyreplace the whole set. They do not merge.- When the user id changes, the previous user's values are dropped at once. Until the new values arrive, getters return your defaults (reason
NOT_READY). - Attributes are strings, numbers, booleans, null, or lists of those. No nested maps. At most 100 attributes; a string holds at most 1,024 characters.
If you already know the user at start, pass userId and attributes in the config. The SDK then serves that user's cached values immediately and sends one request instead of two.
await AbrovaFlags.init(AbrovaFlagsConfig(
apiKey: 'ab_live_xxx',
environment: 'production',
userId: currentUser?.id,
attributes: {'plan': 'pro'},
));Anonymous id
Every install gets a random anonymous id (a UUID). It is stored on the device and sent with every request, so a 10% rollout reaches about 10% of signed-out installs too. When a user id is set, rollouts use the user id instead.
The anonymous id survives reset(). Call resetAnonymousId() to replace it. Read it with flags.anonymousId.
Updates and refresh#
// New values arrived
final sub = flags.onChange.listen((_) => setState(() {}));
// A request failed
flags.onError.listen((e) => debugPrint('${e.statusCode} ${e.code} ${e.message}'));
// Fetch now
await flags.refresh();The SDK fetches values:
- on
init, - after
identify,setUserId,setAttributes,resetandresetAnonymousId, - when the app returns to the foreground and the last fetch is older than 15 minutes,
- on
refresh(), - on a timer, only if you set
refreshIntervalSeconds.
Every request for values is counted as one flag evaluation, whatever the number of flags it returns (100,000 evaluations are free every month; the price above that is set at launch). This is why polling is off by default. identify, setUserId and setAttributes send a request every time you call them, also when nothing changed, so call them when the user or the attributes really change, not on every build.
Offline and errors#
The last values are stored with shared_preferences and served at the next start, before the network answers. A failed request never clears them.
- Offline or server error: the last values keep being served. The SDK retries with a growing delay (up to 5 minutes).
- Rate limited (429): the SDK waits the time the service asks for, then retries.
- 401, 403 or 404 (wrong key, Flags not enabled for the project, unknown environment): automatic fetches stop until you call
refresh()or restart the app. Checkflags.lastError. - Before the first values:
flags.isReadyisfalseand getters return your defaults.await flags.whenReadycompletes when values are available.
Call flags.close() when you no longer need the client.
Configuration Options#
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | String | required | Your public API key (ab_live_xxx) |
| environment | String? | null | Environment key (production, staging, development or your own). null uses the Default values |
| userId | String? | null | Signed-in user id at start |
| attributes | Map<String, Object?> | {} | Targeting attributes at start |
| flagKeys | List<String>? | null | Only fetch these flags. null fetches every flag of the project |
| refreshIntervalSeconds | int | 0 | Polling while the app is in the foreground. 0 is off; otherwise at least 60 |
| initTimeout | Duration | 5 seconds | How long init waits for the first answer |
| debug | bool | false | Verbose logging. Errors are always logged |
Testing#
AbrovaFlags.create builds a client without touching AbrovaFlags.instance. Pass MemoryFlagsStorage() and your own http.Client (for example MockClient from package:http/testing.dart).
final flags = await AbrovaFlags.create( const AbrovaFlagsConfig(apiKey: 'ab_live_xxx'), storage: MemoryFlagsStorage(), httpClient: mockClient, observeAppLifecycle: false, );
Upgrading from 0.1.x#
0.2.0 is a new API, and 0.1.x clients stop receiving flags. Update the package and change these calls:
| 0.1.x | 0.2.0 |
|---|---|
| AbrovaFeatureFlags.init(AbrovaFeatureFlagsConfig(...)) | AbrovaFlags.init(AbrovaFlagsConfig(...)) |
| AbrovaFeatureFlags.instance | AbrovaFlags.instance |
| await flags.isEnabled('k') | flags.isEnabled('k') (synchronous) |
| await flags.getValue('k', defaultValue: x) | getBool / getString / getNumber / getJson('k', x) |
| await flags.evaluate('k') | getDetail('k', x), which adds reason and version |
| setUserAttributes(map), merged | setAttributes(map), replaces. Or identify(id, attributes: map) |
| getAll() returned flag configurations | getAll() returns this user's values only |
| callback: (event, data) | onChange and onError streams |
| enableOfflineCache, isOnline, dispose() | Always cached; lastError; close() |
- The class was renamed to
AbrovaFlags, so it no longer clashes withAbrovaFeatureFlagsin the Abrova A/B Testing Flutter SDK. You can import both packages. connectivity_plusandcryptoare no longer needed by the SDK.- Rollouts use a new bucketing method, so users are assigned to rollouts and variants again once.
Best Practices#
Call AbrovaFlags.init() in main() before runApp()
If a user is signed in, pass userId and attributes in the config so the cached values are served at once
The default is what your users see before the first values and when a flag is removed
Call reset() so the next user never sees the previous user's values
Values refresh on start, on context changes and on app resume. Turn polling on only if you need faster updates