View Categories

Flutter (Android / iOS)

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.3.2
  ...

Then run flutter pub get.

Requirements #

  • Flutter 3.27 (Dart 3.6) or later.
  • iOS 15.0 deployment target — set platform :ios, '15.0' in ios/Podfile if it is lower.
  • Android minSdk 23 — set it in android/app/build.gradle(.kts) if it is lower.
  • firebase_core ^4.14.0 and firebase_messaging ^16.7.0. Any other FlutterFire plugins you use must be on the versions built on firebase_core 4 (for example firebase_auth 6, cloud_firestore 6, firebase_storage 13). firebase_messaging 17 and firebase_core 5 are not supported yet.
  • flutter_local_notifications 18.x or 19.x, if your app depends on it directly.

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.

Apps on the iOS UIScene lifecycle (your Info.plist has a UIApplicationSceneManifest): notification taps reach the SDK only with firebase_messaging 16.7 or later, which is why it is the minimum above. Keep setting UNUserNotificationCenter.current().delegate in application(_:didFinishLaunchingWithOptions:) and registering plugins in didInitializeImplicitFlutterEngine(_:), as flutter_local_notifications documents for UIScene apps.

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. Run this setup on every launch.

import 'dart:io';
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.
    // Skip this while the user is anonymous - see "Identify the visitor" below.
    await client.setProfileId('<profileId>');

    // Enable push notification engagement metrics (delivered, opened, dismissed).
    // IMPORTANT: ensure that you have set the deviceId (and the profileId, if any) 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 by the profileId you set. Until you set one, the visitor is anonymous and identified by the device id — they are still tracked and can still receive push notifications. 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);

You do not need to re-register the push token after a login or logout. setProfileId moves the device’s push token to the new profile in the background, so a logged-out user’s notifications stop reaching the device and the new profile’s start to. It never waits on the network and never fails because of it. If the move fails (for example, the user logged in while offline), the SDK retries it on the next launch or foreground resume.

Do not use setProfileId('') to log a user out — assign a fresh anonymous id as above.

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);
    }
  }
}

A token can be registered before the user logs in: an anonymous device is registered under its device id, and the token follows the user to their profile when you call setProfileId.

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, whenever Firebase rotates it, and whenever the profile id changes. 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. When a tap cold-starts the app, the SDK waits until your first frame has been drawn before calling it, so navigatorKey.currentContext is available even if you await the SDK setup before runApp.

On iOS, a Releva push that arrives while your app is in the foreground is shown as a banner. To make that happen, the SDK sets Firebase’s foreground presentation options (alert, badge and sound) when you call enablePushEngagementTracking. Those options apply to every notification your app receives in the foreground, so if you want foreground notifications hidden, call FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(...) with your own values after enabling tracking.

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.

Images and dynamic button text on iOS (optional) #

iOS requires notification action buttons to be registered before a notification is displayed, and attaches images only from a Notification Service Extension. Without one, Releva notifications on iOS arrive without their image and show a generic “Open” button in place of your custom button text.

The SDK ships a ready-made extension: add a Notification Service Extension target in Xcode, add Firebase Messaging to it in your Podfile, and replace the generated NotificationService.swift with ios/Templates/NotificationService.swift from the releva_sdk package. The step-by-step guide is ios/SETUP_NOTIFICATION_SERVICE_EXTENSION.md in the same package. The extension handles JPEG, PNG and GIF images and converts WebP, HEIC and other formats iOS cannot attach directly; notifications that are not Releva’s are passed on to Firebase unchanged.

If you copied the template into your extension before version 0.3.2, copy it again.

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, bar and flyout overlays, shown on the triggers you configure (including scroll depth). Wrap a screen in BannerDisplayWidget and the SDK handles display, positioning and tracking. The card colour, size, corner radius, position and close-button styling set in the banner editor are applied on the device, as they are on the web.
  • 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 through client.inbox.
  • NPS surveys — native overlay surveys; Releva decides who is eligible and the SDK handles session counting, triggering and submission. Add NpsOverlayWidget to your app. A survey with a dark appearance configured shows it when the device is in dark mode, whatever your app’s own theme.

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:

  1. Call the Product Create / Update API whenever your catalogue changes.
  2. Implement the Periodical Product Sync as a fallback, so a missed update cannot leave Releva stale.
  3. Call the Cart Paid API when a customer places an order, and the Refund API when one is refunded.
  4. Call the Subscribe and Unsubscribe APIs when a user changes their marketing preferences.
  5. Call the Profile Register API when a new user registers.

Upgrading from 0.2.x or earlier #

  • Raise your project to the requirements above: Flutter 3.27, iOS 15.0, Android minSdk 23, firebase_messaging ^16.7.0 with firebase_core ^4.14.0, and the matching versions of your other FlutterFire plugins.
  • Remove any code that re-registers the push token after a login or logout. setProfileId now does it for you; keeping your own call is harmless but unnecessary.
  • Nothing else is needed for existing installs: on the first launch after the update the SDK re-uploads the stored push token once under the current profile, and then only when something changes.
  • Popups without a configured size are now drawn as a centred card sized to their content (600 wide by default) instead of filling the screen, as the Swift and React Native SDKs draw them. Banners also pick up the card and close-button styling set in the banner editor, so check your existing banners on a device.
  • On iOS, if you use the Notification Service Extension, copy ios/Templates/NotificationService.swift into it again.

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.