A/B Testing Flutter SDK
Cross-platform Dart SDK for A/B experiments, feature flags, and event tracking. Works on iOS, Android, Web, macOS, Linux, Windows. Offline-first with SharedPreferences cache.
- Pure Dart SDK for A/B testing and feature flags .
- works on all Flutter platforms (iOS, Android, Web, macOS, Linux, Windows).
- Offline-first with SharedPreferences cache, variants assigned by the service, and 17 built-in event types. Installs from Abrova Center.
- Signed user tokens via
tokenProvider, required for variants, tracking and flag evaluation. See step 3, User tokens. - New in 0.2.2: you can initialize before sign-in. The SDK waits for the user token instead of failing.
- Flutter 3.10 or higher.
- Dart 3.0.0 or higher. Android SDK 21+ / iOS 13.0+. Pure Dart
- works on all Flutter platforms.
The same package ships a standalone AbrovaFeatureFlags class for apps that only need flags (see Feature Flags below). For a full feature-flag platform, see Abrova Flags.
Installation#
Install from Abrova Center in two steps. 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.3Then install:
flutter pub get
Platform-specific setup
Android: Add Internet permission in android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
iOS / Web / Desktop: No additional setup required.
Initialization#
Initialize AbrovaAbTesting in your main function before running the app. Pass a tokenProvider that fetches an Abrova user token from your backend (see User tokens below).
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:abrova_ab_testing/abrova_ab_testing.dart';
/// Calls YOUR backend, which mints the token with your server secret.
/// Returns null while no one is signed in.
Future<String?> fetchAbrovaToken() async {
if (yourSessionToken == null) return null;
final res = await http.post(
Uri.parse('https://your-api.example.com/abrova-token'),
headers: {'Authorization': 'Bearer $yourSessionToken'}, // your app's own login session
);
return jsonDecode(res.body)['token'] as String;
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await AbrovaAbTesting.init(
AbrovaAbTestingConfig(
apiKey: 'YOUR_API_KEY',
tokenProvider: fetchAbrovaToken, // required for variants, events and flags
debug: true, // Enable debug logging
// flushInterval: 10000, // Your own send interval in ms. Leave it out to follow the console
maxQueueSize: 100, // Maximum events to queue
),
callback: (event, data) {
print('AbrovaAbTesting event: $event, data: $data');
},
);
runApp(const MyApp());
}
// Call this after your user signs in
Future<void> onSignedIn(String userId) async {
await AbrovaAbTesting.instance.setUserId(userId); // the id your server puts in the token
await AbrovaAbTesting.instance.refreshUserToken(); // asks tokenProvider again now
}
// Call this after your user signs out
Future<void> onSignedOut() async {
await AbrovaAbTesting.instance.clearUserId();
}AbrovaAbTesting.instance is available once init has completed. After that, getVariant and the track… methods never throw, with or without a signed-in user.
If you pass baseUrl to init, it must be an https URL. Plain http is accepted only for local development (localhost, 127.0.0.1, 10.0.2.2).
Configuration
| Option | Type | Required | Description |
|---|---|---|---|
| apiKey | String | required | Your project API key (`ab_live_…`). |
| tokenProvider | AbrovaTokenProvider | - | `FutureOr<String?> Function()` that returns an Abrova user token from your backend, or `null` while no one is signed in. |
| userToken | String? | - | A token you already hold. Prefer `tokenProvider`: a fixed token stops working when it expires. Hand over a newer one with `setUserToken`. |
| debug | bool | false | Print SDK logs. |
| flushInterval | int? | - | How often queued events are sent, in milliseconds (whole seconds are used). Leave it out to follow the Send interval set in the Abrova console (see below). |
| maxQueueSize | int | 100 | Accepted but not used: the SDK keeps up to 1,000 events and sends 100 at a time. |
Send interval and list cache
Two values come from your project's settings in the Abrova console (A/B Testing › Settings › Defaults):
- Send interval — how often queued events are sent. The SDK follows it when you leave
flushIntervalout. AflushIntervalyou pass is your own and is always used. - List cache — how long the SDK uses the experiment list before it asks the service again. There is no SDK option for it. Once the list is older than that, the SDK fetches it again in the background the next time your code reads a variant or tracks an event. The call doesn't wait for it, and a failed refresh is not reported to your callback as an
error.
A change you make in the console (a new experiment, changed weights or traffic) reaches running apps within the list cache time, without a restart. A user who already has a variant keeps it. A changed send interval is picked up at that same refresh. The SDK keeps the console's values within safe limits, and sends every 30 seconds until it knows the console's value. Apps on SDK versions older than 0.2.3 keep their built-in behaviour.
From 0.2.3, a flushInterval set in code always wins.
User tokens#
Anything the SDK does for a user needs a signed Abrova user token: getting a variant from the server, tracking events and conversions, syncing queued events, and evaluating feature flags. Loading the experiment and flag lists works with the API key alone, so your app can fetch them before sign-in.
Mint the token on your server
Add one endpoint, protected by your own login, that returns a token for the signed-in user. It calls Abrova with your project's server secret:
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.
The server secret can mint a token for any user. Keep it on your server, out of your app bundle and source code.
One token for A/B Testing, Chat and Sync
This is the same Abrova user token that Abrova Chat and Abrova Sync use. Give that same token to every SDK's tokenProvider instead of fetching one per product.
A token is valid until it expires, so store it with the user's session (Keychain, Keystore or secure storage) and reuse it across app launches. Your app then only calls your backend when the token is close to expiry — not on every start.
How the SDK uses it
The SDK calls `tokenProvider` at start, when it needs a token, again shortly before the token expires, and once more if the service reports it expired.
No `tokenProvider`? Call `setUserToken(token)` after `init` and whenever you refresh the token, and `setUserToken(null)` at sign-out. A token passed as `userToken` is not refreshed.
The service takes the user from the token, and so does the SDK: a token for a different user switches the SDK to that user.
Mint the token for the same user ID you pass to `setUserId`.
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:
getVariantreturns the default variant.- Flags come from the cached flag list, or return their default.
- Events are kept on the device (up to 1,000) and nothing is sent.
- No error is reported.
Once a token arrives, the SDK fetches the variants you asked for in the meantime (listen for the experimentAssigned event in the init callback) and sends the pending events.
final ab = AbrovaAbTesting.instance;
ab.isAwaitingUserToken; // true while there is no user token
ab.onAwaitingUserToken(() { }); // called each time the SDK starts waiting
// After sign-in
await ab.refreshUserToken(); // with a tokenProvider: asks it again now
await ab.setUserToken(token); // without one: hand the token over
// After sign-out
await ab.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); the provider is asked straight away.
Events are credited to the user in the token. Events recorded before sign-in are only sent if they were recorded for the same user id that signs in (you called setUserId with that id before tracking). Events recorded while no user was set belong to no one, are not credited, and are dropped at sign-in.
User Identification#
Identify users and set attributes for targeting rules. Call setUserId on login with the same id your server puts in the token, and clearUserId on logout.
// Set user ID (e.g., after login)
await AbrovaAbTesting.instance.setUserId('user-123');
// Forget the user (after logout)
await AbrovaAbTesting.instance.clearUserId();
// Get current user ID
final userId = AbrovaAbTesting.instance.getUserId();
// Set user attributes for targeting
AbrovaAbTesting.instance.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() is a user switch. The SDK first sends the previous user's pending events under their own token, then drops their variants, attributes, token and remaining events before anything is sent for the next user. Set the user attributes again after a switch.
Getting Experiment Variants#
Get the variant assigned to the current user. The service decides the variant (this needs a user token) and the SDK remembers it, so the same user keeps the same variant. The SDK never works a variant out on the device.
The Flutter SDK returns the variant's name as it appears in Abrova Console (for example Control or Variant B), not its key (control, variant-b). getVariant never throws: without a user, a user token or a connection it returns the remembered variant, or defaultVariant when there is none.
// Get variant for an experiment
final variant = await AbrovaAbTesting.instance.getVariant(
'checkout-button-color',
defaultVariant: 'control', // returned while the service has not assigned one
);
// Use the variant
switch (variant) {
case 'Variant A':
buttonColor = Colors.green;
break;
case 'Variant B':
buttonColor = Colors.blue;
break;
default:
buttonColor = Colors.grey; // control
}Getting Variant Configuration
// Get variant config as a Map
final config = await AbrovaAbTesting.instance.getVariantConfig('checkout-button-color');
// Access config values
final buttonText = config?['buttonText'] as String? ?? 'Buy Now';
final buttonColorHex = config?['buttonColor'] as String? ?? '#6B7280';
// Get all cached experiments
final experiments = AbrovaAbTesting.instance.getExperiments();
for (final experiment in experiments) {
print('${experiment.key}: ${experiment.status}');
}Tracking Events#
Track user interactions to measure experiment results. All 17 event types are supported. Events are queued locally and synced automatically with the current user's token. The track… methods never throw: without a signed-in user the event is kept, and it is only sent if that same user signs in.
Core Events
final sdk = AbrovaAbTesting.instance;
// Track view
await sdk.trackView('checkout-button-color');
// Track click
await sdk.trackClick('checkout-button-color');
// Track conversion with optional value
await sdk.trackConversion('checkout-button-color', value: 300000);
// Track custom event
await sdk.trackCustomEvent(
'checkout-button-color',
'add_to_cart',
properties: {
'product_id': 'SKU123',
'quantity': 2,
},
);Conversion Events
final sdk = AbrovaAbTesting.instance;
// Add to cart
await sdk.trackAddToCart('checkout-flow', value: 200000, productId: 'SKU123');
// Remove from cart
await sdk.trackRemoveFromCart('checkout-flow', value: 200000, productId: 'SKU123');
// Begin checkout
await sdk.trackBeginCheckout('checkout-flow', value: 600000);
// Purchase
await sdk.trackPurchase('checkout-flow',
value: 600000,
transactionId: 'txn_abc123',
properties: {'currency': 'Toman'},
);Engagement Events
final sdk = AbrovaAbTesting.instance;
// Scroll tracking
await sdk.trackScroll('landing-page', depth: 75.0);
// Form submission
await sdk.trackFormSubmit('signup-flow', formName: 'registration');
// Search
await sdk.trackSearch('search-experiment', query: 'flutter sdk');
// Share
await sdk.trackShare('referral-test', method: 'email');User Auth Events
final sdk = AbrovaAbTesting.instance;
// Sign up
await sdk.trackSignUp('onboarding-flow', method: 'google');
// Login
await sdk.trackLogin('auth-experiment', method: 'email');
// Logout
await sdk.trackLogout('auth-experiment');Media Events
final sdk = AbrovaAbTesting.instance;
// Video start
await sdk.trackVideoStart('video-experiment', videoId: 'vid_123');
// Video complete
await sdk.trackVideoComplete('video-experiment', videoId: 'vid_123');Generic Event Tracking
// Track any event type with full control
await AbrovaAbTesting.instance.track(
'my-experiment',
EventType.custom,
eventName: 'my_custom_event',
eventValue: 42.0,
properties: {'key': 'value'},
);Feature Flags#
The A/B testing SDK includes built-in feature flag support. Flags are evaluated server-side for the signed-in user (with their token). When the service can't be asked (offline, or no user token yet), the SDK uses the service's last answer, then the cached flag list, then your default.
final sdk = AbrovaAbTesting.instance;
// Check if feature is enabled
final isEnabled = await sdk.isFeatureEnabled('new-checkout-flow');
if (isEnabled) {
return NewCheckoutPage();
} else {
return OldCheckoutPage();
}
// Get feature flag value (for multivariate flags)
final bannerText = await sdk.getFeatureValue(
'banner-text',
defaultValue: 'Welcome!',
);
// Get all feature flags
final flags = await sdk.getFeatureFlags();
for (final flag in flags) {
print('${flag.key}: ${flag.enabled}');
}
// Refresh flags from server
await sdk.refreshFeatureFlags();Flags without experiments
If your app only needs flags, use AbrovaFeatureFlags from the same package. It takes the same tokenProvider and has the same isAwaitingUserToken, onAwaitingUserToken, setUserToken, refreshUserToken and clearUserId members:
await AbrovaFeatureFlags.init(
AbrovaFeatureFlagsConfig(
apiKey: 'YOUR_API_KEY',
tokenProvider: fetchAbrovaToken,
),
);
await AbrovaFeatureFlags.instance.setUserId('user-123');
if (await AbrovaFeatureFlags.instance.isEnabled('dark-mode')) {
// Show dark mode
}
final maxUpload = await AbrovaFeatureFlags.instance.getValue(
'max-upload-size',
defaultValue: 10,
);Offline Support#
AbrovaAbTesting automatically handles offline scenarios. Experiments and feature flags are cached locally. Events are queued when offline and synced, with the user's token, when connectivity returns.
Events are stored locally using SharedPreferences when offline and automatically synced when back online.
The queue holds at most 1,000 events (the oldest are dropped first) and is sent in batches. The SDK waits before retrying when the service is busy or your project has reached its usage limit.
Once the service assigns a user a variant, the assignment is cached locally so they always see the same variant, even when offline. Without a cached assignment, `getVariant` returns your default.
Each event has a unique ID, sent as `clientEventId` and saved with the event.
Each event carries the time your app recorded it, so events sent together keep their order.
Lifecycle & Sync#
App Lifecycle Integration
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// Flush events before app goes to background
AbrovaAbTesting.instance.flush();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
AbrovaAbTesting.instance.dispose();
super.dispose();
}
}Manual Flush & Status
// Flush events immediately
await AbrovaAbTesting.instance.flush();
// Check pending event count
final pending = await AbrovaAbTesting.instance.getPendingEventCount();
print('Pending events: $pending');
// Check connectivity
final isOnline = AbrovaAbTesting.instance.isOnline;
print('Online: $isOnline');
// Refresh experiments from server
await AbrovaAbTesting.instance.refreshExperiments();
// Wipe all SDK state, including unsent events. You must call init() again afterwards.
// To switch users, call setUserId() on login and clearUserId() on logout instead.
await AbrovaAbTesting.instance.reset();Complete Example#
A complete example showing initialization, experiments, events, and feature flags:
import 'package:flutter/material.dart';
import 'package:abrova_ab_testing/abrova_ab_testing.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await AbrovaAbTesting.init(
AbrovaAbTestingConfig(
apiKey: 'YOUR_API_KEY',
tokenProvider: fetchAbrovaToken, // see Initialization
debug: true,
),
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(home: const CheckoutPage());
}
}
class CheckoutPage extends StatefulWidget {
const CheckoutPage({super.key});
@override
State<CheckoutPage> createState() => _CheckoutPageState();
}
class _CheckoutPageState extends State<CheckoutPage> with WidgetsBindingObserver {
String _variant = 'control';
bool _newCheckoutEnabled = false;
bool _loading = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_init();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
AbrovaAbTesting.instance.flush();
}
}
Future<void> _init() async {
final sdk = AbrovaAbTesting.instance;
// Set the signed-in user (the token from fetchAbrovaToken is for this user)
await sdk.setUserId('user-123');
sdk.setUserAttributes({'plan': 'premium', 'country': 'US'});
// Get experiment variant (the default until the service has assigned one)
final variant = await sdk.getVariant(
'checkout-button-style',
defaultVariant: 'control',
);
// Check feature flag
final newCheckout = await sdk.isFeatureEnabled('new-checkout-flow');
setState(() {
_variant = variant;
_newCheckoutEnabled = newCheckout;
_loading = false;
});
// Track view
await sdk.trackView('checkout-button-style');
}
Future<void> _handleCheckout() async {
final sdk = AbrovaAbTesting.instance;
await sdk.trackClick('checkout-button-style');
// ... process checkout ...
await sdk.trackPurchase('checkout-button-style', value: 1000000, transactionId: 'txn_123');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Checkout complete!')),
);
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: const Text('Checkout')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Variant: $_variant'),
Text('New checkout: $_newCheckoutEnabled'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _handleCheckout,
style: ElevatedButton.styleFrom(
backgroundColor: _variant == 'Variant B' ? Colors.green : Colors.grey,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: const Text('Complete Purchase', style: TextStyle(fontSize: 18)),
),
],
),
),
);
}
}API Reference#
// ── Initialization ──
static Future<AbrovaAbTesting> init(AbrovaAbTestingConfig config, {callback, baseUrl})
static AbrovaAbTesting get instance
// ── Configuration ──
AbrovaAbTestingConfig({required String apiKey, AbrovaTokenProvider? tokenProvider,
String? userToken, bool debug, int? flushInterval, int maxQueueSize})
typedef AbrovaTokenProvider = FutureOr<String?> Function() // null: no one signed in
// ── User Management ──
Future<void> setUserId(String userId) // call on login
Future<void> clearUserId() // call on logout
String? getUserId()
void setUserAttributes(Map<String, dynamic> attributes)
// ── User Token ──
bool get isAwaitingUserToken
void onAwaitingUserToken(void Function() callback)
Future<void> setUserToken(String? token) // null = signed out
Future<void> refreshUserToken() // ask tokenProvider again now
Future<void> setTokenProvider(AbrovaTokenProvider? provider)
// ── Experiments ──
Future<String> getVariant(String experimentKey, {String defaultVariant})
Future<Map<String, dynamic>?> getVariantConfig(String experimentKey)
Future<void> refreshExperiments()
List<Experiment> getExperiments()
// ── Event Tracking (17 types) ──
Future<void> trackView(String experimentKey)
Future<void> trackClick(String experimentKey)
Future<void> trackConversion(String experimentKey, {double? value})
Future<void> trackCustomEvent(String experimentKey, String eventName, {properties})
Future<void> trackScroll(String experimentKey, {double? depth, properties})
Future<void> trackFormSubmit(String experimentKey, {String? formName, properties})
Future<void> trackSearch(String experimentKey, {String? query, properties})
Future<void> trackShare(String experimentKey, {String? method, properties})
Future<void> trackAddToCart(String experimentKey, {double? value, String? productId})
Future<void> trackRemoveFromCart(String experimentKey, {double? value, String? productId})
Future<void> trackBeginCheckout(String experimentKey, {double? value, properties})
Future<void> trackPurchase(String experimentKey, {required double value, String? transactionId})
Future<void> trackVideoStart(String experimentKey, {String? videoId, properties})
Future<void> trackVideoComplete(String experimentKey, {String? videoId, properties})
Future<void> trackSignUp(String experimentKey, {String? method, properties})
Future<void> trackLogin(String experimentKey, {String? method, properties})
Future<void> trackLogout(String experimentKey, {properties})
Future<void> track(String experimentKey, EventType type, {eventName, eventValue, properties})
// ── Feature Flags ──
Future<bool> isFeatureEnabled(String featureKey, {bool defaultValue})
Future<dynamic> getFeatureValue(String featureKey, {dynamic defaultValue})
Future<List<FeatureFlag>> getFeatureFlags()
Future<void> refreshFeatureFlags()
AbrovaFeatureFlags get featureFlags
// ── Lifecycle ──
Future<void> flush()
Future<int> getPendingEventCount()
bool get isOnline
Future<void> reset()
void dispose()EventType Enum#
enum EventType {
view, // 'view'
click, // 'click'
conversion, // 'conversion'
custom, // 'custom'
scroll, // 'scroll'
formSubmit, // 'form_submit'
search, // 'search'
share, // 'share'
addToCart, // 'add_to_cart'
removeFromCart, // 'remove_from_cart'
beginCheckout, // 'begin_checkout'
purchase, // 'purchase'
videoStart, // 'video_start'
videoComplete, // 'video_complete'
signUp, // 'sign_up'
login, // 'login'
logout, // 'logout'
}