This article explains how to perform a full integration of a mobile app written in Flutter, using the Releva Flutter SDK.
Add the Releva Flutter SDK to your project #
Add the package to your pubspec.yaml:
dependencies:
...
releva_sdk: ^0.1.0
...
Then run flutter pub get.
Set up Firebase #
Required only if you plan to use Releva to send push notifications. Releva delivers push through Firebase Cloud Messaging.
flutter pub add firebase_core firebase_messaging
flutterfire configure
flutterfire configure generates firebase_options.dart for your project.
No Android manifest changes are needed. Releva sends data-only FCM messages and the SDK displays them itself, so the RELEVA_NOTIFICATION_CLICK intent filter that older versions of this guide asked for is no longer required โ remove it if you added it.
No iOS AppDelegate changes are needed either. The SDK registers its notification categories on start-up, which is what enables action buttons, deep links and tap handling.
Initialize the Releva Client #
Create the client once, then set the device and profile identifiers before anything else โ push engagement tracking depends on both being in place.
import 'package:releva_sdk/client.dart';
import 'package:releva_sdk/services/navigation_service.dart';
import 'package:releva_sdk/types/notification_action.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(MyApp());
}
class _MyAppState extends State<MyApp> {
late RelevaClient client;
@override
void initState() {
super.initState();
// realm - use '' unless instructed otherwise by your account manager
// accessToken - use the access token provided by your account manager
client = RelevaClient('', '<yourAccessToken>');
_initializeSDK();
}
Future<void> _initializeSDK() async {
// Set the deviceId based on your current logic for device tracking
await client.setDeviceId('<deviceId>');
// If a user has registered or logged in, provide a profileId for this user.
// This must be consistent across channels and integrations.
await client.setProfileId('<profileId>');
// Enable push notification engagement metrics (delivered, opened, dismissed).
// IMPORTANT: ensure that you have set the profileId and deviceId first!
await client.enablePushEngagementTracking(
onNotificationTapped: (RelevaNotificationAction action) async {
// Your app decides how to navigate - see "Push notifications" below
},
);
await _registerPushToken();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
// Automatic screen tracking
navigatorObservers: [client.createScreenTrackingService()],
// Optional: lets your onNotificationTapped callback obtain a BuildContext
navigatorKey: NavigationService.instance.navigatorKey,
home: HomeScreen(client: client),
);
}
}
Identify the visitor #
The SDK identifies a user solely by the profileId you set. Contact details and other profile attributes are never sent from the app โ send those from your backend through the Create / Update Profile API, where they can be validated and deduplicated against your other channels.
When a user logs in, set their profile id normally. The anonymous profile they browsed under is merged into it, so their pre-login behaviour is not lost:
await client.setProfileId(loggedInUserId);
When a user logs out, generate a fresh anonymous id and pass true as the second argument to suppress that merge โ otherwise the next person using the device inherits the previous user’s profile:
import 'package:uuid/uuid.dart';
final newAnonymousProfileId = const Uuid().v4();
await client.setProfileId(newAnonymousProfileId, true);
// Because the merge was skipped, the push token has to be registered
// against the new profile id explicitly.
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
final deviceType = Platform.isIOS ? DeviceType.ios : DeviceType.android;
await client.registerPushToken(deviceType, token);
}
Send app push tokens #
Register the FCM token once, after permissions are granted. DeviceType accepts android, ios, huawei and other.
Future<void> _registerPushToken() async {
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true, badge: true, sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
final token = await messaging.getToken();
if (token != null) {
final deviceType = Platform.isIOS ? DeviceType.ios : DeviceType.android;
await client.registerPushToken(deviceType, token);
}
}
}
This first registration is required. After it, and once enablePushEngagementTracking has been called, the SDK re-uploads the token by itself on every app launch, on every foreground resume, and whenever Firebase rotates it. You do not need to wire onTokenRefresh yourself โ FCM rotates tokens silently, and a backgrounded app that never cold-starts is the most common cause of registration-token-not-registered errors.
Push notifications #
There are two ways to wire engagement tracking. Pick one.
Option 1: let the SDK own the Firebase hooks (recommended) #
Only use this if you do not have any of the following anywhere in your app or in your other libraries: FirebaseMessaging.instance.onMessage.listen(...), FirebaseMessaging.instance.onMessageOpenedApp.listen(...), or FirebaseMessaging.instance.getInitialMessage().then(...).
The SDK takes all three hooks and tracks engagement itself. Your app supplies an onNotificationTapped callback and decides how to navigate, so the SDK stays compatible with any routing approach (named routes, GoRouter, auto_route, and so on).
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 a deep link or the browser
// action.parameters - parsed key/value parameters for the navigation
final context = NavigationService.instance.navigatorKey.currentContext;
if (context == null) return;
if (action.target == 'inbox') {
Navigator.of(context).pushNamedAndRemoveUntil(
'/inbox', (route) => route.isFirst, arguments: action.parameters,
);
} else if (action.target == 'screen' && action.screen != null) {
// action.screen is the free-form name configured in the Releva dashboard;
// it should match one of your app's route names.
Navigator.of(context).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);
}
},
);
The callback is required, and engagement is always tracked before it fires. It works in all app states โ foreground, background and terminated.
Option 2: keep your own Firebase hooks #
If your app already listens to Firebase messaging, call the SDK from inside your existing handlers instead:
FirebaseMessaging.instance.onMessage.listen((message) async {
// Your existing logic...
await client.trackEngagement(message);
});
FirebaseMessaging.instance.onMessageOpenedApp.listen((message) async {
// Your existing logic...
await client.trackEngagement(message);
});
FirebaseMessaging.instance.getInitialMessage().then((message) async {
if (message != null) {
// Your existing logic...
await client.trackEngagement(message);
}
});
Use client.isRelevaMessage(message) if you need to tell Releva notifications apart from your own.
Dynamic button text on iOS (optional) #
iOS requires notification action buttons to be registered before a notification is displayed. In the foreground the SDK handles this, but for notifications that arrive while your app is backgrounded or terminated you need a Notification Service Extension, or the button falls back to a generic โOpenโ. Ask your account manager for the setup guide if you want custom button text in those states.
Track what the visitor is doing #
The high-level methods cover the common screens. Each returns a RelevaResponse carrying the recommenders and banners configured for that screen token.
// Product page
final response = await client.trackProductView(
screenToken: 'product_detail',
productId: 'product-123',
categories: ['electronics', 'phones'],
locale: 'en',
currency: 'USD',
);
// Listing or home screen
await client.trackScreenView(
screenToken: 'home_screen',
productIds: ['prod1', 'prod2', 'prod3'],
categories: ['electronics'],
);
// Search results
await client.trackSearchView(
screenToken: 'search_results',
query: 'red running shoes',
resultProductIds: ['prod1', 'prod2', 'prod3'],
);
// Checkout success - the user is identified by the profileId you already set
await client.trackCheckoutSuccess(
screenToken: 'checkout_success',
orderedCart: Cart.active([...]),
);
Reading the response:
if (response.hasRecommenders) {
for (final recommender in response.recommenders) {
print('${recommender.name}: ${recommender.response.length} products');
}
}
Cart and wishlist #
Keep the cart in step with the app. If the user empties their cart, set an active cart with an empty product list rather than leaving the last known state behind.
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';
// Custom fields describe the variant the user chose
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]);
CartProduct product = CartProduct('<productId>', 29.99, 1, custom);
await client.setCart(Cart.active([product]));
// Wishlist, if your app has one
WishlistProduct wishlistProduct = WishlistProduct('<productId>', CustomFields.empty());
await client.setWishlist([wishlistProduct]);
Sending a request by hand #
For anything the high-level methods do not cover, build a PushRequest and send it with client.push(...):
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';
PushRequest request = PushRequest()
// If the screen uses a non-default language
.locale('en')
// If the screen uses a non-default currency
.currency('EUR')
// If the screen shows a list with a filter applied
.pageFilter(NestedFilter.and([]))
// The screen (a.k.a. page) token the user is viewing
.screenView('<pageToken>')
// The product the user is viewing
.productView(Viewedproduct('<productId>', CustomFields.empty()))
// Any custom events - e.g. the user filled out a form
.customEvents([
CustomEvent(
'fubar',
[CustomEventProduct('<productId>', 2)],
['foo_tag'],
CustomFields.empty(),
)
]);
RelevaResponse response = await client.push(request);
Filtering #
Filters combine with nested AND/OR logic and are passed on listing and search screens:
final complexFilter = NestedFilter.and([
SimpleFilter.priceRange(minPrice: 10, maxPrice: 100),
NestedFilter.or([
SimpleFilter.brand(brand: 'Nike'),
SimpleFilter.brand(brand: 'Adidas'),
]),
SimpleFilter.color(color: 'red'),
]);
await client.trackSearchView(
screenToken: 'search_results',
query: 'shoes',
filter: complexFilter,
);
On-screen content #
Beyond recommendations, the SDK can render content you configure in Releva. Each of these is optional and off unless you add it.
- Banners โ popup or bar overlays, shown on the triggers you configure (including scroll depth). Wrap a screen in
BannerDisplayWidgetand the SDK handles display, positioning and tracking. - Stories โ tappable, swipeable story sets with interactive slide elements.
- App Inbox โ a persistent message centre whose messages survive until read, deleted or expired, kept in sync by silent push and cached locally. Call
client.initializeInbox()after setting the profile id, then drive it throughclient.inbox. - NPS surveys โ native overlay surveys; Releva decides who is eligible and the SDK handles session counting, triggering and submission. Add
NpsOverlayWidgetto your app.
Configuration options #
Every feature is on by default. Turn off what you do not use:
RelevaClient('', '<yourAccessToken>', config: RelevaConfig(
enableTracking: true,
enableScreenTracking: true, // the NavigatorObserver
enablePushNotifications: true,
enableInbox: true,
));
Backend calls you still need #
The app covers what the user does. Your catalogue, your orders and your consent changes still have to reach Releva from your backend:
- Call the Product Create / Update API whenever your catalogue changes.
- Implement the Periodical Product Sync as a fallback, so a missed update cannot leave Releva stale.
- Call the Cart Paid API when a customer places an order, and the Refund API when one is refunded.
- Call the Subscribe and Unsubscribe APIs when a user changes their marketing preferences.
- Call the Profile Register API when a new user registers.
All set! #
Your app is integrated. Explore the API Specification for the server-side calls that let you send custom product and profile data, which can then be used in personalization.