Flutter Low-Code Integration

Use the Salesforce Personalization Flutter plugin to display personalized banners, recommendations, and custom components in your Flutter app with minimal setup. The plugin bridges the native iOS and Android Personalization SDKs and provides a declarative ContentZone widget that handles fetching, rendering, and engagement tracking for you. Your app owns native SDK initialization, consent, trusted navigation, and accessible UI.

  1. Install the SDK
  2. Initialize the SDK
  3. Display a Content Zone
  4. Out-of-the-Box Components
  5. Custom Components
  6. Engagement Tracking
  7. Preview
  8. Design-Time Rendering
  9. Identity and Events

Install the SDK 

1flutter pub add flutter_salesforce_personalization

Add the package. It resolves to the latest published version.

On iOS, the native pods are pulled in automatically the next time you build (or run pod install). On Android, you must also declare the Salesforce Maven repositories (see Initialize the SDK).

Requirements 

RequirementReviewed baseline
Flutter3.19 or later
Dart3.3 or later
AndroidAPI 26 minimum; compile SDK 37
Java17
Kotlin Gradle plugin2.3.0
Android Gradle Plugin8.13.2
Gradle wrapper8.13
iOS deployment target15.0 or later
Swift5.7 or later

Obtain these values from your Salesforce administrator and inject environment-specific values through the app’s existing build-configuration or secret-management process. The samples show key names only.

KeyValue
salesforce.cdp.appIdCDP application ID
salesforce.cdp.endpointBare endpoint host with no URL scheme
salesforce.cdp.cdnUrlCDN configuration URL
salesforce.cdp.dataspaceDataspace, commonly default

Initialize the SDK 

The native SDKs must be initialized before any ContentZone widget attempts to fetch content. Initialization creates the connection to Data 360 (for identity and behavioral data) and the Personalization service (for content decisions). The Flutter plugin does not expose a Dart-side configure(...) call - the host app initializes the native SDK directly in platform code, ensuring it runs before the Flutter engine renders its first frame.

Complete both the Android and iOS platform setup below.

Android setup 

The plugin supplies native SDK dependencies transitively. The host app must provide the Salesforce Maven repositories and a compatible Android toolchain.

Use the reviewed plugins in android/settings.gradle.kts:

1pluginManagement {
2    val flutterSdkPath = run {
3        val properties = java.util.Properties()
4        file("local.properties").inputStream().use { properties.load(it) }
5        requireNotNull(properties.getProperty("flutter.sdk")) {
6            "flutter.sdk not set in local.properties"
7        }
8    }
9    includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
10    repositories { google(); mavenCentral(); gradlePluginPortal() }
11}
12
13dependencyResolutionManagement {
14    repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
15    repositories { google(); mavenCentral() }
16}
17
18plugins {
19    id("dev.flutter.flutter-plugin-loader") version "1.0.0"
20    id("com.android.application") version "8.13.2" apply false
21    id("org.jetbrains.kotlin.android") version "2.3.0" apply false
22}
23
24include(":app")

Use Gradle 8.13 in android/gradle/wrapper/gradle-wrapper.properties:

1distributionBase=GRADLE_USER_HOME
2distributionPath=wrapper/dists
3zipStoreBase=GRADLE_USER_HOME
4zipStorePath=wrapper/dists
5distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip

Configure android/app/build.gradle.kts. Replace the sample namespace and application ID.

1plugins {
2    id("com.android.application")
3    id("kotlin-android")
4    id("dev.flutter.flutter-gradle-plugin")
5}
6
7repositories {
8    google()
9    mavenCentral()
10    maven { url = uri("https://salesforce-marketingcloud.github.io/MarketingCloudSDK-Android/repository") }
11    maven { url = uri("https://salesforce-marketingcloud.github.io/mobile-sdk-cdp-android/repository") }
12    maven { url = uri("https://salesforce-marketingcloud.github.io/Personalization-Android/repository") }
13}
14
15android {
16    namespace = "com.example.personalized_app"
17    compileSdk = 37
18    compileOptions {
19        sourceCompatibility = JavaVersion.VERSION_17
20        targetCompatibility = JavaVersion.VERSION_17
21    }
22    defaultConfig {
23        applicationId = "com.example.personalized_app"
24        minSdk = 26
25        targetSdk = flutter.targetSdkVersion
26        versionCode = flutter.versionCode
27        versionName = flutter.versionName
28    }
29}
30
31kotlin {
32    compilerOptions {
33        jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
34    }
35}
36
37flutter { source = "../.." }

