flutter_salesforce_personalization 2.0.0
flutter_salesforce_personalization: ^2.0.0 copied to clipboard
A Flutter plugin that wraps the native iOS/Android Salesforce Personalization SDKs to deliver personalized UI components (Hero Banner, Recommendations) in Flutter apps.
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.19+ |
| Dart | 3.3+ |
| iOS deployment target | 15.0+ |
| Swift | 5.7+ |
Android minSdk |
API 26+ |
Android compileSdk |
37 |
| Kotlin Gradle plugin | 2.3.0+ |
| Java | 17 |
The Android
compileSdk/ Kotlin floors are hard build-time requirements imposed by the native Personalization SDK 3.x AAR, not soft recommendations — a host app below these will fail to build. See Android.md for why each one is required and the exact failure symptoms.
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 config values supplied by your Salesforce Marketing Cloud administrator: two required (appId, endpoint) and two optional (dataspace, cdnUrl).
| Key | Required | Description |
|---|---|---|
salesforce.cdp.appId |
Yes | CDP application ID |
salesforce.cdp.endpoint |
Yes | Bare host with no scheme (e.g. <tenant>.<region>.c360a.salesforce.com) |
salesforce.cdp.dataspace |
No | Dataspace name; defaults to default |
salesforce.cdp.cdnUrl |
No | CDN base URL |
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: [
SalesforceBanner(),
SalesforceRecommendations(),
],
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. If your native initialization already sets consent to
OPT_IN(see the platform setup guides), content is available as soon as aContentZonemounts. If instead you defer the consent choice to Dart, consent starts unset: mount aContentZoneonly after your app has recorded the user's choice, because flipping consent later does not auto-retry a failed or empty fetch — you must callcontroller.refresh()(see Consent below). In that deferred case, gate the zone on your app's persisted consent state rather than mounting it eagerly. The Consent section 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 passed to the platform SDK when fetching content. |
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. |
One configuration per component name per zone.
allowedComponentsis a name-keyed lookup — each entry'snamemust be unique within the zone, matched case-insensitively. Entries after the first with a matching name are dropped and logged as duplicates. Because both OOTB components use a fixed component name, this means a single zone can host only oneSalesforceBannerand oneSalesforceRecommendations— to run a second configuration of the same OOTB type (differentstyleoronTap), put it in a separateContentZone.
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 each ContentZone whose name matches the previewed content re-fetches automatically in preview mode; other zones are unaffected.
Forwarding a preview deep link:
// From your deep-link or app_links handler:
await PersonalizationModule.handlePreviewUrl(incomingUrl);
Supports any URL scheme (custom or HTTPS), as long as the URL contains the sfp-preview query parameter:
// Custom scheme
await PersonalizationModule.handlePreviewUrl('myapp://preview?sfp-preview=H4sI...');
// HTTPS
await 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 (SalesforceBanner)
Renders an image-left, text-right card: a square image, with a header, optional subheader, and an optional CTA button to its right. Maps to the Salesforce_Banner transformer.
SalesforceBannerModel 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:
SalesforceBanner(
onTap: (SalesforceBannerModel model) {
Navigator.pushNamed(context, '/promo', arguments: model.ctaUrl);
},
style: SalesforceBannerStyle(backgroundColor: Colors.white),
)
Recommendations (SalesforceRecommendations)
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.
SalesforceRecommendationsModel fields:
| Field | Type | Description |
|---|---|---|
items |
List<SalesforceRecommendationItem> |
The recommendation cards (required, non-empty). |
sectionHeader |
String? |
Optional heading above the list. |
ctaText |
String? |
Shared CTA label shown on each card. |
SalesforceRecommendationItem 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 SalesforceRecommendationTapEvent { item: SalesforceRecommendationItem, index: int }:
SalesforceRecommendations(
onTap: (SalesforceRecommendationTapEvent event) {
Navigator.pushNamed(
context,
'/product',
arguments: event.item.id,
);
},
style: SalesforceRecommendationsStyle(
card: SalesforceRecommendationCardStyle(backgroundColor: Colors.white),
),
)
Engagement Tracking #
OOTB widgets auto-track View and Click events. There are two axes for custom components to opt into:
-
Every-tap / custom actions (e.g.
Click): call the publiccontext.trackEngagement(action)(single-element components) orcontext.trackEngagementPerItem(index, action)(list components) — they never inspectengagementPayloadsthemselves and fire on every call, with no dedup. -
Once-per-serving (e.g.
View): callcontext.trackEngagementViewOnce([action = 'View'])(single-element) orcontext.trackEngagementViewOncePerItem(index, [action = 'View'])(list) directly. The primitive dedups on theComponentContextinstance's own object identity (notpersonalizationId), so it's safe to call straight frombuild()— including aStatelessWidget's — with no lifecycle wiring; pair it with a per-indexpersonalizationIdmemo in your ownState(as the OOTB widgets do) for full serving-level dedup.View-only. These methods dedup, so use them only for
View(or another action that is genuinely once-per-serving). Don't use them forClickor any repeatable action — every call after the first for a given serving is silently dropped. Repeatable actions always go throughtrackEngagement/trackEngagementPerItem, which fire every time.
OOTB behaviour (Banner, Recommendations):
Within a living widget State, both OOTB widgets additionally hold a
per-index personalizationId "memo": View fires once per
personalizationId, not just once per ComponentContext instance. A
same-PID refetch — a controller.refresh() or a production → preview
transition that happens to return the same id — does not refire as long
as the reporting widget's State stays alive across the refresh; a new PID
does. The one exception is a controller.refresh(withLoadingState: true)
that actually paints a loading frame: ContentZone swaps in the loading
widget while the fetch is in flight, which disposes the child State (and
its PID memo), so when the fetch resolves — even at the same PID — a fresh
State mounts and View refires. Likewise, a fully disposed-and-remounted
ContentZone (e.g. scrolled offscreen and back in an outer
ListView.builder) is a new State with no memo of its own — that's a
genuine new serving, and View correctly refires. See
doc/VIEW_ONCE_TRACKING.md for the full
treatment.
- Recommendations: each
RecsCarouselcard callscontext.trackEngagementViewOncePerItem(index)inbuild(), gated by the carouselState's per-index PID memo.Clickfires every time the user taps a card — it is never deduped. IfonTapisnullAND the model has noctaUrl, the card has no tap surface and noClickevent fires. Eager-mount caveat: cards render eagerly (not lazily), soViewfires on mount for every rendered card regardless of on-screen visibility; true visibility-basedViewtracking is a documented follow-up. - Banner:
HeroBannercallscontext.trackEngagementViewOnce()on mount and again only whenpersonalizationIdchanges (didUpdateWidget), so a same-PID refetch does not refire while itsStatestays alive (see the loading-frame exception above).
Custom components:
The ComponentContext passed to validateAndCreateComponentModel and build carries everything needed to track engagement. Action names ('View', 'Click', 'Dismiss', …) are server-defined strings matched case-insensitively (whitespace trimmed); the SDK does not gate on a fixed list. Every call below is fire-and-forget and never throws — it is a safe no-op when no matching payload exists.
- Click, or any every-tap/custom action: call
context.trackEngagement(action)(single-element) orcontext.trackEngagementPerItem(index, action)(list). - View, or any once-per-serving action: call
context.trackEngagementViewOnce()(single-element) orcontext.trackEngagementViewOncePerItem(index)(list) — both are public and safe to call directly frombuild(); repeated calls on the sameComponentContextinstance are no-ops. These dedup, so use them only forView— never forClickor other repeatable actions.
class _MyBanner extends StatelessWidget {
final SalesforceBannerModel model;
final ComponentContext context;
const _MyBanner({required this.model, required this.context});
@override
Widget build(BuildContext buildContext) {
// View fires once per ComponentContext instance; safe to call every
// build because dedup keys on the context's own object identity.
// Click fires on every tap.
context.trackEngagementViewOnce();
return GestureDetector(
onTap: () => context.trackEngagement(EngagementActions.click),
child: Text(model.header),
);
}
}
trackEngagementViewOnce dedups on the ComponentContext instance's own
object identity — a recycle/rebuild guard, not a personalizationId
guard. Calling it from a plain StatelessWidget's build() is safe against
rebuilds and unmount/remount recycles that reuse the SAME instance, but a
same-PID new instance (e.g. a controller.refresh() that happens to
return the same personalizationId) still refires. For full serving-level
dedup — fires once per personalizationId, not just once per instance —
pair the primitive with a per-index PID memo held in your own State, the
way the OOTB HeroBanner and RecsCarousel do (see
Engagement Tracking above and
doc/VIEW_ONCE_TRACKING.md).
Every successful fetch mints a new ComponentContext instance; its
personalizationId may or may not have changed — the two are not
equivalent, so don't treat "new fetch" and "new personalizationId" as the
same signal.
For list components (Recommendations-shaped payloads), pass the item index
via context.trackEngagementViewOncePerItem(index) in build() — each
index dedups independently — and call context.trackEngagementPerItem(index, 'Click') on tap. See doc/VIEW_ONCE_TRACKING.md
for the full treatment, including the PID-memo pattern.
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();
await PersonalizationModule.setProfileId('anon-<generated-id>'); // full logout: also reset identity
// 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();
If native init defers consent, mount zones after the choice. When native initialization sets
OPT_IN, zones fetch content as soon as they mount. But if native init leaves consent unset, changing it later does not auto-retry a failed or empty content request, so aContentZonemounted before consent was recorded stays empty even after the user opts in. In that deferred case, prefer gating the zone on your app's persisted consent state so it mounts only once a choice exists. Either way, for a zone that was already mounted when consent changes, callcontroller.refresh()to re-fetch (see doc/TROUBLESHOOTING.md).
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<SalesforceRecommendationsModel> and set name to the same transformer name ('Salesforce_Recommendations'). List it in allowedComponents instead of SalesforceRecommendations() — your renderer then handles that transformer, with no backend change.
class CoverflowRecommendations extends Component<SalesforceRecommendationsModel> {
@override
String get name => 'Salesforce_Recommendations';
@override
ValidationResult<SalesforceRecommendationsModel> validateAndCreateComponentModel(
String json,
ComponentContext context,
) {
final model = SalesforceRecommendationsModel.fromJson(json);
return model == null
? const ValidationFailure('Invalid JSON')
: ValidationSuccess(model);
}
@override
Widget build(SalesforceRecommendationsModel model, ComponentContext context) =>
MyCoverflowCarousel(items: model.items);
}
// Usage
ContentZone(
name: 'HomeScreen',
allowedComponents: [
CoverflowRecommendations(), // handles 'Salesforce_Recommendations'
],
)
Don't list both CoverflowRecommendations() and SalesforceRecommendations() under the same name: entries after the first with a matching (case-insensitive) name are dropped and logged as duplicates, so the extra entry is dead weight.
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<SalesforceBannerModel>(
name: 'previewBanner',
component: SalesforceBanner(),
mockContent: MockContent.success(SalesforceBannerModel(
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.