A/B Testing — Feature Flags add-on
AbrovaFeatureFlags is the standalone feature flags client that ships in the Flutter A/B Testing package (abrova_ab_testing). If you only need feature flags without experiments, use it or the dedicated Abrova Flags service.
This page documents AbrovaFeatureFlags, the standalone Flutter feature flags client that ships in the A/B Testing package abrova_ab_testing. If you want full feature flag platform support with iOS, Android, React Native, Node.js, and Next.js SDKs, see the dedicated Abrova Flags service instead. This page is for apps that use (or plan to use) the A/B Testing SDK and just need lightweight feature flags in Flutter. The other A/B Testing SDKs expose flags as methods on the main client (isFeatureEnabled, getFeatureValue, getFeatureFlags); see their SDK pages.
AbrovaFeatureFlags is a standalone class for feature flag management. No experiment setup, no event tracking: just feature flags. Use it if you only need feature flags without experiments. It is initialized on its own and does not need AbrovaAbTesting.init().
Evaluating a flag for a user (isEnabled, getValue) needs a signed user token, minted by your backend. Add a tokenProvider to your config (step 3). Listing flags (getAll, refresh) works with the API key alone, so the app can load them before sign-in.
New in 0.2.2: you can initialize before sign-in. The SDK waits for the user token instead of failing (see step 3).
- Dart 3.0.0 or higher (Flutter 3.10+).
- Pure Dart: works on Android, iOS, Web, macOS, Windows and Linux.
- A backend endpoint that mints Abrova user tokens (see step 3).
Installation#
The add-on ships in abrova_ab_testing. Install it from Abrova Center: add the package to your pubspec.yaml with the Abrova Center address:
dependencies:
abrova_ab_testing:
hosted: https://center.abrova.ir/api/packages/abrova/pub
version: ^0.2.3Install:
flutter pub get
Platform-specific setup
Android: Add Internet permission in android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Quick Start#
Get up and running in a few lines of code:
import 'package:abrova_ab_testing/abrova_ab_testing.dart';
// Initialize
final flags = await AbrovaFeatureFlags.init(
AbrovaFeatureFlagsConfig(
apiKey: 'YOUR_API_KEY',
tokenProvider: fetchAbrovaToken, // returns a user token from your backend (step 3)
),
);
// Set the signed-in user
await flags.setUserId('user-123');
// Check a flag
if (await flags.isEnabled('dark-mode')) {
// Feature is enabled
}User tokens#
Anyone can read the API key out of your app, so on its own it can't prove which user a request is for. Your backend can: it holds your project's server secret and mints a short-lived token for the signed-in user. The SDK sends that token, and the service takes the user from it.
Mint the token on your server
curl -X POST https://auth.abrova.ir/users/token \
-H "x-api-key: $ABROVA_API_KEY" \
-H "x-server-secret: $ABROVA_SERVER_SECRET" \
-H "Content-Type: application/json" \
-d '{"userId": "user-123", "expiresIn": 3600}'
# → { "token": "eyJhbGciOi…", "userId": "user-123", "expiresIn": 3600, "expiresAt": "…" }expiresIn is in seconds: 3600 (1 hour) by default, 86400 (24 hours) maximum. On a Node.js backend, createUserToken(userId) from the Node.js SDK makes this call for you. Never put the server secret in the app.
One token for A/B Testing, Chat and Sync
The token is the same Abrova user token used by Abrova Chat and Abrova Sync. If your app uses them too, mint one token and give it to every SDK's tokenProvider instead of fetching one per product.
Give it to the SDK
Pass a tokenProvider that calls your backend. The SDK caches the token, asks again shortly before it expires, and asks once more if the service reports it expired. Return null while no one is signed in.
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<String?> fetchAbrovaToken() async {
if (sessionToken == null) return null; // no one signed in: the SDK waits
// Call YOUR backend, authenticated as the signed-in user
final res = await http.post(
Uri.parse('https://your-api.example.com/abrova-token'),
headers: {'Authorization': 'Bearer $sessionToken'}, // your app's own session
);
if (res.statusCode != 200) throw Exception('Could not get an Abrova token');
return jsonDecode(res.body)['token'] as String;
}If you already hold a token, you can pass it as userToken instead, and hand over a newer one with setUserToken(token). tokenProvider is preferred: a static token expires.
Before your user signs in
You can call init before anyone is signed in. While there is no token (tokenProvider returned null or failed, or none was set) the SDK is awaiting a user token: flags are answered from the cached flag list, or your default is returned, and nothing is sent. No error is reported.
final flags = AbrovaFeatureFlags.instance;
flags.isAwaitingUserToken; // true while there is no user token
flags.onAwaitingUserToken(() { }); // called each time the SDK starts waiting
// After sign-in
await flags.refreshUserToken(); // with a tokenProvider: asks it again now
await flags.setUserToken(token); // without one: hand the token over
// After sign-out
await flags.clearUserId(); // or setUserToken(null)The init callback reports the same moment as an awaitingUserToken event. To add or remove the provider after init, call setTokenProvider(provider).
A token is valid until it expires, so store it with the user's session (Keychain, Keystore or secure storage) and return it from your tokenProvider across app launches. Your app then only calls your backend when the token is close to expiry, not on every start.
Initialization#
Initialize the SDK in your app's main function. The SDK uses a singleton pattern — initialize once, access anywhere.
import 'package:flutter/material.dart';
import 'package:abrova_ab_testing/abrova_ab_testing.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize with full config
await AbrovaFeatureFlags.init(
AbrovaFeatureFlagsConfig(
apiKey: 'YOUR_API_KEY',
tokenProvider: fetchAbrovaToken, // User token from your backend
debug: true, // Enable debug logging
enableOfflineCache: true, // Cache flags for offline use (default: true)
),
callback: (event, data) {
print('FeatureFlags event: $event, data: $data');
},
);
runApp(const MyApp());
}
// Access the instance anywhere after init
final flags = AbrovaFeatureFlags.instance;Configuration Options
| Option | Type | Required | Description |
|---|---|---|---|
| apiKey | String | required | Your project API key |
| baseUrl | String | https://abtest.abrova.ir | API base URL. Must be https; plain http is accepted only for localhost, 127.0.0.1 and 10.0.2.2 |
| tokenProvider | FutureOr<String?> Function() | null | Returns a user token minted by your backend, or null while no one is signed in. Needed to evaluate flags for a user |
| userToken | String | null | A token you already hold (tokenProvider is preferred: a static token expires) |
| debug | bool | false | Enable debug logging |
| enableOfflineCache | bool | true | Cache flags locally for offline use |
User Identification#
Set a user ID for rollout percentage targeting and user-based flag evaluation. Set user attributes for advanced targeting rules. The service takes the user from the token, so the user ID must match the user your tokenProvider returns a token for.
final flags = AbrovaFeatureFlags.instance;
// Set user ID (persisted across sessions)
await flags.setUserId('user-123');
// Forget the user at sign-out
await flags.clearUserId();
// Get current user ID
final userId = flags.getUserId();
// Set user attributes for targeting rules
flags.setUserAttributes({
'plan': 'premium',
'country': 'US',
'age': 25,
'is_beta_user': true,
});Calling setUserId with a different user, handing over a token for a different user, or calling clearUserId() clears the previous user's attributes and token. The next evaluation asks your tokenProvider for the new user's token, so set the new user's attributes after the switch.
Checking Feature Flags#
Boolean Flags (isEnabled)
Check if a feature flag is enabled. Returns the server-evaluated result for the user (sent with the user token). If the service can't be asked (offline, or no user token yet), the SDK uses the service's last answer for that user, then the cached flag list, and returns your default when the flag isn't cached.
final flags = AbrovaFeatureFlags.instance;
// Simple check
if (await flags.isEnabled('dark-mode')) {
enableDarkMode();
}
// With default value (returned if flag not found)
final showBanner = await flags.isEnabled('promo-banner', defaultValue: false);Value Flags (getValue)
Get the value of a feature flag. Useful for multivariate flags that return strings, numbers, or JSON.
final flags = AbrovaFeatureFlags.instance;
// Get a string value
final buttonColor = await flags.getValue('button-color', defaultValue: 'blue');
// Get a numeric value
final maxRetries = await flags.getValue('max-retries', defaultValue: 3);
// Get a JSON config value
final config = await flags.getValue('onboarding-config', defaultValue: {});Get All Flags
// Fetch all flags (server call with cache fallback; API key only)
final allFlags = await AbrovaFeatureFlags.instance.getAll();
for (final flag in allFlags) {
print('${flag.key}: enabled=${flag.enabled}, value=${flag.defaultValue}');
if (flag.variants != null) {
for (final variant in flag.variants!) {
print(' variant ${variant.key}: ${variant.value} (weight: ${variant.weight})');
}
}
}Targeting Rules#
Feature flags support advanced targeting rules based on user attributes. Rules are configured in the dashboard and evaluated by the service. When the service can't be asked, the SDK falls back to the cached flag list.
Supported Operators
| Option | Type | Required | Description |
|---|---|---|---|
| equals | - | - | Exact match: {"country": {"equals": "US"}} |
| notEquals | - | - | Not equal: {"plan": {"notEquals": "free"}} |
| in | - | - | Value in list: {"country": {"in": ["US", "CA"]}} |
| notIn | - | - | Value not in list: {"plan": {"notIn": ["free"]}} |
| greaterThan | - | - | Numeric comparison: {"age": {"greaterThan": 18}} |
| lessThan | - | - | Numeric comparison: {"age": {"lessThan": 65}} |
| greaterThanOrEqual | - | - | Numeric comparison: {"age": {"greaterThanOrEqual": 18}} |
| lessThanOrEqual | - | - | Numeric comparison: {"age": {"lessThanOrEqual": 65}} |
| contains | - | - | String contains: {"email": {"contains": "@company.com"}} |
| regex | - | - | Regex match: {"email": {"regex": ".*@company\.com$"}} |
| exists | - | - | Attribute exists: {"premium": {"exists": true}} |
Setting Attributes for Targeting
// Set attributes that match your targeting rules
AbrovaFeatureFlags.instance.setUserAttributes({
'country': 'US',
'plan': 'premium',
'age': 25,
'email': 'user@company.com',
});
// Flag with targeting rule {"country": {"in": ["US", "CA"]}}
// will be enabled for this user
final enabled = await AbrovaFeatureFlags.instance.isEnabled('us-feature');Rollout Percentage#
Feature flags support gradual rollouts using percentage-based targeting. Rollout is decided per user, so the same user always gets the same result.
// User ID is required for rollout percentage to work
await AbrovaFeatureFlags.instance.setUserId('user-123');
// A flag with 25% rollout will be enabled for ~25% of users
// The same user always gets the same result
final enabled = await AbrovaFeatureFlags.instance.isEnabled('new-feature');Multivariate Flags#
Feature flags can have multiple variants with weighted distribution. Use getValue() to get the assigned variant value.
// A flag with variants: blue (50%), green (30%), red (20%)
final buttonColor = await AbrovaFeatureFlags.instance.getValue(
'button-color',
defaultValue: 'blue',
);
// Use the value
switch (buttonColor) {
case 'green':
return Colors.green;
case 'red':
return Colors.red;
default:
return Colors.blue;
}Offline Support#
The service decides every flag. When it can't be asked (offline, or no user token yet), the SDK uses the service's last answer for that user, then the flag list it cached after the last fetch. The cached answer is an approximation; the service's own answer is used whenever there is one.
How It Works
When online, flags are evaluated server-side for the signed-in user, using their user token.
When the service can't be asked, the SDK answers from the cached flag list, and returns your default for a flag that isn't cached.
Flags are cached using SharedPreferences, surviving app restarts.
The same user always gets the same flag value from the service.
final flags = AbrovaFeatureFlags.instance;
// Check connectivity status
print('Online: ${flags.isOnline}');
// Access cached flags directly (no server call)
final cached = flags.cachedFlags;
print('Cached flags: ${cached.length}');
// Force refresh from server
await flags.refresh();Event Callbacks#
Listen for SDK events using the callback parameter during initialization.
await AbrovaFeatureFlags.init(
AbrovaFeatureFlagsConfig(
apiKey: 'YOUR_API_KEY',
tokenProvider: fetchAbrovaToken,
),
callback: (event, data) {
switch (event) {
case 'initialized':
print('SDK ready, offline: ${data?['offline']}');
break;
case 'featureFlagsRefreshed':
print('Fetched ${data?['count']} flags');
break;
case 'awaitingUserToken':
print('Waiting for a user token');
break;
case 'error':
print('Error: ${data?['message']}');
break;
}
},
);Advanced Usage#
Refresh Flags
// Manually refresh flags from server await AbrovaFeatureFlags.instance.refresh();
Reset State
// Clear cached flags, user and token, and reset the SDK. // Call AbrovaFeatureFlags.init() again before using it afterwards. // To sign a user out, call clearUserId() instead. await AbrovaFeatureFlags.instance.reset();
Dispose
// Clean up resources when done (currently a no-op: nothing to release) AbrovaFeatureFlags.instance.dispose();
Complete Example#
A complete example showing the Feature Flags SDK in a Flutter app:
import 'package:flutter/material.dart';
import 'package:abrova_ab_testing/abrova_ab_testing.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await AbrovaFeatureFlags.init(
AbrovaFeatureFlagsConfig(
apiKey: 'YOUR_API_KEY',
tokenProvider: fetchAbrovaToken, // see step 3
debug: true,
),
);
runApp(const MyApp());
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
bool _darkMode = false;
String _bannerText = 'Welcome!';
bool _loading = true;
@override
void initState() {
super.initState();
_loadFlags();
}
Future<void> _loadFlags() async {
final flags = AbrovaFeatureFlags.instance;
// Set user for targeting
await flags.setUserId('user-123');
flags.setUserAttributes({
'plan': 'premium',
'country': 'US',
});
// Check flags
final darkMode = await flags.isEnabled('dark-mode');
final bannerText = await flags.getValue(
'banner-text',
defaultValue: 'Welcome!',
) as String;
setState(() {
_darkMode = darkMode;
_bannerText = bannerText;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Theme(
data: _darkMode ? ThemeData.dark() : ThemeData.light(),
child: Scaffold(
appBar: AppBar(title: const Text('My App')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_bannerText,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text('Dark mode: ${_darkMode ? "ON" : "OFF"}'),
],
),
),
),
);
}
}AbrovaFeatureFlags vs AbrovaAbTesting#
Both classes ship in the abrova_ab_testing package. Choose the one that fits your needs:
| Option | Type | Required | Description |
|---|---|---|---|
| Feature flags | - | - | Both have it |
| A/B testing | - | - | Only AbrovaAbTesting |
| Event tracking | - | - | Only AbrovaAbTesting |
| Offline caching | - | - | Both have it |
| Targeting rules | - | - | Both have it |
| Rollout % | - | - | Both have it |
| User token | - | - | Both need one to act for a user (tokenProvider) |
| Lightweight | - | - | AbrovaFeatureFlags is minimal; AbrovaAbTesting is the full SDK |
Use AbrovaFeatureFlags if you only need feature flags. Use AbrovaAbTesting if you also need experiments and event tracking. Each is initialized on its own, and both take the same user token.
For a multi-platform feature flag service (iOS, Android, React Native, Node.js, Next.js), use the dedicated Abrova Flags service instead — it has its own SDKs across all platforms.