Do not add com.salesforce.personalization:sdk to app dependencies. The plugin exposes it transitively.

Declare INTERNET in the main manifest so release builds can reach the service. Register the custom Application and tenant metadata in android/app/src/main/AndroidManifest.xml. The custom preview scheme below is for controlled development devices only, use verified Android App Links for production preview tokens.

1<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2    <uses-permission android:name="android.permission.INTERNET" />
3    <application
4        android:name=".MainApplication"
5        android:icon="@mipmap/ic_launcher"
6        android:label="personalized_app">
7        <meta-data android:name="salesforce.cdp.appId" android:value="YOUR_CDP_APP_ID" />
8        <meta-data android:name="salesforce.cdp.endpoint" android:value="YOUR_CDP_ENDPOINT" />
9        <meta-data android:name="salesforce.cdp.dataspace" android:value="default" />
10        <meta-data android:name="salesforce.cdp.cdnUrl" android:value="YOUR_CDN_URL" />
11        <activity
12            android:name=".MainActivity"
13            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
14            android:exported="true"
15            android:hardwareAccelerated="true"
16            android:launchMode="singleTop"
17            android:theme="@style/LaunchTheme"
18            android:windowSoftInputMode="adjustResize">
19            <meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme" />
20            <meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
21            <intent-filter>
22                <action android:name="android.intent.action.MAIN" />
23                <category android:name="android.intent.category.LAUNCHER" />
24            </intent-filter>
25            <intent-filter>
26                <action android:name="android.intent.action.VIEW" />
27                <category android:name="android.intent.category.DEFAULT" />
28                <category android:name="android.intent.category.BROWSABLE" />
29                <data android:host="preview" android:scheme="personalizationdemo" />
30            </intent-filter>
31        </activity>
32        <meta-data android:name="flutterEmbedding" android:value="2" />
33    </application>
34</manifest>

Create MainApplication.kt (or use the existing with the initialization code) under the directory matching its package. This complete example deliberately leaves consent unset.

1package com.example.personalized_app
2
3import android.content.pm.PackageManager
4import android.util.Log
5import com.salesforce.marketingcloud.cdp.CdpConfig
6import com.salesforce.marketingcloud.sfmcsdk.InitializationStatus
7import com.salesforce.marketingcloud.sfmcsdk.SFMCSdk
8import com.salesforce.marketingcloud.sfmcsdk.SFMCSdkModuleConfig
9import com.salesforce.personalization.PersonalizationConfig
10import io.flutter.app.FlutterApplication
11
12class MainApplication : FlutterApplication() {
13    override fun onCreate() {
14        super.onCreate()
15        val data = packageManager
16            .getApplicationInfo(packageName, PackageManager.GET_META_DATA).metaData
17        val appId = data.getString("salesforce.cdp.appId").orEmpty()
18        val endpoint = data.getString("salesforce.cdp.endpoint").orEmpty()
19        val dataspace = data.getString("salesforce.cdp.dataspace") ?: "default"
20        val cdnUrl = data.getString("salesforce.cdp.cdnUrl")
21        if (appId.isBlank() || appId.startsWith("YOUR_") ||
22            endpoint.isBlank() || endpoint.startsWith("YOUR_")) {
23            Log.w("PersonalizedApp", "Personalization is not configured; SDK init skipped")
24            return
25        }
26
27        val cdp = CdpConfig.Builder(applicationContext, appId, endpoint)
28            .trackScreens(false)
29            .trackLifecycle(false)
30            .sessionTimeout(1800L)
31            .eventFlushRate(CdpConfig.EventFlushRate.QuantityAndInterval(10L, 5000L))
32            .build()
33        val personalization =
34            PersonalizationConfig.Builder(applicationContext).dataspace(dataspace)
35        if (!cdnUrl.isNullOrBlank() && !cdnUrl.startsWith("YOUR_")) {
36            personalization.cdnUrl(cdnUrl)
37        }
38
39        SFMCSdk.configure(applicationContext, SFMCSdkModuleConfig.build {
40            cdpModuleConfig = cdp
41            personalizationModuleConfig = personalization.build()
42        }) { status ->
43            val message = if (status.status == InitializationStatus.SUCCESS) {
44                "SDK ready; waiting for user consent"
45            } else {
46                "SDK initialization failed"
47            }
48            Log.d("PersonalizedApp", message)
49        }
50    }
51}

