Overview
iOS Integration
Android Integration
React Native Integration
Flutter 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.
1flutter pub add flutter_salesforce_personalizationAdd 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).
| Requirement | Reviewed baseline |
|---|---|
| Flutter | 3.19 or later |
| Dart | 3.3 or later |
| Android | API 26 minimum; compile SDK 37 |
| Java | 17 |
| Kotlin Gradle plugin | 2.3.0 |
| Android Gradle Plugin | 8.13.2 |
| Gradle wrapper | 8.13 |
| iOS deployment target | 15.0 or later |
| Swift | 5.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.
| Key | Value |
|---|---|
salesforce.cdp.appId | CDP application ID |
salesforce.cdp.endpoint | Bare endpoint host with no URL scheme |
salesforce.cdp.cdnUrl | CDN configuration URL |
salesforce.cdp.dataspace | Dataspace, commonly default |
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.
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.zipConfigure 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}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.
fallback and status calls report “not ready” - instead of crashing.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.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}| Parameter | Contract |
|---|---|
name | Required backend point name. Use a unique name per simultaneously mounted zone. |
allowedComponents | Required lookup and security allowlist. Matching is case-insensitive; the first duplicate wins. |
loading | Optional initial or explicit loading-state widget. |
fallback | Optional bridge, timeout, validation, blocked component, consent, or empty-content fallback. Do not expose raw errors. |
timeoutMs | Native fetch timeout; default 10000 milliseconds. |
controller | Optional programmatic refresh controller. |
decisionsRequestContext | Optional 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() 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.
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".
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.
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.
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.
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.
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:
personalizationId changes while its widget state remains alive.id is not part of the dedup key, and this is not on-screen visibility.refresh(withLoadingState: true) can refire at the same ID after a loading frame disposes and remounts the child state.ComponentContext, but its ID can remain unchanged. Context identity and personalizationId are different signals.For custom components:
trackEngagementViewOnce() for build/mount View and trackEngagement() for repeatable actions.ComponentContext object. Pass the context supplied by the zone; do not construct or copy one.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.0Use 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.
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 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