Datra Engage SDK for Flutter
datra_engage_sdk resolves Datra in-app messages and sends delivery events from Flutter applications. Version 0.1.2 is contract-compatible with @datra/engage-sdk 0.3.2, including bounded per-placement diagnostics and shared resolve reliability.
The SDK does not depend on Riverpod or go_router, does not render marketing UI, and does not control navigation. Your app owns presentation and action handling.
Installation
During local development:
dependencies:
datra_engage_sdk:
path: ../datra-engage-flutter-sdk
After the first public release:
dependencies:
datra_engage_sdk: ^0.1.2
Then import the package:
import 'package:datra_engage_sdk/datra_engage_sdk.dart';
Release process
Every change to main and every pull request is validated by the
datra-engage-flutter-sdk Azure pipeline on the isolated Datra build agent.
Publishing is a separate manual release:
- Merge an approved pull request and wait for a successful CI build on the exact release commit.
- Create and push a
v<version>tag whose version matchespubspec.yaml. - Run
Datra Engage Flutter SDK Publishusing that tag and provide the successful CI build ID for the same commit. - Select
validateto run the complete release gate without publishing, orpublishto continue to the protecteddatra-pubdev-productionenvironment. - Approve the production publication in Azure DevOps.
The release pipeline rejects branches, mismatched tags, failed or unrelated CI builds, and CI builds for another commit. Pub.dev authentication uses a dedicated Google service account stored only as an Azure secret. Release tags and public publication are never created by the normal CI pipeline.
The one-time release setup uses the protected Azure variable group
datra-pubdev: pubdev_package contains only the public package name, while
gcp_pub_service_account_key_b64 is a secret. The same service-account email
must be allowed in the package's pub.dev Admin page. Keep the secret set to the
non-working placeholder until that permission is configured.
Configure the client
Create one client and keep it for the application lifetime. A Datra public SDK key may be embedded in a mobile app. Never place an integration token, admin token, private API key, or other secret in the app.
final engage = DatraEngage(
const DatraEngageConfig(
baseUrl: 'https://api.example.com',
publicKey: 'pk_live_your_public_key',
customerExternalId: 'customer_123',
platform: 'flutter',
appVersion: '1.0.0',
locale: 'uz',
defaultContext: {
'branchExternalId': 'branch_1',
},
),
);
Update identity after login or logout:
engage.identify(
const DatraEngageIdentity(
customerExternalId: 'customer_456',
context: {'segment': 'regular'},
),
);
engage.setCustomerExternalId(); // clear customer after logout
identify has patch semantics: non-null identity fields update the current value, while omitted/null fields keep their current value. Its context is merged into the existing context instead of replacing it. Use the explicit nullable setters when a field must be cleared:
engage.setCustomerExternalId(); // clears customer ID and cancels stale resolves
engage.setPlatform(); // clears platform
engage.setAppVersion(''); // empty or whitespace also clears the value
engage.setLocale(null); // clears locale
Changing the customer cancels obsolete in-flight resolve requests. Changing platform, app version, or locale affects subsequent requests but does not cancel a request already in flight.
Flutter provider and banner placement
Wrap the relevant subtree. flushEventQueueOnResume is optional.
DatraEngageProvider(
client: engage,
flushEventQueueOnResume: true,
child: const MyApp(),
)
Resolve a banner for a placement and render it with your own design:
DatraEngageBuilder(
screen: 'home',
placement: 'home_top_banner',
enabled: customerId != null,
customerExternalId: customerId,
engageContext: {
'cartTotal': cart.total,
'productIds': cart.productIds,
},
trackShown: true,
builder: (context, state) {
if (state.isLoading && state.messages.isEmpty) {
return const SizedBox.shrink();
}
if (state.messages.isEmpty) {
return const SizedBox.shrink();
}
final message = state.messages.first;
return OfferBanner(
title: message.title,
body: message.body,
imageUrl: message.imageUrl,
onTap: () async {
await engage.messageClickedSafe(message.decisionId);
await handleEngageAction(message.action, message);
},
onDismiss: () => engage.messageDismissedSafe(message.decisionId),
);
},
)
DatraEngageMessagesState exposes messages, response, isLoading, error, and refresh. Request failures stay inside the state and never throw from the widget lifecycle.
Resolve messages without widgets
final response = await engage.getMessages(
const ResolveMessagesInput(
placement: 'cart_product_reward',
screen: 'cart',
context: {
'cartProductIds': ['fries', 'burger'],
'loyaltyPoints': 20,
},
),
);
Use getMessagesSafe or screenViewedSafe when Engage must fail open. They return an empty message list on network, timeout, cancellation, or parsing errors.
Tracking
await engage.messageShownSafe(message.decisionId);
await engage.messageClickedSafe(message.decisionId);
await engage.messageDismissedSafe(message.decisionId);
await engage.messageConvertedSafe(
message.decisionId,
const TrackEventOptions(metadata: {'orderExternalId': 'order_123'}),
);
Strict variants (messageShown, messageClicked, messageDismissed, messageConverted, and trackEvent) throw a DatraEngageError. Safe variants return SafeTrackEventResult and do not interrupt application flows.
Default idempotency keys match the React Native SDK:
datra:shown:<decisionId>
datra:clicked:<decisionId>
datra:dismissed:<decisionId>
datra:converted:<decisionId>
Action handling
The SDK only parses and dispatches actions. The application decides how to navigate.
final handleEngageAction = createDatraEngageActionHandler(
DatraEngageActionHandlers(
onOpenScreen: (screen, action, message) {
router.goNamed(screen);
},
onOpenPromotion: (promotionId, action, message) {
router.push('/promotions/$promotionId');
},
onOpenProduct: (productId, action, message) {
router.push('/products/$productId');
},
onOpenCart: (action, message) {
router.go('/cart');
},
onOpenUrl: (url, action, message) {
openExternalUrl(url);
},
onUnhandledAction: (action, message) {
diagnostics.log('Unsupported Engage action: ${action.type}');
},
),
);
Typed handlers are available for OPEN_SCREEN, OPEN_PROMOTION, OPEN_COUPON, OPEN_PRODUCT, OPEN_CART, OPEN_URL, OPEN_STORY, and OPEN_MECHANIC. Unknown or incomplete actions go to onUnhandledAction.
Offline event queue
The queue is opt-in. It stores retryable event failures only, keeps at most 100 events by default, and flushes with concurrency 3.
final storage = MemoryDatraEngageStorage();
final engage = DatraEngage(
DatraEngageConfig(
baseUrl: 'https://api.example.com',
publicKey: 'pk_live_your_public_key',
eventQueue: DatraEngageEventQueueConfig(
enabled: true,
storage: storage,
),
),
);
await engage.flushEventQueue();
Memory storage is the safe default but does not survive process restarts. To persist the queue and dedupe keys, implement DatraEngageStorage in the app with shared_preferences:
class SharedPreferencesEngageStorage implements DatraEngageStorage {
SharedPreferencesEngageStorage(this.preferences, {this.prefix = ''});
final SharedPreferences preferences;
final String prefix;
@override
Future<String?> getItem(String key) async =>
preferences.getString('$prefix$key');
@override
Future<void> setItem(String key, String value) async {
await preferences.setString('$prefix$key', value);
}
@override
Future<void> removeItem(String key) async {
await preferences.remove('$prefix$key');
}
}
The SDK intentionally does not require shared_preferences; the host app chooses its persistence implementation.
Runtime defaults
| Operation | Timeout | Retries |
|---|---|---|
| Message resolve | 2000 ms | 0 |
| Event tracking | 3000 ms | 1 |
Retry statuses are 408, 429, 500, 502, 503, and 504. Event dedupe is enabled by default with a 24-hour TTL and a maximum of 500 keys. Identical resolves use single-flight; a changed request for the same customer, placement, and screen cancels the stale request.
Diagnostics
getDiagnostics() returns aggregate counters and an immutable per-placement snapshot. Placement names are trimmed, the first 100 distinct placements are tracked independently, and further placements are aggregated under __other__.
final diagnostics = await engage.getDiagnostics();
final home = diagnostics.placements['home_top_banner'];
debugPrint(
'calls=${home?.resolveCalls}, network=${home?.networkRequests}, '
'average=${home?.averageDurationMs}ms',
);
Each DatraEngagePlacementDiagnostics contains resolve calls, real network requests, successful and empty responses, timeouts, errors, cancellations, single-flight hits, and average/last/maximum duration. A caller cancelling its own subscription does not cancel or count as cancellation of a shared network request.
Compatibility
| React Native SDK | Flutter SDK |
|---|---|
@datra/engage-sdk 0.3.2 |
datra_engage_sdk 0.1.2 |
The declared minimum remains Flutter 3.19 (Dart 3.3). CI compiles a separate consumer fixture on Flutter 3.19.6 without resolving this package's development dependencies.
Discounts, rewards, and loyalty points
Datra Engage tells the app which offer to show and carries the action/offer metadata. The SDK does not calculate or apply a discount, validate promotion eligibility, reserve a reward product, or debit loyalty points. Those operations must be confirmed by the Datra backend/order calculation endpoint. Never grant a discount or spend points based only on client-side message data.
Development checks
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test
dart doc
dart pub publish --dry-run
Publishing to pub.dev is a separate manual release step.