iOS setup 

Set the deployment target to iOS 15 or later and use Swift 5.7 or later. The app target must use use_frameworks!. Flutter autolinking installs the native Salesforce pods; do not declare duplicate Salesforce pods manually.

Add the four salesforce.cdp.* keys inside the existing top-level dict in ios/Runner/Info.plist. The custom preview scheme is for controlled development devices only; use a verified Universal Link for production tokens:

1<key>salesforce.cdp.appId</key>
2<string>YOUR_CDP_APP_ID</string>
3<key>salesforce.cdp.endpoint</key>
4<string>YOUR_CDP_ENDPOINT</string>
5<key>salesforce.cdp.dataspace</key>
6<string>default</string>
7<key>salesforce.cdp.cdnUrl</key>
8<string>YOUR_CDN_URL</string>
9<key>CFBundleURLTypes</key>
10<array>
11    <dict>
12        <key>CFBundleURLName</key>
13        <string>com.example.personalized-app.preview</string>
14        <key>CFBundleURLSchemes</key>
15        <array><string>personalizationdemo</string></array>
16    </dict>
17</array>
18<key>FlutterDeepLinkingEnabled</key>
19<false/>

Initialize before registering Flutter plugins. This complete ios/Runner/AppDelegate.swift deliberately leaves consent unset:

1import Flutter
2import UIKit
3import SFMCSDK
4import Cdp
5import Personalization
6
7@main
8@objc class AppDelegate: FlutterAppDelegate {
9  override func application(
10    _ application: UIApplication,
11    didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]?
12  ) -> Bool {
13    initializePersonalizationSdk()
14    GeneratedPluginRegistrant.register(with: self)
15    return super.application(application, didFinishLaunchingWithOptions: options)
16  }
17
18  override func application(
19    _ app: UIApplication,
20    open url: URL,
21    options: [UIApplication.OpenURLOptionsKey: Any] = [:]
22  ) -> Bool { super.application(app, open: url, options: options) }
23
24  override func application(
25    _ application: UIApplication,
26    continue activity: NSUserActivity,
27    restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
28  ) -> Bool {
29    super.application(
30      application,
31      continue: activity,
32      restorationHandler: restorationHandler
33    )
34  }
35
36  private func initializePersonalizationSdk() {
37    let info = Bundle.main.infoDictionary ?? [:]
38    let appId = info["salesforce.cdp.appId"] as? String ?? ""
39    let endpoint = info["salesforce.cdp.endpoint"] as? String ?? ""
40    let dataspace = info["salesforce.cdp.dataspace"] as? String ?? "default"
41    let cdnUrl = info["salesforce.cdp.cdnUrl"] as? String
42    if appId.isEmpty || appId.hasPrefix("YOUR_") ||
43       endpoint.isEmpty || endpoint.hasPrefix("YOUR_") {
44      NSLog("Personalization is not configured; SDK init skipped")
45      return
46    }
47
48    let cdp = CdpConfigBuilder(appId: appId, endpoint: endpoint)
49    _ = cdp.trackScreens(false)
50    _ = cdp.trackLifecycle(false)
51    _ = cdp.sessionTimeout(1800)
52    _ = cdp.eventFlushRate(
53      EventFlushRateQuantityAndInterval(quantity: 10, interval: 5.0)
54    )
55    let personalization = PersonalizationConfigBuilder().dataspace(dataspace)
56    if let cdnUrl, !cdnUrl.isEmpty, !cdnUrl.hasPrefix("YOUR_") {
57      _ = personalization.cdnUrl(cdnUrl)
58    }
59    let config = ConfigBuilder()
60      .setCdp(config: cdp.build())
61      .setPersonalization(config: personalization.build())
62      .build()
63    SFMCSdk.initializeSdk(config) { statuses in
64      let ready = !statuses.isEmpty && statuses.allSatisfy {
65        $0.initStatus == .success
66      }
67      NSLog(ready ? "SDK ready; waiting for user consent" : "SDK initialization failed")
68    }
69  }
70}

