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:

  1. In Xcode, go to File → New → Target → Notification Service Extension
  2. Name it RelevaNotificationService
  3. Add Firebase/Messaging to the extension in your Podfile
  4. Replace the generated NotificationService.swift with the implementation from releva_sdk/ios/Templates/NotificationService.swift
  5. Ensure your notification payload includes mutable-content: 1 in the aps section

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

  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()],
      // 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

IMPORTANT: Only do this if you DO NOT have ANY of the following anywhere in your app OR YOUR OTHER LIBRARIES:

  1. FirebaseMessaging.instance.onMessage.listen((message) => ...);
  2. FirebaseMessaging.instance.onMessageOpenedApp.listen((message) => ...);
  3. 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 RelevaNotificationActionyour 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", or null (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 - Parsed Map<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
          ],
        ),
      ),
    );
  }
}

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:

  1. User views Home screen → Banner shows (trigger: immediately)
  2. User navigates to Product screen
  3. 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

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: [...],
      ),
    );
  }
}

The SDK supports two banner display types:

  1. Popup Banners: Modal overlays with backdrop, close button, and customizable styling
  2. 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

  1. Use unique ValueKey: Always provide a unique ValueKey to each BannerDisplayWidget to maintain proper state
  2. One BannerDisplayWidget per screen: Each screen should have its own BannerDisplayWidget wrapper
  3. Track screen views: Ensure you call trackScreenView() when the screen is displayed (use RouteAware pattern)
  4. 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:

  1. StoryDisplayWidget — wrap your screen content with this widget. It listens for story display events and opens the full-screen story viewer.

  2. StoryManagerService — processes stories from push responses and evaluates trigger conditions. You create an instance and call initialize() after each push response.

  3. Push response wiring — after each push, pass response.stories to 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

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 onNotificationTapped callback 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 identifier
  • Future<void> setProfileId(String profileId, [bool skipMergeWithPreviousProfileId = false]) - Set user profile identifier. Pass true as second parameter when logging out to prevent merging logged-in profile with anonymous profile
  • void setAppVersion(String appVersion) - Set app version string for NPS context
  • void setEndpointOverride(String? url) - Override the API endpoint URL (e.g. ngrok URL for local development). Pass null to revert to the default
  • Future<void> setCart(Cart cart) - Update user's cart
  • Future<void> setWishlist(List<WishlistProduct> products) - Update user's wishlist
  • Future<void> enablePushEngagementTracking({required Function(RelevaNotificationAction) onNotificationTapped}) - Enable push notification tracking with required tap callback
  • Future<void> registerPushToken(DeviceType type, String token) - Register FCM token
  • ScreenTrackingService createScreenTrackingService() - Create screen tracking observer
  • InboxService get inbox - Access the inbox service
  • Future<void> initializeInbox() - Initialize inbox (call after setProfileId)

Tracking

  • Future<RelevaResponse> push(PushRequest request) - Send custom tracking request
  • Future<RelevaResponse> trackScreenView({required String screenToken, ...}) - Track screen views
  • Future<RelevaResponse> trackProductView({required String screenToken, required String productId, ...}) - Track product views
  • Future<RelevaResponse> trackSearchView({required String screenToken, ...}) - Track search queries
  • Future<RelevaResponse> trackCheckoutSuccess({required String screenToken, required Cart orderedCart, ...}) - Track successful purchases

Push Notifications

  • Future<void> trackEngagement(RemoteMessage message) - Track notification engagement
  • bool 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 count
  • Future<void> loadMore() - Fetch next page (cursor pagination)
  • Future<void> markAsRead(String messageId) - Mark single message as read
  • Future<void> markAllAsRead() - Mark all messages as read
  • Future<void> deleteMessage(String messageId) - Delete a message
  • Future<void> trackAction(String messageId) - Track tap/click on message content
  • Future<void> refreshIfStale() - Refresh only if cache > 5 minutes old
  • InboxState 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 context
  • void 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 by NpsOverlayWidget)

Publishing

To publish a new version:

  1. Bump the version in pubspec.yaml
  2. 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