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 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: Automatic screen navigation from notification taps
- 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
βοΈ 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.38
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
Add to android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="your_app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
...>
<!-- ADDED for Releva -->
<intent-filter>
<action android:name="RELEVA_NOTIFICATION_CLICK" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
...
</application>
</manifest>
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();
// Register FCM token
_registerPushToken();
}
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
String? token = await messaging.getToken();
if (token != null) {
DeviceType deviceType = Platform.isIOS
? DeviceType.ios
: DeviceType.android;
await client.registerPushToken(deviceType, token);
}
// Handle token refresh
messaging.onTokenRefresh.listen((newToken) async {
DeviceType deviceType = Platform.isIOS
? DeviceType.ios
: DeviceType.android;
await client.registerPushToken(deviceType, newToken);
});
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Add automatic screen tracking
navigatorObservers: [client.createScreenTrackingService()],
// Add navigation key for deep linking
navigatorKey: NavigationService.instance.navigatorKey,
home: HomeScreen(client: client),
);
}
}
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) => ...);
await client.enablePushEngagementTracking();
The SDK will automatically track engagement when notifications are opened.
Custom Notification Tap Callback
If you need to handle navigation yourself when a notification is tapped, you can provide an optional callback. When you provide a callback, the SDK will NOT handle navigation automatically - you have full control:
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
await client.enablePushEngagementTracking(
onNotificationTapped: (NotificationResponse response) {
print('Notification tapped!');
print('Response type: ${response.notificationResponseType}');
print('Action ID: ${response.actionId}'); // null if notification body was tapped, non-null if button was tapped
// Parse the payload to access notification data
final data = Uri.splitQueryString(response.payload ?? '');
// For Releva messages, check: data['click_action'] == 'RELEVA_NOTIFICATION_CLICK'
if (data['click_action'] == 'RELEVA_NOTIFICATION_CLICK') {
final deeplink = data['navigate_to_url'];
final screen = data['navigate_to_screen'];
// Handle navigation yourself
if (deeplink != null && deeplink.isNotEmpty) {
ActionHandler.instance.handleJump(ActionModel.from(deeplink));
} else if (screen != null) {
Navigator.pushNamed(context, screen);
}
}
},
);
NotificationResponse Properties:
notificationResponseType- Type of response (e.g.,NotificationResponseType.selectedNotification)actionId- ID of the tapped button (null if notification body was tapped)payload- URL-encoded string containing notification data (parse withUri.splitQueryString())
Available Fields in Parsed Payload (data):
click_action- Always"RELEVA_NOTIFICATION_CLICK"for Releva messagesnavigate_to_url- Deep link URL (if target is "url")navigate_to_screen- Screen name (if target is "screen")navigate_to_parameters- JSON string with navigation parameterstarget- Navigation type:"screen"or"url"title- Notification titlebody- Notification body textimageUrl- Image URL (if present)button- Action button text (if present)callbackUrl- Engagement tracking URL
Navigation Behavior:
- β With callback: SDK skips automatic navigation, you handle it in your callback
- β
Without callback: SDK handles navigation automatically based on
target,navigate_to_url, andnavigate_to_screenfields - β Engagement tracking: Always happens automatically regardless of callback presence
When Your Callback Runs:
- Works in all app states: foreground, background, and terminated
- Called when notification body OR action button is tapped
- Runs AFTER SDK's internal engagement tracking completes
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,
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.
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
- Navigates to the specified screen 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,
));
API Reference
RelevaClient Methods
Configuration
Future<void> setDeviceId(String deviceId)- Set unique device identifierFuture<void> setProfileId(String profileId)- Set user profile identifierFuture<void> setCart(Cart cart)- Update user's cartFuture<void> setWishlist(List<WishlistProduct> products)- Update user's wishlistFuture<void> enablePushEngagementTracking({Function(NotificationResponse)? onNotificationTapped})- Enable push notification tracking with optional tap callbackFuture<void> registerPushToken(DeviceType type, String token)- Register FCM tokenScreenTrackingService createScreenTrackingService()- Create screen tracking observer
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;
}
Additional Information
For additional information, please visit https://releva.ai or contact tech-support@releva.ai
Libraries
- client
- services/engagement_tracking_service
- services/hive_storage_service
- services/notification_display_service
- services/screen_tracking_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/push_request
- types/releva_config
- types/response/recommender_response
- types/response/releva_response
- types/tracking/checkout_success_request
- types/tracking/screen_view_request
- types/tracking/search_request
- types/view/viewed_product
- types/wishlist/wishlist_product