After the compatible Flutter package is published and added, confirm the Podfile target uses platform :ios, '15.0' and use_frameworks!, run CocoaPods, and open Runner.xcworkspace.

Tips 

  • If the native SDK ends up off the classpath (Android) or the pods aren’t linked (iOS), the plugin degrades gracefully - content zones render their fallback and status calls report “not ready” - instead of crashing.
  • To enable debug logging during development: await PersonalizationModule.setLogging(LogLevel.debug);. Call this before the native SFMCSdk.configure(...) / SFMCSdk.initializeSdk(...) call in your platform init code, so logging is active for the whole init sequence. Disable before a production build.

Display a Content Zone 

With the SDK initialized and consent granted, place a ContentZone widget in your UI. Pass the personalization point name and a list of components the zone is allowed to render.

After adding a ContentZone to your app code, create the matching content zone record in Salesforce Personalization. See Set Up Mobile Content Zones for instructions on defining personalization points, assigning components, and configuring engagement definitions.

Personalization must remain disabled until the user explicitly opts in. Await the Dart consent call and persist opt-in only after it succeeds. On opt-out, unmount live zones immediately, call the SDK, then persist the result. Changing consent does not make a mounted zone retry automatically; prefer mounting only while opted in, or refresh an attached controller after a successful change.

Recommendations are eager and not internally scrollable. Put a zone that can render recommendations in a host scrollable. All cards build at once, and built-in View is a mount/render event, not verified on-screen visibility.

The following illustrative widget focuses on update ordering and the required scrollable host. Its loadChoice and saveChoice callbacks are the app’s awaited consent store; null means the user has not made a choice.

