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.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.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: [
    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

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: View fires once per personalizationId per card. The dedup is owned by the carousel's state, so it survives orientation changes and other layout-driven re-mounts; a new personalizationId (e.g. from controller.refresh()) clears the dedup so fresh content fires fresh View events.
  • Banner: View fires on first mount and again on every personalizationId change. The banner does not hold a dedup set, so an orientation change (or other re-mount) that recreates its state will refire View for the same personalizationId.
  • Click fires every time the user taps a card. If onTap is null AND the model has no ctaUrl, the card has no tap surface and no Click event 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 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

Libraries

flutter_salesforce_personalization
Salesforce Personalization Flutter Plugin.