Releva Flutter SDK
This package offers an easy way to integrate Releva's AI-powered e-commerce personalization platform into your mobile app built on Flutter.
Features
🎯 E-commerce Personalization
- Product Recommendations: AI-powered product suggestions with real-time personalization
- Dynamic Content: Personalized banners, stories, and content blocks based on user behavior
- Advanced Filtering: Complex product filtering with nested AND/OR logic, price ranges, custom fields
- Smart Search: Search tracking with result optimization and recommendation integration
📱 Mobile Tracking & Analytics
- Automatic Screen Tracking: NavigatorObserver for seamless route/screen tracking
- E-commerce Events: Product views, cart changes, checkout tracking, search analytics
- Custom Events: Flexible event system for business-specific tracking needs
- Real-Time Analytics: ClickHouse integration for comprehensive engagement insights
🔔 Push Notifications
- Firebase Integration: Complete FCM push notification system with data-only payloads
- Rich Notifications: Images, action buttons, and deep linking support
- Navigation: App-controlled navigation via structured tap callback
- Engagement Analytics: Delivered, opened, dismissed tracking to ClickHouse
- Backward Compatible: Supports both new (data-only) and legacy (notification+data) payloads
- Cross-Platform: Android, iOS, Huawei device support
📬 App Inbox
- Persistent Messages: In-app message centre where messages survive until read, deleted, or expired
- Rich Content: Messages rendered with Unlayer design JSON, fully personalized per user
- Real-Time Sync: Silent push notifications trigger automatic inbox refresh
- Optimistic Updates: Mark read, mark all read, and delete with instant UI feedback
- Cursor Pagination: Efficient infinite scroll with server-side cursor pagination
- Local Caching: Messages cached locally for instant display on return visits
⚙️ Flexible Configuration
- Modular Setup: Enable only needed features
- Production Ready: Robust error handling, offline support, automatic retries
Installation
Add the Releva Flutter SDK to your project by adding the following to your pubspec.yaml:
dependencies:
releva_sdk: ^0.0.49
Then run:
flutter pub get
Firebase Setup (Required for Push Notifications)
1. Install Firebase dependencies
flutter pub add firebase_core firebase_messaging
2. Configure Firebase for your project
flutterfire configure
This will create firebase_options.dart with your project configuration.
3. Android Configuration
No special Android manifest configuration is required for Releva push notifications. The SDK uses data-only FCM messages and handles notification display via local notifications.
4. iOS Configuration
iOS notification categories are automatically configured by the Releva SDK! No manual AppDelegate modification is required.
The SDK automatically registers notification categories when your app starts, enabling:
- Action buttons on notifications
- Deep linking
- Notification tap handling
Optional: Notification Service Extension (for dynamic button text on background/terminated notifications)
If you want to display dynamic button text from push notification payloads even when the app is in the background or terminated, you need to add a Notification Service Extension. This is required for showing custom button text on notifications that arrive while the app is not running.
Why is this needed? iOS requires notification action buttons to be registered BEFORE a notification is displayed. When the app is in the background, a Notification Service Extension allows you to modify the notification (including registering dynamic categories) right before it's shown to the user.
Setup Steps:
See the detailed setup guide in ios/SETUP_NOTIFICATION_SERVICE_EXTENSION.md
Quick Summary:
- In Xcode, go to File → New → Target → Notification Service Extension
- Name it
RelevaNotificationService - Add Firebase/Messaging to the extension in your Podfile
- Replace the generated
NotificationService.swiftwith the implementation fromreleva_sdk/ios/Templates/NotificationService.swift - Ensure your notification payload includes
mutable-content: 1in theapssection
Without the extension:
- Foreground notifications: ✅ Dynamic button text works
- Background notifications: ❌ Shows "Open" button
- Terminated app notifications: ❌ Shows "Open" button
With the extension:
- Foreground notifications: ✅ Dynamic button text works
- Background notifications: ✅ Dynamic button text works
- Terminated app notifications: ✅ Dynamic button text works
Initialize the Releva Client
import 'package:releva_sdk/client.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late RelevaClient client;
@override
void initState() {
super.initState();
// Initialize Releva Client
// realm - use '' unless instructed otherwise by your account manager
// accessToken - use the access token provided by your account manager
client = RelevaClient(
'', // realm
'<yourAccessToken>', // access token
);
_initializeSDK();
}
Future<void> _initializeSDK() async {
// Set device ID based on your current logic for device tracking
await client.setDeviceId('<deviceId>');
// If a user has registered or logged in, provide a profileId
// This must be consistent across channels and integrations
await client.setProfileId('<profileId>');
// Enable app push notification engagement metrics collection
// IMPORTANT: ensure that you have set the profileId and deviceId first!
await client.enablePushEngagementTracking(
onNotificationTapped: (action) async {
// Handle navigation — see "Push Notification Engagement Tracking" below
},
);
// Register FCM token
_registerPushToken();
}
// Assumes setDeviceId(), setProfileId(), and enablePushEngagementTracking()
// have all been called earlier — see _setupSDK above. The initial
// registerPushToken() call below is required: the SDK's auto-refresh only
// kicks in *after* the host has registered the token at least once.
Future<void> _registerPushToken() async {
final messaging = FirebaseMessaging.instance;
// Request notification permissions
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
// Get FCM token and register once on first run. After
// enablePushEngagementTracking has been called, the SDK automatically
// refreshes and re-uploads the token on every app launch, every
// foreground resume, and on FirebaseMessaging.onTokenRefresh, so you
// do not need to wire those yourself. FCM rotates tokens silently and
// backgrounded apps that don't cold-start are the most common source
// of "messaging/registration-token-not-registered" errors on iOS.
String? token = await messaging.getToken();
if (token != null) {
DeviceType deviceType = Platform.isIOS
? DeviceType.ios
: DeviceType.android;
await client.registerPushToken(deviceType, token);
}
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Add automatic screen tracking
navigatorObservers: [client.createScreenTrackingService()],
// Optional: wire the SDK's navigator key so your onNotificationTapped
// callback can obtain a BuildContext via
// NavigationService.instance.navigatorKey.currentContext
navigatorKey: NavigationService.instance.navigatorKey,
home: HomeScreen(client: client),
);
}
}
Profile Management and User Logout
Important: Handling User Logout
When a user logs out of your application, it's critical to prevent merging the logged-in user's profile with the anonymous profile created for the logged-out state. To do this, use the skipMergeWithPreviousProfileId parameter when setting the new anonymous profile ID:
// When user logs out, generate a new anonymous profile ID
final newAnonymousProfileId = const Uuid().v4();
// Set the new profile ID with skipMergeWithPreviousProfileId set to true
await client.setProfileId(newAnonymousProfileId, true);
// Registering the firebase push token to the new profile id because we are skipping the merge with previous one and we need explicit call for registering it to the new profile id
RelevaClient rlv_client = RelevaClient(realm, accessToken);
DeviceType deviceType = Platform.isIOS
? DeviceType.ios
: DeviceType.android;
final messaging = FirebaseMessaging.instance;
// Now get FCM token (safe to call after APNs is ready on iOS)
String? firebaseToken = await messaging.getToken();
if (firebaseToken != null) {
await rlv_client!.registerPushToken(deviceType, firebaseToken);
}
Why is this important?
By default, when you change a profile ID, the SDK will merge the previous profile with the new one to maintain user behavior continuity. However, when a user logs out, you want to start fresh with a completely separate anonymous profile. Setting skipMergeWithPreviousProfileId: true ensures:
- The logged-in user's profile data remains separate
- The new anonymous profile starts with a clean slate
- No cross-contamination of user behavior data
Default Behavior (when logging in or switching users):
// Normal profile change - previous profile will be merged
await client.setProfileId(loggedInUserId);
This default behavior is ideal for:
- User login (merging anonymous behavior with logged-in profile)
- Account linking
- Profile migrations
Push Notification Engagement Tracking
OPTION 1: Use the library's built-in callback registration (Recommended)
IMPORTANT: Only do this if you DO NOT have ANY of the following anywhere in your app OR YOUR OTHER LIBRARIES:
FirebaseMessaging.instance.onMessage.listen((message) => ...);FirebaseMessaging.instance.onMessageOpenedApp.listen((message) => ...);FirebaseMessaging.instance.getInitialMessage().then((message) => ...);
The SDK handles all Firebase messaging hooks and engagement tracking. Your app
provides a required onNotificationTapped callback that receives a structured
RelevaNotificationAction — your app decides how to navigate. This keeps
the SDK compatible with any navigation approach (named routes, GoRouter,
auto_route, etc.).
import 'package:releva_sdk/types/notification_action.dart';
import 'package:url_launcher/url_launcher.dart';
await client.enablePushEngagementTracking(
onNotificationTapped: (RelevaNotificationAction action) async {
// action.target - 'screen', 'url', 'inbox', or null (main screen)
// action.screen - app-defined screen name (when target is 'screen')
// action.url - URL for deep link or browser
// action.parameters - parsed key-value parameters for screen navigation
// Example using Navigator (adapt to your routing solution):
final navigator = NavigationService.instance.navigatorKey.currentContext;
if (navigator == null) return;
if (action.target == 'inbox') {
// Navigate to inbox — parameters may contain 'inboxMessageId'
Navigator.of(navigator).pushNamedAndRemoveUntil(
'/inbox',
(route) => route.isFirst,
arguments: action.parameters,
);
} else if (action.target == 'screen' && action.screen != null) {
// action.screen is the free-form screen name configured in the
// Releva dashboard — it should match your app's route names.
Navigator.of(navigator).pushNamedAndRemoveUntil(
action.screen!,
(route) => route.isFirst,
arguments: action.parameters,
);
} else if (action.target == 'url' && action.url != null) {
final uri = Uri.tryParse(action.url!);
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
},
);
RelevaNotificationAction Fields:
target- Navigation type:"screen","url","inbox", ornull(main screen)screen- App-defined screen name, free-form value configured in the Releva dashboard (when target is"screen")url- URL to open (deep link or browser)parameters- ParsedMap<String, dynamic>of navigation parameters
Behavior:
- The callback is required — your app handles all navigation
- Engagement tracking always happens automatically before the callback fires
- Works in all app states: foreground, background, and terminated
- The SDK is navigation-agnostic — use whichever routing library your app uses
OPTION 2: Manually invoke Releva's engagement tracking in your hooks
If you already have Firebase messaging hooks in your app, place the following code in your event hooks:
// When app is in foreground
FirebaseMessaging.instance.onMessage.listen((message) async {
// Your existing logic...
await client.trackEngagement(message);
});
// When user taps notification while app is in background
FirebaseMessaging.instance.onMessageOpenedApp.listen((message) async {
// Your existing logic...
await client.trackEngagement(message);
});
// When app is opened from terminated state via notification
FirebaseMessaging.instance.getInitialMessage().then((message) async {
if (message != null) {
// Your existing logic...
await client.trackEngagement(message);
}
});
Send Push Requests
Set Wishlist (if applicable)
import 'package:releva_sdk/types/wishlist/wishlist_product.dart';
import 'package:releva_sdk/types/custom_field/custom_fields.dart';
// If your app supports a wishlist feature, set the active wishlist
// If the user has no wishlist right now, set it to an empty list
WishlistProduct product = WishlistProduct('<productId>', CustomFields.empty());
await client.setWishlist([product]);
Set Cart (if applicable)
import 'package:releva_sdk/types/cart/cart.dart';
import 'package:releva_sdk/types/cart/cart_product.dart';
import 'package:releva_sdk/types/custom_field/custom_field.dart';
import 'package:releva_sdk/types/custom_field/custom_fields.dart';
// Create custom fields to pass to your cart product
CustomField<String> string = CustomField('size', ['S']);
CustomField<double> numeric = CustomField('size_code', [1, 2]);
CustomField<DateTime> date = CustomField('in_promo_after', [
DateTime.utc(2025, 1, 1, 0, 1, 2, 5)
]);
CustomFields custom = CustomFields([string], [numeric], [date]);
// If your app supports a cart feature, set the active cart
// If the user has no cart right now, set it to Cart.active with an empty list
CartProduct product = CartProduct('<id>', 29.99, 1, custom);
await client.setCart(Cart.active([product]));
Initialize and Send a Request - full low-level example
import 'package:releva_sdk/types/push_request.dart';
import 'package:releva_sdk/types/view/viewed_product.dart';
import 'package:releva_sdk/types/event/custom_event.dart';
import 'package:releva_sdk/types/event/custom_event_product.dart';
import 'package:releva_sdk/types/filter/nested_filter.dart';
// Initialize a request
PushRequest request = PushRequest()
// If a user is viewing a screen with non-default language
.locale('en')
// If a user is viewing a screen with non-default currency
.currency('EUR')
// If the screen the user is viewing contains a list of items with a filter applied
.pageFilter(NestedFilter.and([]))
// If a user is viewing a specific screen, send the screen (a.k.a. page) token
.screenView('<pageToken>')
// If a user is viewing a product, send the viewed product
.productView(Viewedproduct('<productId>', CustomFields.empty()))
// If a user has performed custom events (e.g., filling out a form)
.customEvents([
CustomEvent(
'fubar',
[CustomEventProduct('<productId>', 2)],
['foo_tag'],
CustomFields.empty(),
)
]);
// Send the request
RelevaResponse response = await client.push(request);
// Handle recommendations
if (response.hasRecommenders) {
for (final recommender in response.recommenders) {
print('${recommender.name}: ${recommender.response.length} products');
// Display products to user
}
}
HIgh-level Tracking Methods
Product View Tracking
final response = await client.trackProductView(
screenToken: 'product_detail',
productId: 'product-123',
categories: ['electronics', 'phones'],
locale: 'en',
currency: 'USD',
);
Search Tracking
import 'package:releva_sdk/types/filter/simple_filter.dart';
import 'package:releva_sdk/types/filter/nested_filter.dart';
final response = await client.trackSearchView(
screenToken: 'search_results',
query: 'red running shoes',
resultProductIds: ['prod1', 'prod2', 'prod3'],
filter: NestedFilter.and([
SimpleFilter.priceRange(minPrice: 50, maxPrice: 200),
SimpleFilter.brand(brand: 'Nike'),
SimpleFilter.color(color: 'red'),
]),
locale: 'en',
currency: 'USD',
);
Checkout Success Tracking
final response = await client.trackCheckoutSuccess(
screenToken: 'checkout_success',
orderedCart: Cart.active([...]),
userEmail: 'user@example.com',
userPhoneNumber: '+1234567890',
userFirstName: 'John',
userLastName: 'Doe',
locale: 'en',
currency: 'USD',
);
Screen View Tracking
final response = await client.trackScreenView(
screenToken: 'home_screen',
productIds: ['prod1', 'prod2', 'prod3'],
categories: ['electronics', 'phones'],
locale: 'en',
currency: 'USD',
);
Advanced Filtering
Build complex product filters with nested AND/OR logic:
final complexFilter = NestedFilter.and([
// Price range
SimpleFilter.priceRange(minPrice: 10, maxPrice: 100),
// Multiple brands (OR)
NestedFilter.or([
SimpleFilter.brand(brand: 'Nike'),
SimpleFilter.brand(brand: 'Adidas'),
]),
// Size AND color
NestedFilter.and([
NestedFilter.or([
SimpleFilter.size(size: '42'),
SimpleFilter.size(size: '43'),
]),
SimpleFilter.color(color: 'red'),
]),
]);
final response = await client.trackSearchView(
screenToken: 'search_results',
query: 'shoes',
filter: complexFilter,
);
Banners
Banners are dynamic content overlays (popup modals or bars) that can be displayed based on user behavior and configured triggers. The SDK automatically handles banner display, positioning, and tracking.
Using BannerDisplayWidget
Wrap your screen content with BannerDisplayWidget to enable banner display on that screen:
import 'package:releva_sdk/widgets/banner_display_widget.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final ScrollController _scrollController = ScrollController();
@override
Widget build(BuildContext context) {
final client = RelevaManager.instance.client;
return BannerDisplayWidget(
key: const ValueKey('home-banner-display'), // Important: Use a unique key
targetSelector: "#home-content",
client: client,
onLinkTap: (url) {
// Handle link taps from banner content (buttons, links, images)
final uri = Uri.tryParse(url);
if (uri != null) {
if (uri.scheme == 'http' || uri.scheme == 'https') {
launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
// Handle deep links (e.g. myapp://product/123)
Navigator.of(context).pushNamed(uri.path, arguments: uri.queryParameters);
}
}
},
child: Scaffold(
body: ListView(
controller: _scrollController,
children: [
// Your screen content
],
),
),
);
}
}
Banner Lifecycle and Behavior
Important: Banners are reset when you navigate back to a screen
When you call trackScreenView() on a screen (which happens automatically with RouteAware or when you manually track the screen), banners for that screen are reset and will be shown again based on their trigger conditions. This ensures users see relevant banners each time they visit a screen.
Example flow:
- User views Home screen → Banner shows (trigger: immediately)
- User navigates to Product screen
- User returns to Home screen → Banner shows again (reset on navigation back)
Why this matters:
- Banners will re-display when users navigate back to a screen
- Each screen visit is treated as a fresh opportunity to show banners
- This behavior is intentional and matches web SDK behavior
Banner Triggers
Banners can be configured with different triggers in the Releva dashboard:
- immediately: Shows as soon as the screen loads
- delaySeconds: Shows after a specified delay (e.g., 5 seconds)
- scrollPercentage: Shows when user scrolls to a certain percentage (requires ScrollController)
- cartChanged: Shows when cart is modified
- wishlistChanged: Shows when wishlist is modified
- leaveIntent: Not supported on mobile (web-only feature)
Scroll-based Triggers
For scroll-based banner triggers, you need to provide a ScrollController:
class _HomeScreenState extends State<HomeScreen> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Set scroll controller for banner positioning
RelevaManager.instance.setScrollController(_scrollController);
}
@override
Widget build(BuildContext context) {
return BannerDisplayWidget(
// ... same as above
child: ListView(
controller: _scrollController, // Pass to your scrollable widget
children: [...],
),
);
}
}
Banner Types
The SDK supports two banner display types:
- Popup Banners: Modal overlays with backdrop, close button, and customizable styling
- Bar Banners: Fixed position bars (top/bottom) with configurable height and styling
All banner styling, positioning, and content are configured in the Releva dashboard.
Best Practices
- Use unique ValueKey: Always provide a unique
ValueKeyto eachBannerDisplayWidgetto maintain proper state - One BannerDisplayWidget per screen: Each screen should have its own
BannerDisplayWidgetwrapper - Track screen views: Ensure you call
trackScreenView()when the screen is displayed (use RouteAware pattern) - Provide ScrollController: If using scroll-triggered banners, ensure you pass the ScrollController to both the widget and RelevaManager
Automatic Tracking
The SDK automatically tracks:
- Banner impressions (when banner is displayed)
- Banner taps (when user clicks on banner)
- Banner dismissals (when user closes banner or taps outside)
All tracking happens transparently - you don't need to implement any tracking logic.
Stories
Stories are full-screen, multi-slide content experiences similar to Instagram or Facebook stories. They support auto-advance timers, progress indicators, tap/swipe navigation, and configurable end behavior (dismiss, loop, or stay on last slide). Like banners, stories are configured in the Releva dashboard and delivered as part of the push response.
Setup
Stories require three things:
-
StoryDisplayWidget— wrap your screen content with this widget. It listens for story display events and opens the full-screen story viewer. -
StoryManagerService— processes stories from push responses and evaluates trigger conditions. You create an instance and callinitialize()after each push response. -
Push response wiring — after each push, pass
response.storiesto the story manager so triggers are evaluated.
import 'package:releva_sdk/widgets/story_display_widget.dart';
import 'package:releva_sdk/services/story_manager_service.dart';
// Create a StoryManagerService instance (e.g. in your app manager/service)
final storyManager = StoryManagerService();
// After each push response, initialize stories:
final response = await client.push(request);
if (response.hasStories) {
storyManager.initialize(response.stories, scrollController, client);
}
Then wrap your screen content with StoryDisplayWidget:
class HomeScreen extends StatelessWidget {
final RelevaClient client;
const HomeScreen({super.key, required this.client});
@override
Widget build(BuildContext context) {
return StoryDisplayWidget(
client: client,
onLinkTap: (url) {
// Handle link taps from within story slides
launchUrl(Uri.parse(url));
},
child: Scaffold(
body: ListView(
children: [
// Your screen content
],
),
),
);
}
}
How Stories Are Triggered
Stories share the same trigger system as banners. When you call trackScreenView() (or any push method), the server response may include stories. Pass them to StoryManagerService.initialize() to evaluate triggers:
- immediately: Story opens as soon as the response is processed
- delaySeconds: Story opens after the configured delay
- scrollPercentage: Story opens when the user scrolls past the threshold (requires
ScrollController— see Banners section) - cartChanged / wishlistChanged: Story opens when cart or wishlist is modified
Story Viewer Behavior
The story viewer is a full-screen overlay that displays slides sequentially:
- Progress indicators at the top show the current position and auto-advance timer
- Tap left half of the screen to go to the previous slide
- Tap right half to go to the next slide
- Swipe left/right to navigate between slides
- Close button (X) in the top-right corner dismisses the story
- Each slide's background color is read from the Unlayer design JSON
When multiple stories are triggered simultaneously, they are queued and shown one at a time.
End Behavior
Each story has a configurable end behavior (set in the Releva dashboard):
- dismiss (default): Story closes automatically after the last slide
- loop: Story restarts from the first slide
- stayOnLast: Story stays on the last slide until the user closes it
Link Handling
Story slides can contain interactive elements (buttons, links) created in the Unlayer editor. When a user taps one of these elements, the onLinkTap callback you provided to StoryDisplayWidget is called with the URL:
StoryDisplayWidget(
client: client,
onLinkTap: (url) {
// Navigate to a screen, open a browser, or handle deep links
launchUrl(Uri.parse(url));
},
child: ...,
);
Automatic Tracking
The SDK automatically tracks all story engagement events:
- storyImpression — when the story viewer opens
- storySlideView — when each slide is displayed
- storySlideClick — when the user taps a link or button within a slide
- storyComplete — when the last slide is reached
- storyClose — when the user dismisses the story
All tracking happens transparently — no manual tracking code is needed.
App Inbox
App Inbox is a persistent, in-app message centre. Unlike push notifications, inbox messages survive until the user reads or deletes them (or they expire). Messages are delivered server-side with content already personalized — the SDK receives ready-to-render data.
Initialize the Inbox
Call initializeInbox() after setting the profile ID. The inbox is gated behind the enableInbox config flag (enabled by default in RelevaConfig.full()).
await client.setProfileId('<profileId>');
await client.initializeInbox();
Access Inbox State
The InboxService is a ChangeNotifier. Listen to it for reactive UI updates:
final inbox = client.inbox;
// Current state
print(inbox.state.messages); // List<InboxMessage>
print(inbox.state.unreadCount); // int
print(inbox.state.isLoading); // bool
print(inbox.state.hasMore); // bool (more pages available)
// Listen for changes
inbox.addListener(() {
setState(() {}); // or use ListenableBuilder
});
Refresh and Pagination
// Pull-to-refresh: fetch first page + unread count
await client.inbox.refresh();
// Infinite scroll: load next page
await client.inbox.loadMore();
// Refresh only if cache is stale (> 5 minutes)
await client.inbox.refreshIfStale();
Mark as Read and Delete
All mutations use optimistic updates — the UI updates instantly, and reverts on API error.
// Mark a single message as read
await client.inbox.markAsRead(message.id);
// Mark all messages as read
await client.inbox.markAllAsRead();
// Delete a message
await client.inbox.deleteMessage(message.id);
Track Message Actions
Call trackAction() when a user taps an interactive element inside a message (e.g. a button or link). This records an analytics event but does not mark the message as read.
await client.inbox.trackAction(message.id);
Render Message Content
Use InboxMessageWidget to render the message body. It wraps the SDK's DesignRenderer and automatically tracks actions on link taps:
import 'package:releva_sdk/widgets/inbox_message_widget.dart';
InboxMessageWidget(
message: inboxMessage,
onLinkTap: (url) {
// Handle URL (e.g. open in browser or deep link)
launchUrl(Uri.parse(url));
},
);
Inbox Sync
When a push notification has an associated inbox message, the backend includes an inbox_sync flag in the push payload. The SDK automatically handles this — it refreshes the inbox alongside displaying the notification. No additional setup is required beyond enablePushEngagementTracking().
The inbox also refreshes automatically when the app returns to the foreground, as a reliable fallback.
InboxMessage Data Model
| Field | Type | Description |
|---|---|---|
id |
String (UUID) |
Unique ID of this delivery. Use in all read/delete/action calls. |
title |
String |
Resolved message title. |
design |
Map<String, dynamic> |
Unlayer design JSON, ready to render. |
read |
bool |
Whether the user has read this message. |
createdAt |
DateTime |
When the message was delivered. Messages are sorted newest-first. |
inboxMessageId |
int |
ID of the source message template. |
Expected Push Notification Payload
Releva sends push notifications with the following data-only payload structure:
{
"data": {
"click_action": "RELEVA_NOTIFICATION_CLICK",
"title": "Special Offer!",
"body": "Get 20% off your next purchase",
"imageUrl": "https://example.com/image.jpg",
"button": "Shop Now",
"target": "screen",
"navigate_to_screen": "/product/123",
"callbackUrl": "https://api.releva.ai/track/..."
}
}
The SDK automatically:
- Displays rich notifications with images and action buttons
- Calls your
onNotificationTappedcallback with parsed navigation fields when tapped - Tracks engagement metrics (delivered, opened, dismissed)
Configuration Options
// Full functionality (default)
RelevaClient('', 'token');
// Custom configuration
RelevaClient('', 'token', config: RelevaConfig(
enableTracking: true,
enableScreenTracking: true, // Automatic screen tracking
enablePushNotifications: true,
enableInbox: true, // App Inbox
));
API Reference
RelevaClient Methods
Configuration
Future<void> setDeviceId(String deviceId)- Set unique device identifierFuture<void> setProfileId(String profileId, [bool skipMergeWithPreviousProfileId = false])- Set user profile identifier. Passtrueas second parameter when logging out to prevent merging logged-in profile with anonymous profilevoid setAppVersion(String appVersion)- Set app version string for NPS contextvoid setEndpointOverride(String? url)- Override the API endpoint URL (e.g. ngrok URL for local development). Passnullto revert to the defaultFuture<void> setCart(Cart cart)- Update user's cartFuture<void> setWishlist(List<WishlistProduct> products)- Update user's wishlistFuture<void> enablePushEngagementTracking({required Function(RelevaNotificationAction) onNotificationTapped})- Enable push notification tracking with required tap callbackFuture<void> registerPushToken(DeviceType type, String token)- Register FCM tokenFuture<void> refreshPushToken()- Manually re-fetch the current FCM token and re-upload it if stale. The SDK calls this automatically on app launch, on foreground resume, and ononTokenRefreshafterenablePushEngagementTracking()has been calledScreenTrackingService createScreenTrackingService()- Create screen tracking observerInboxService get inbox- Access the inbox serviceFuture<void> initializeInbox()- Initialize inbox (call after setProfileId)
Tracking
Future<RelevaResponse> push(PushRequest request)- Send custom tracking requestFuture<RelevaResponse> trackScreenView({required String screenToken, ...})- Track screen viewsFuture<RelevaResponse> trackProductView({required String screenToken, required String productId, ...})- Track product viewsFuture<RelevaResponse> trackSearchView({required String screenToken, ...})- Track search queriesFuture<RelevaResponse> trackCheckoutSuccess({required String screenToken, required Cart orderedCart, ...})- Track successful purchases
Push Notifications
Future<void> trackEngagement(RemoteMessage message)- Track notification engagementbool isRelevaMessage(RemoteMessage message)- Check if notification is from Releva
Response Models
RelevaResponse
class RelevaResponse {
final List<RecommenderResponse> recommenders;
final List<BannerResponse> banners;
bool get hasRecommenders;
bool get hasBanners;
RecommenderResponse? getRecommenderByToken(String token);
}
ProductRecommendation
class ProductRecommendation {
final String id;
final String name;
final double price;
final bool available;
final String? imageUrl;
final double? discountPrice;
final List<String>? categories;
}
InboxMessage
class InboxMessage {
final String id;
final String title;
final Map<String, dynamic> design;
bool read;
final DateTime createdAt;
final int inboxMessageId;
}
InboxService Methods
Future<void> refresh()- Fetch first page + unread countFuture<void> loadMore()- Fetch next page (cursor pagination)Future<void> markAsRead(String messageId)- Mark single message as readFuture<void> markAllAsRead()- Mark all messages as readFuture<void> deleteMessage(String messageId)- Delete a messageFuture<void> trackAction(String messageId)- Track tap/click on message contentFuture<void> refreshIfStale()- Refresh only if cache > 5 minutes oldInboxState get state- Current inbox state (messages, unreadCount, isLoading, hasMore)
NPS Surveys
The SDK supports native NPS (Net Promoter Score) surveys delivered as overlays. The server decides which profiles are eligible; the SDK handles session counting, trigger evaluation, rendering, and submission.
Setup
1. Add NpsOverlayWidget to your app
Wrap your widget tree (typically via MaterialApp's builder) so surveys can appear on any screen:
MaterialApp(
builder: (context, child) => NpsOverlayWidget(
onSubmit: (token, score, comment) async {
await client.submitNpsResponse(token: token, score: score, comment: comment);
},
child: child!,
),
// ...
)
2. Set the app version (recommended)
Call this once after initialising the client so the server can filter by app version:
client.setAppVersion('3.2.1');
How it works
On every push() call the SDK automatically includes device context (session count, platform, app version, first seen date, total push call count) in the request body. When the server returns an nps field in the response, the SDK stores the config and evaluates trigger conditions:
| Trigger type | When it fires |
|---|---|
appOpen |
First push call of a new session |
sessionCount |
Treated as already satisfied (server pre-checks minSessions) |
customEvent |
When client.trackEvent(eventName) matches eventName |
After a trigger fires, the SDK waits triggerDelaySeconds before presenting the overlay. Once shown (or cancelled), it is suppressed for the rest of the session.
Firing custom events
// Trigger NPS after checkout
client.trackEvent('checkout_complete');
// Cancel pending NPS if user enters a sensitive flow
client.trackEvent('checkout_started');
Session boundary
A new device session starts when more than 30 minutes elapse between push calls (configurable via deviceSessionDurationMillis). The session count is persisted across app restarts.
Overlay appearance
The survey appearance is fully server-driven: colors, button style (pill/rounded/square), position (bottomSheet/modal), labels, and dark-mode variants are all read from the nps.appearance config returned by the server. No hardcoded strings or colors are used.
API reference (NPS)
void setAppVersion(String version)— Set the running app version for NPS contextvoid trackEvent(String eventName)— Fire a named event (triggers / cancel events)Future<void> submitNpsResponse({required String token, required int score, String? comment})— Submit a survey response (called automatically byNpsOverlayWidget)
Publishing
To publish a new version:
- Bump the version in
pubspec.yaml - Run:
make publish
This automatically regenerates lib/pubspec.dart (so RelevaClient reports the correct version) before publishing to pub.dev.
Additional Information
For additional information, please visit https://releva.ai or contact tech-support@releva.ai
Libraries
- client
- controllers/nps_display_controller
- controllers/story_display_controller
- pubspec
- services/engagement_tracking_service
- services/hive_storage_service
- services/inbox_service
- services/notification_display_service
- services/nps_manager_service
- services/push_token_refresh_service
- services/screen_tracking_service
- services/session_service
- services/story_manager_service
- types/cart/cart
- types/cart/cart_product
- types/custom_field/custom_field
- types/custom_field/custom_fields
- types/device/device_type
- types/event/custom_event
- types/event/custom_event_product
- types/event/engagement_event_type
- types/filter/abstract_filter
- types/filter/nested_filter
- types/filter/simple_filter
- types/inbox/inbox_message
- types/inbox/inbox_state
- types/notification_action
- types/push_request
- types/releva_config
- types/response/nps_response
- types/response/recommender_response
- types/response/releva_response
- types/response/story_response
- types/tracking/checkout_success_request
- types/tracking/screen_view_request
- types/tracking/search_request
- types/view/viewed_product
- types/wishlist/wishlist_product
- widgets/design_renderer
- widgets/inbox_message_widget
- widgets/nps_overlay_widget
- widgets/story_display_widget
- widgets/story_viewer_widget