1import 'dart:async';
2import 'package:flutter/material.dart';
3import 'package:flutter_salesforce_personalization/flutter_salesforce_personalization.dart';
4
5Future<bool?> loadConsentChoice() async => null; // Replace with app storage.
6Future<void> saveConsentChoice(bool value) async {} // Replace with app storage.
7
8void main() => runApp(MaterialApp(home: HomeScreen(
9  loadChoice: loadConsentChoice,
10  saveChoice: saveConsentChoice,
11)));
12
13class HomeScreen extends StatefulWidget {
14  const HomeScreen({
15    super.key,
16    required this.loadChoice,
17    required this.saveChoice,
18  });
19  final Future<bool?> Function() loadChoice;
20  final Future<void> Function(bool value) saveChoice;
21  @override
22  State<HomeScreen> createState() => _HomeScreenState();
23}
24
25class _HomeScreenState extends State<HomeScreen> {
26  final _controller = ContentZoneController();
27  late final List<Component> _components = <Component>[
28    SalesforceBanner(onTap: _selectBanner),
29    SalesforceRecommendations(onTap: _selectRecommendation),
30  ];
31  bool? _optedIn;
32  bool _updating = false;
33
34  @override
35  void initState() { super.initState(); unawaited(_restoreConsent()); }
36
37  Future<void> _restoreConsent() async {
38    try {
39      final saved = await widget.loadChoice();
40      var nativeOptIn = await PersonalizationModule.isConsentOptIn();
41      if (saved != true && nativeOptIn) {
42        await PersonalizationModule.setConsent(optIn: false);
43        nativeOptIn = false;
44      }
45      if (!mounted) return;
46      setState(() => _optedIn = saved == true && nativeOptIn);
47    } catch (_) {
48      try { await PersonalizationModule.setConsent(optIn: false); } catch (_) {}
49      if (mounted) {
50        setState(() => _optedIn = false);
51        _showSelection('Consent restoration failed');
52      }
53    }
54  }
55
56  void _selectBanner(SalesforceBannerModel model) =>
57      _showSelection(model.header);
58  void _selectRecommendation(SalesforceRecommendationTapEvent event) =>
59      _showSelection(event.item.name);
60  void _showSelection(String name) => ScaffoldMessenger.of(context)
61      .showSnackBar(SnackBar(content: Text('Selected: $name')));
62
63  Future<void> _setConsent(bool optIn) async {
64    if (_updating) return;
65    final previous = _optedIn;
66    setState(() { _updating = true; if (!optIn) _optedIn = false; });
67    try {
68      await PersonalizationModule.setConsent(optIn: optIn);
69      if (!mounted) return;
70      setState(() => _optedIn = optIn);
71      try {
72        await widget.saveChoice(optIn);
73      } catch (_) {
74        if (optIn) {
75          await PersonalizationModule.setConsent(optIn: false);
76          if (mounted) setState(() => _optedIn = false);
77        }
78        if (mounted) _showSelection('Consent was applied but could not be saved');
79      }
80    } catch (_) {
81      if (mounted) setState(() => _optedIn = previous);
82      if (mounted) _showSelection('Consent update failed');
83    } finally {
84      if (mounted) setState(() => _updating = false);
85    }
86  }
87
88  @override
89  void dispose() { _controller.dispose(); super.dispose(); }
90
91  @override
92  Widget build(BuildContext context) {
93    return Scaffold(
94      appBar: AppBar(title: const Text('Personalized content')),
95      body: RefreshIndicator(
96        onRefresh: _optedIn == true ? _controller.refresh : () async {},
97        child: ListView(
98          physics: const AlwaysScrollableScrollPhysics(),
99          padding: const EdgeInsets.all(16),
100          children: <Widget>[
101            Text(_optedIn == null
102                ? 'Loading personalization choice'
103                : _optedIn == true
104                    ? 'Personalization is on'
105                    : 'Personalization is off'),
106            Wrap(spacing: 12, children: <Widget>[
107              FilledButton(
108                onPressed: _updating || _optedIn == null
109                    ? null : () => unawaited(_setConsent(true)),
110                child: const Text('Opt in'),
111              ),
112              OutlinedButton(
113                onPressed: _updating || _optedIn == null
114                    ? null : () => unawaited(_setConsent(false)),
115                child: const Text('Opt out'),
116              ),
117            ]),
118            if (_optedIn == true) ContentZone(
119              name: 'HomeRecommendations',
120              controller: _controller,
121              allowedComponents: _components,
122              loading: const CircularProgressIndicator(
123                semanticsLabel: 'Loading personalized content',
124              ),
125              fallback: (_) => Semantics(
126                liveRegion: true,
127                child: const Text('Personalized content is unavailable.'),
128              ),
129            ),
130          ],
131        ),
132      ),
133    );
134  }
135}

ContentZone Parameters 

ParameterContract
nameRequired backend point name. Use a unique name per simultaneously mounted zone.
allowedComponentsRequired lookup and security allowlist. Matching is case-insensitive; the first duplicate wins.
loadingOptional initial or explicit loading-state widget.
fallbackOptional bridge, timeout, validation, blocked component, consent, or empty-content fallback. Do not expose raw errors.
timeoutMsNative fetch timeout; default 10000 milliseconds.
controllerOptional programmatic refresh controller.
decisionsRequestContextOptional anchor and attributes merged with automatic context.

The zone rebuilds its component allowlist whenever allowedComponents changes, comparing by value. Changing the allowed component names triggers a refetch; swapping only a component instance for an already-allowed name (for example a new style or onTap) re-renders in place from the retained serving with no refetch, reusing the same ComponentContext so a View is not re-fired. You no longer need to re-key or remount the zone to change the list, though keeping the set of names stable across rebuilds is still good practice.

Use anchorType in DecisionsRequestContext. anchorDmoName is deprecated and retained only as an alias. Attribute values must be String, int, double, or bool; convert dates to strings first.

Refresh with a controller 

