Salesforce Personalization Flutter Plugin
A Flutter plugin that wraps the native iOS / Android Salesforce Personalization SDKs. Renders personalized UI (Banner, Recommendations) in Flutter apps and lets you ship your own custom components against the same backend.
Looking for a runnable demo? See
example/.
Table of Contents
- Requirements
- Install
- Platform Setup
- Quick Start
- Core APIs
- Custom Components
- Offline / Design-time
- More
Requirements
| Requirement | Version |
|---|---|
| Flutter | 3.24+ |
| Dart | 3.5+ |
| iOS | 15.0+ |
| Android | API 26+ |
Install
Add the package to your app:
flutter pub add flutter_salesforce_personalization
This adds it to the dependencies: block of your pubspec.yaml and runs flutter pub get. On iOS the native pods are pulled in automatically the next time you build (or run pod install); on Android you also declare the Salesforce Maven repositories — see the platform guides below.
Platform Setup
The host app owns SDK initialization — there is no Dart-side configure(...). The SDK needs four config values supplied by your Salesforce Marketing Cloud administrator:
| Key | Description |
|---|---|
salesforce.cdp.appId |
CDP application ID |
salesforce.cdp.endpoint |
Bare host with no scheme (e.g. <tenant>.<region>.c360a.salesforce.com) |
salesforce.cdp.cdnUrl |
CDN base URL |
salesforce.cdp.dataspace |
Dataspace name (usually default) |
Follow the platform-specific guides to configure Maven repositories, native SDK dependencies, and host-app initialization code:
- Android — see Android.md for required Maven repos, SDK levels,
MainApplication.ktsetup, andAndroidManifest.xmlconfig keys. - iOS — see iOS.md for CocoaPods setup,
AppDelegate.swiftinitialization, andInfo.plistconfig keys.
Quick Start
Once platform setup is complete, drop a ContentZone into your widget tree. Give it the backend zone name and the list of components it's allowed to render:
import 'package:flutter_salesforce_personalization/flutter_salesforce_personalization.dart';
ContentZone(
name: 'HomeScreen',
allowedComponents: [
BannerComponent(),
Recommendations(),
],
loading: const Center(child: CircularProgressIndicator()),
fallback: (error) => const SizedBox.shrink(),
)
Personalization requires consent — until the user is opted in, the SDK fetches no personalized content. The initial opt-in is set natively during SDK initialization (see Android.md / iOS.md). The Consent section below covers changing consent at runtime from Dart.
ContentZone fetches content from the platform SDK, finds the component whose name matches the backend's componentName, parses the JSON into a typed model, and renders it. While the fetch is in flight it shows loading. If the backend returns a name not in allowedComponents — or anything else goes wrong — the fallback builder is invoked. Both are optional: with no loading the zone renders nothing until content arrives, and with no fallback it renders nothing on error.
Core APIs
ContentZone
ContentZone is the main widget. All props except name and allowedComponents are optional.
| Prop | Type | Required | Description |
|---|---|---|---|
name |
String |
Yes | Backend zone identifier. Matched case- and whitespace-insensitively against the payload's componentName. |
allowedComponents |
List<Component> |
Yes | Components this zone can render. Acts as both the lookup and the security allowlist. |
loading |
Widget? |
No | Shown while the fetch is in flight. Defaults to nothing. |
fallback |
Widget Function(Object error)? |
No | Builder invoked on error (timeout, unknown component, validation failure). Defaults to nothing. |
timeoutMs |
int |
No | Fetch timeout in milliseconds. Default 10000. |
controller |
ContentZoneController? |
No | Attach for programmatic refresh (pull-to-refresh). |
decisionsRequestContext |
DecisionsRequestContext? |
No | Bias decisions with anchor metadata or arbitrary attributes. |
Pull-to-refresh
Wrap your scrollable in RefreshIndicator and route its onRefresh to a ContentZoneController.refresh:
class _HomeScreenState extends State<HomeScreen> {
final _zoneController = ContentZoneController();
@override
void dispose() {
_zoneController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: _zoneController.refresh,
child: ListView(
children: [
ContentZone(
name: 'HomeScreen',
controller: _zoneController,
allowedComponents: [...],
),
],
),
);
}
}
controller.refresh() defaults to a silent refresh — the previous content stays visible while the new fetch is in flight. Pass controller.refresh(withLoadingState: true) to flip the zone to its loading widget for the duration of the fetch instead.
Preview Mode
Preview lets you QR-scan or deep-link a preview URL to see unpublished content in a live app, without affecting other users. When a preview URL is received, the SDK updates its preview state and all active ContentZone widgets for that zone re-fetch automatically in preview mode.
Forwarding a preview deep link:
// From your deep-link or app_links handler:
PersonalizationModule.handlePreviewUrl(incomingUrl);
Supports any URL scheme (custom or HTTPS), as long as the URL contains the sfp-preview query parameter:
// Custom scheme
PersonalizationModule.handlePreviewUrl('myapp://preview?sfp-preview=H4sI...');
// HTTPS
PersonalizationModule.handlePreviewUrl('https://myapp.com/preview?sfp-preview=H4sI...');
Checking preview state in the UI (e.g. to show a "Preview" badge):
final inPreview = await PersonalizationModule.isPreview('HomeScreen');
See Android.md and iOS.md for deep-link intent-filter / URL-scheme registration in the host app.
Out-of-the-Box Components
Banner (BannerComponent)
Renders a full-width hero card with an image, header, optional subheader, and an optional CTA button. Maps to the Salesforce_Banner transformer.
BannerModel fields:
| Field | Type | Required | Description |
|---|---|---|---|
header |
String |
Yes | Primary headline text. |
imageUrl |
String |
Yes | Banner image URL. |
subheader |
String? |
No | Secondary text below the header. |
ctaText |
String? |
No | CTA button label. |
ctaUrl |
String? |
No | URL opened when the CTA is tapped (or when onTap is null). |
Tapping the banner opens ctaUrl by default. Pass onTap to run your own logic instead:
BannerComponent(
onTap: (BannerModel model) {
Navigator.pushNamed(context, '/promo', arguments: model.ctaUrl);
},
style: BannerStyle(backgroundColor: Colors.white),
)
Recommendations (Recommendations)
Renders a scrollable list of product or content cards. Adapts to orientation: portrait shows a single-column list, landscape shows a two-column grid. Maps to the Salesforce_Recommendations transformer.
RecommendationsModel fields:
| Field | Type | Description |
|---|---|---|
items |
List<RecommendationItem> |
The recommendation cards (required, non-empty). |
sectionHeader |
String? |
Optional heading above the list. |
ctaText |
String? |
Shared CTA label shown on each card. |
RecommendationItem fields:
| Field | Type | Required | Description |
|---|---|---|---|
id |
String |
Yes | Unique identifier. |
name |
String |
Yes | Item title. |
imageUrl |
String |
Yes | Card image URL. |
description |
String? |
No | Supporting text. |
url |
String? |
No | URL opened on tap (or when onTap is null). |
The tap callback receives a RecommendationTapEvent { item: RecommendationItem, index: int }:
Recommendations(
onTap: (RecommendationTapEvent event) {
Navigator.pushNamed(
context,
'/product',
arguments: event.item.id,
);
},
style: RecommendationsStyle(
card: RecsCardStyle(backgroundColor: Colors.white),
),
)
Engagement Tracking
OOTB widgets auto-track View and Click events; custom components can opt in via EngagementPayloads.trackEngagement(payload).
OOTB behaviour (Banner, Recommendations):
- Recommendations:
Viewfires once perpersonalizationIdper card. The dedup is owned by the carousel's state, so it survives orientation changes and other layout-driven re-mounts; a newpersonalizationId(e.g. fromcontroller.refresh()) clears the dedup so fresh content fires freshViewevents. - Banner:
Viewfires on first mount and again on everypersonalizationIdchange. The banner does not hold a dedup set, so an orientation change (or other re-mount) that recreates its state will refireViewfor the samepersonalizationId. Clickfires every time the user taps a card. IfonTapisnullAND the model has noctaUrl, the card has no tap surface and noClickevent fires.
Custom components:
The ComponentContext passed to validateAndCreateComponentModel and build exposes the engagement payload bag the SDK delivered for this fetch. Action names ('View', 'Click', 'Dismiss', …) are server-defined strings; the SDK does not gate on a fixed list.
class _MyBannerState extends State<_MyBanner> {
@override
void initState() {
super.initState();
_track('View');
}
void _track(String action) {
final payloads = widget.context.engagementPayloads;
if (payloads is! PerActionEngagementPayloads) return;
final payload = payloads.getPayloadForAction(action);
if (payload != null) EngagementPayloads.trackEngagement(payload);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => _track('Click'),
child: Text(widget.model.header),
);
}
}
For Recommendations-shaped payloads, use PerItemAndActionEngagementPayloads.getPayloadForItemAndAction(index, action) instead.
Identity
// Profile ID (contact key)
await PersonalizationModule.setProfileId('user-123');
final String? id = await PersonalizationModule.getProfileId();
// Attributes — upsert semantics
await PersonalizationModule.setAttribute('email', 'user@example.com');
await PersonalizationModule.setAttributes({'firstName': 'John', 'lastName': 'Doe'});
// Clear
await PersonalizationModule.clearAttribute('email');
await PersonalizationModule.clearAllAttributes(); // typical logout path
// Read
final Map<String, String>? attrs = await PersonalizationModule.getAttributes();
// Party identification — per-field setters and getters
await PersonalizationModule.setPartyIdentificationName('MC Subscriber Key');
await PersonalizationModule.setPartyIdentificationNumber('user-123');
await PersonalizationModule.setPartyIdentificationType('Email');
Events
All tracking flows through a single PersonalizationModule.track(SfpEvent) entry point. SfpEvent is sealed; pick the variant that matches the customer journey:
// Custom — free-form name + attributes
await PersonalizationModule.track(
CustomEvent(name: 'banner_tap', attributes: {'page': 'home'}),
);
// Cart — add / remove / replace
await PersonalizationModule.track(
CartEvent.add(LineItem(
catalogObjectType: 'product',
catalogObjectId: 'SKU-001',
quantity: 1,
price: 29.99,
currency: 'USD',
)),
);
// Order — purchase / preorder / cancel / ship / deliver / returnOrder / exchange
await PersonalizationModule.track(
OrderEvent.purchase(Order(
id: 'ORDER-1',
totalValue: 99.99,
currency: 'USD',
lineItems: [
LineItem(catalogObjectType: 'product', catalogObjectId: 'SKU-001', quantity: 1),
],
)),
);
// Catalog — view / viewDetail / quickView / share / review / comment / favorite
await PersonalizationModule.track(
CatalogObjectEvent.view(CatalogObject(type: 'product', id: 'PROD-001')),
);
// Engagement — engagement-category event with a free-form name
await PersonalizationModule.track(
EngagementEvent(name: 'banner_visible', attributes: {'zone': 'home'}),
);
// System — system-category event with a free-form name
await PersonalizationModule.track(
SystemEvent(name: 'app_launched'),
);
EngagementEvent and SystemEvent take a required name and optional attributes, like CustomEvent. On iOS the native SDK has no dedicated engagement event, so EngagementEvent is dispatched as a custom event — the public Dart API is identical on both platforms.
Flush cadence is configured natively in MainApplication.kt / AppDelegate.swift via CdpConfig.Builder.eventFlushRate(...). See doc/GETTING_STARTED.md for the full event reference.
Consent
Consent gates personalization — the SDK does not fetch content until the user is opted in. The initial opt-in is set natively during SDK initialization; see Android.md and iOS.md. To change consent at runtime from Dart:
// Opt the user in
await PersonalizationModule.setConsent(optIn: true);
// Opt the user out
await PersonalizationModule.setConsent(optIn: false);
// Check current state
final bool isOptedIn = await PersonalizationModule.isConsentOptIn();
Custom Components
You can render personalized content however you want by implementing Component<T>. Two patterns:
1. New look for an existing transformer
Subclass Component<RecommendationsModel> and set name to the same transformer name ('Salesforce_Recommendations'). Place it ahead of the OOTB component in allowedComponents — the first-occurrence-per-name wins, so your renderer replaces the OOTB one.
class CoverflowRecommendations extends Component<RecommendationsModel> {
@override
String get name => 'Salesforce_Recommendations';
@override
ValidationResult<RecommendationsModel> validateAndCreateComponentModel(
String json,
ComponentContext context,
) {
final model = RecommendationsModel.fromJson(json);
return model == null
? const ValidationFailure('Invalid JSON')
: ValidationSuccess(model);
}
@override
Widget build(RecommendationsModel model, ComponentContext context) =>
MyCoverflowCarousel(items: model.items);
}
// Usage
ContentZone(
name: 'HomeScreen',
allowedComponents: [
CoverflowRecommendations(), // wins for 'Salesforce_Recommendations'
Recommendations(), // unreachable while the above is present
],
)
2. New component with its own model
Define a ComponentModel subclass and pair it with a Component<YourModel>. Register it under a transformer name your CDP backend emits.
class MyCardModel implements ComponentModel {
final String title;
final String imageUrl;
const MyCardModel({required this.title, required this.imageUrl});
static MyCardModel? fromJson(String json) {
try {
final m = jsonDecode(json) as Map<String, dynamic>;
return MyCardModel(title: m['title'], imageUrl: m['imageUrl']);
} catch (_) {
return null;
}
}
}
class MyCardComponent extends Component<MyCardModel> {
@override
String get name => 'MyCard';
@override
ValidationResult<MyCardModel> validateAndCreateComponentModel(
String json,
ComponentContext context,
) {
final model = MyCardModel.fromJson(json);
return model == null
? const ValidationFailure('Invalid MyCard JSON')
: ValidationSuccess(model);
}
@override
Widget build(MyCardModel model, ComponentContext context) =>
Card(child: Text(model.title));
}
The generic T in Component<T> means build(model, context) is strongly typed — no runtime casts. The context parameter (ComponentContext) carries contentSource, personalizationId, and engagementPayloads (the EngagementPayloads bag the SDK delivered with this fetch) — ignore it unless you want to opt into engagement tracking. See Engagement Tracking.
Offline / Design-time
Render a caller-supplied model without a backend using MockDataContentZone. Useful for storybooks, tests, and developing against unavailable transformers.
MockDataContentZone<BannerModel>(
name: 'previewBanner',
component: BannerComponent(),
mockContent: MockContent.success(BannerModel(
header: 'Sample banner',
imageUrl: 'https://picsum.photos/800/300',
)),
)
The generic T enforces component/model pairing at compile time. Pass MockContent.failure(error) to exercise your fallback builder.
More
- doc/GETTING_STARTED.md — full API reference.
- doc/TROUBLESHOOTING.md — common issues and fixes.
- doc/RUNNING_THE_EXAMPLE.md — build and run the bundled example app.
- Android.md / iOS.md — host-app setup guides.
- example/ — runnable demo app with OOTB and custom components side by side.
- CHANGELOG.md — release notes.
- SECURITY.md — security and vulnerability reporting.
- CONTRIBUTING.md — how to contribute.
Libraries
- flutter_salesforce_personalization
- Salesforce Personalization Flutter Plugin.