flutter_salesforce_personalization 2.0.0 copy "flutter_salesforce_personalization: ^2.0.0" to clipboard
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 #

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.kt setup, and AndroidManifest.xml config keys.
  • iOS — see iOS.md for CocoaPods setup, AppDelegate.swift initialization, and Info.plist config 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 a ContentZone mounts. If instead you defer the consent choice to Dart, consent starts unset: mount a ContentZone only after your app has recorded the user's choice, because flipping consent later does not auto-retry a failed or empty fetch — you must call controller.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. allowedComponents is a name-keyed lookup — each entry's name must 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 one SalesforceBanner and one SalesforceRecommendations — to run a second configuration of the same OOTB type (different style or onTap), put it in a separate ContentZone.

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 #

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 public context.trackEngagement(action) (single-element components) or context.trackEngagementPerItem(index, action) (list components) — they never inspect engagementPayloads themselves and fire on every call, with no dedup.

  • Once-per-serving (e.g. View): call context.trackEngagementViewOnce([action = 'View']) (single-element) or context.trackEngagementViewOncePerItem(index, [action = 'View']) (list) directly. The primitive dedups on the ComponentContext instance's own object identity (not personalizationId), so it's safe to call straight from build() — including a StatelessWidget's — with no lifecycle wiring; pair it with a per-index personalizationId memo in your own State (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 for Click or any repeatable action — every call after the first for a given serving is silently dropped. Repeatable actions always go through trackEngagement / 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 RecsCarousel card calls context.trackEngagementViewOncePerItem(index) in build(), gated by the carousel State's per-index PID memo. Click fires every time the user taps a card — it is never deduped. If onTap is null AND the model has no ctaUrl, the card has no tap surface and no Click event fires. Eager-mount caveat: cards render eagerly (not lazily), so View fires on mount for every rendered card regardless of on-screen visibility; true visibility-based View tracking is a documented follow-up.
  • Banner: HeroBanner calls context.trackEngagementViewOnce() on mount and again only when personalizationId changes (didUpdateWidget), so a same-PID refetch does not refire while its State stays 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) or context.trackEngagementPerItem(index, action) (list).
  • View, or any once-per-serving action: call context.trackEngagementViewOnce() (single-element) or context.trackEngagementViewOncePerItem(index) (list) — both are public and safe to call directly from build(); repeated calls on the same ComponentContext instance are no-ops. These dedup, so use them only for View — never for Click or 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 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 a ContentZone mounted 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, call controller.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 #

0
likes
150
points
38
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin that wraps the native iOS/Android Salesforce Personalization SDKs to deliver personalized UI components (Hero Banner, Recommendations) in Flutter apps.

Repository (GitHub)
View/report issues
Contributing

Topics

#salesforce #personalization #marketing-cloud #flutter-plugin

License

BSD-3-Clause (license)

Dependencies

flutter, url_launcher

More

Packages that depend on flutter_salesforce_personalization

Packages that implement flutter_salesforce_personalization