refresh() keeps old content visible only while the request is pending. A failure replaces it with fallback; refresh(withLoadingState: true) shows loading while pending. Failures do not rethrow; inspect lastRefreshError. dispose() is currently a no-op but should still be called.

Create one ContentZoneController per simultaneously mounted zone. Binding is first-bind-wins and unbinding is ownership-aware, so disposing a second holder no longer unbinds the active zone - but a shared controller still drives only the zone that bound it first, so give each zone its own. Use separate controllers and Future.wait to refresh multiple zones.

App resume, connectivity recovery, identity changes, and decisionsRequestContext changes do not automatically fetch new content. After consent and identity are settled, refresh the dedicated zone controller once when the app’s lifecycle policy requires a new decision.

Out-of-the-Box Components 

The plugin ships two ready-to-use components - SalesforceBanner and SalesforceRecommendations - that handle rendering and engagement tracking (View and Click events) automatically.

These components are available out-of-the-box in the UI and are registered under the names "Salesforce_Banner" and "Salesforce_Recommendations".

Banner contract 

SalesforceBanner handles Salesforce_Banner. header is required and nonblank. imageUrl is supplied as a string; an invalid or blank value warns and renders an image fallback rather than rejecting an otherwise valid banner. subheader, ctaText, and ctaUrl are optional.

ctaText and ctaUrl are independent. A banner is actionable when an onTap callback exists or ctaUrl is nonblank. onTap wins. Label-only content can render without an action; URL-only content can be actionable without a visible CTA label. Prefer app-owned callbacks to enforce trusted destinations and accessible labels.

The reviewed built-in card uses a bare GestureDetector, provides no explicit button/link semantics or keyboard activation, and truncates text. Use a custom accessible component when those behaviors do not satisfy the app’s accessibility requirements.

Recommendations contract 

SalesforceRecommendations handles Salesforce_Recommendations. Portrait uses one column and landscape uses two. It eagerly builds Column and Row trees; it does not provide a scroll view.

The model has optional sectionHeader, optional shared ctaText, and a nonempty list of valid items. Each item has nonblank id and name, an imageUrl, optional description, and optional item-level url. The plugin does not enforce unique IDs; production payloads must use unique IDs.

Invalid items are skipped, but engagement indices are not remapped. A skipped item can misattribute later View and Click events. Production responses must contain only valid items in unchanged flat serving order. Do not sort, filter, insert, or section before per-item tracking unless each original zero-based index is preserved.

Styling contract 

The reviewed renderer defaults are white backgrounds; 20sp semibold #393939 headers; 16sp #6B6B6B subheaders; 15sp underlined #0B5CAB CTAs; 28sp section headers; banner padding 12; recommendation card padding 20; spacing 12; image width fraction 0.3; maximum image size 150; and banner radius 8.

SalesforceBannerStyle.ctaBackgroundColor and imageHeight exist but are not consumed by the reviewed renderer.

Custom Components 

Implement a custom component to render a content zone with your own UI. A custom component can reuse an existing backend experience template (for example, clone Salesforce_Banner) or use a fully custom experience template name with its own fields.

Match the component’s name to the backend experience template’s component name. Validate the JSON payload in validateAndCreateComponentModel, and return the widget from compose. See Engagement Tracking for reporting View and Click from a custom component.

Engagement Tracking 

Before engagement events are recorded, ensure your engagement definitions (View, Click) are configured in the Data 360 mobile connector. See Set Up Mobile Engagement Tracking for instructions on defining engagement actions alongside your components and content zones.

Out-of-the-box components track View and Click automatically. These are the only two actions currently supported end-to-end. The engagement lifecycle for both built-in and custom components:

  • Built-in Banner invokes its view-once tracker on mount and when personalizationId changes while its widget state remains alive.
  • Built-in Recommendations invokes the per-item view-once tracker for every eagerly rendered index. Item id is not part of the dedup key, and this is not on-screen visibility.
  • A tracker invocation emits only when the expected engagement payload shape contains a matching View payload. A no-op still consumes its once/PID gate.
  • A silent same-ID refresh does not refire while the child state survives. refresh(withLoadingState: true) can refire at the same ID after a loading frame disposes and remounts the child state.
  • A fully disposed and remounted zone can attempt View again at the same ID.
  • Every successful fetch creates a new ComponentContext, but its ID can remain unchanged. Context identity and personalizationId are different signals.
  • On an actionable tap, built-in components invoke Click tracking before the callback or URL action. The action proceeds regardless of payload availability; analytics emit only when a matching payload exists. URL-free cards in the reviewed renderer still install a no-op gesture recognizer.
  • Shape and count mismatches are logged but do not block rendering.
  • Preview commonly has no engagement payloads and is not evidence that production analytics will be emitted.

For custom components:

  • For custom single elements, use trackEngagementViewOnce() for build/mount View and trackEngagement() for repeatable actions.
  • For custom lists, use the per-item variants with original serving indices.
  • View-once methods deduplicate by the exact ComponentContext object. Pass the context supplied by the zone; do not construct or copy one.
  • For true impression measurement, use a custom lazy or paged component and report from an actual visibility signal.

Preview 

Preview is per zone. A valid receipt causes only its matching zone to refetch; other active zones are unaffected. Query with PersonalizationModule.isPreview(exactZoneName). The plugin’s border is not an accessible status indicator by itself; also show persistent Preview text and Flutter semantics.

The reviewed API exposes only a one-shot preview query, not an observable per-zone preview state for host UI. A persistent synchronized accessible label therefore requires a plugin API or built-in renderer change and remains a publication gate.

Add the separately versioned deep-link dependency validated for the release:

1flutter pub add app_links:^6.4.0

Use app_links for cold and warm links. Register only endpoints your app validates. For HTTPS, configure verified Android App Links and iOS Universal Links for a domain you control. This complete handler awaits the cold link and subscribes for warm links:

1import 'dart:async';
2import 'package:app_links/app_links.dart';
3import 'package:flutter/foundation.dart';
4import 'package:flutter_salesforce_personalization/flutter_salesforce_personalization.dart';
5
6class PreviewLinks {
7  PreviewLinks._(this._subscription);
8  final StreamSubscription<Uri> _subscription;
9
10  static Future<PreviewLinks> start() async {
11    final links = AppLinks();
12    final seen = <String>{};
13    Future<void> forwardOnce(Uri uri) async {
14      if (seen.add(uri.toString())) await _forward(uri);
15    }
16    final subscription = links.uriLinkStream.listen(
17      (uri) => unawaited(forwardOnce(uri)),
18      onError: (_) => debugPrint('A preview link could not be read.'),
19    );
20    final initial = await links.getInitialLink();
21    if (initial != null) await forwardOnce(initial);
22    return PreviewLinks._(subscription);
23  }
24
25  static Future<void> _forward(Uri uri) async {
26    if (!_trusted(uri)) return;
27    try {
28      await PersonalizationModule.handlePreviewUrl(uri.toString());
29    } catch (_) {
30      debugPrint('A trusted preview link could not be applied.');
31    }
32  }
33
34  static bool _trusted(Uri uri) {
35    final custom = uri.scheme == 'personalizationdemo' &&
36        uri.host == 'preview' && (uri.path.isEmpty || uri.path == '/') &&
37        !uri.hasPort;
38    final https = uri.scheme == 'https' &&
39        uri.host == 'preview.example.com' &&
40        uri.path == '/mobile-personalization' &&
41        (!uri.hasPort || uri.port == 443);
42    final token = uri.queryParametersAll['sfp-preview'];
43    final exactQuery = uri.queryParametersAll.length == 1 &&
44        token != null && token.length == 1 && token.single.trim().isNotEmpty;
45    return (custom || https) && uri.userInfo.isEmpty &&
46        uri.fragment.isEmpty && exactQuery;
47  }
48
49  Future<void> dispose() => _subscription.cancel();
50}

Await PreviewLinks.start() before mounting zones, retain the returned owner, and dispose it at app teardown. Treat the full URL and token as sensitive bearer-like material. Never log, persist, analyze, or include them in crashes or support artifacts. Reject untrusted schemes, hosts, paths, ports, user info, fragments, extra query keys, duplicate tokens, and empty tokens.

Design-Time Rendering 

MockDataContentZone<T> renders components with mock data for styling and layout without any backend setup or networking. It is development/test-only. It accepts one matching Component<T>, MockContent.success(model) or MockContent.failure(error), loading, fallback, mockLoadingMs, and an optional controller. Mock refresh always reruns loading then resolution. Keep component and mock objects stable rather than recreating them on every parent rebuild.

Use a mock controller to exercise refresh and MockContent.failure to verify a customer-safe fallback. Never ship a mock zone in production.

1import 'package:flutter/material.dart';
2import 'package:flutter_salesforce_personalization/flutter_salesforce_personalization.dart';
3
4class BannerMockPreview extends StatefulWidget {
5  const BannerMockPreview({super.key});
6  @override
7  State<BannerMockPreview> createState() => _BannerMockPreviewState();
8}
9
10class _BannerMockPreviewState extends State<BannerMockPreview> {
11  final _controller = ContentZoneController();
12  final _component = SalesforceBanner();
13  late final _success = MockContent<SalesforceBannerModel>.success(
14    SalesforceBannerModel(
15      header: 'Sample offer',
16      imageUrl: 'https://images.example.com/sample-offer.png',
17    ),
18  );
19  late final _failure =
20      const MockContent<SalesforceBannerModel>.failure('Design-time failure');
21  bool _fail = false;
22  @override
23  void dispose() { _controller.dispose(); super.dispose(); }
24  @override
25  Widget build(BuildContext context) {
26    return Column(children: <Widget>[
27      MockDataContentZone<SalesforceBannerModel>(
28        name: 'banner-preview',
29        component: _component,
30        mockContent: _fail ? _failure : _success,
31        controller: _controller,
32        mockLoadingMs: 200,
33        loading: const CircularProgressIndicator(),
34        fallback: (_) => const Text('Preview content is unavailable.'),
35      ),
36      Wrap(spacing: 8, children: [
37        FilledButton(
38          onPressed: () => setState(() => _fail = false),
39          child: const Text('Show content'),
40        ),
41        OutlinedButton(
42          onPressed: () => setState(() => _fail = true),
43          child: const Text('Show fallback'),
44        ),
45        OutlinedButton(
46          onPressed: _controller.refresh,
47          child: const Text('Refresh mock'),
48        ),
49      ]),
50    ]);
51  }
52}

Identity and Events 

Identity setters and getters are available after native initialization, explicit opt-in, and app authentication:

1import 'package:flutter_salesforce_personalization/flutter_salesforce_personalization.dart';
2
3Future<void> updateAndReadIdentity() async {
4  await PersonalizationModule.setProfileId('customer-123');
5  await PersonalizationModule.setAttributes(<String, String>{'tier': 'gold'});
6  await PersonalizationModule.setPartyIdentificationName('Customer ID');
7  await PersonalizationModule.setPartyIdentificationNumber('customer-123');
8  await PersonalizationModule.setPartyIdentificationType('CRM');
9  final String? profileId = await PersonalizationModule.getProfileId();
10  final Map<String, String>? attributes = await PersonalizationModule.getAttributes();
11  final String? name = await PersonalizationModule.getPartyIdentificationName();
12  final String? number = await PersonalizationModule.getPartyIdentificationNumber();
13  final String? type = await PersonalizationModule.getPartyIdentificationType();
14  if ([profileId, attributes, name, number, type].contains(null)) {
15    throw StateError('Identity was not fully available');
16  }
17}

setAttributes upserts supplied keys and leaves other keys unchanged. Use clearAttribute or clearAllAttributes for attributes. Those methods do not clear profileId or party-identification fields, and this plugin version exposes no public API that clears them. Do not set those identifiers in a multi-user app until the compatible release documents a supported native identity/session reset. Track behavioral events only after the same consent and governance checks.

Mobile backend prerequisite: A mobile personalization point matching ContentZone.name, an assigned component or experience template (built-in names are Salesforce_Banner and Salesforce_Recommendations), payloads that satisfy the contracts above, and View/Click engagement definitions must all be configured and activated in the backend before a zone can return content. See Set Up Mobile Content Zones.

Note