Skip to main content

Overview

In this guide, you’ll create a campaign banner system that:
  • Shows promotional banners
  • Tracks impressions and dismissals
  • Respects impression limits and cooldowns
  • Handles user interactions
Time estimate: 5 minutesPrerequisites: Flutter project with Presentum installed (Installation guide)

Step 1: Define surfaces and variants

Create lib/campaigns/presentum/surfaces.dart:
lib/campaigns/presentum/surfaces.dart
import 'package:presentum/presentum.dart';

/// Where presentations can appear
enum AppSurface with PresentumSurface {
  homeTopBanner,
  profileAlert,
  popup;
}

/// How presentations are displayed
enum CampaignVariant with PresentumVisualVariant {
  banner,
  dialog,
  inline;
}
Surfaces are locations (homeTopBanner, popup). Variants are display styles (banner, dialog).

Step 2: Create payload and option classes

Create lib/campaigns/presentum/payload.dart:
lib/campaigns/presentum/payload.dart
import 'package:presentum/presentum.dart';
import 'surfaces.dart';

class CampaignPresentumOption
    extends PresentumOption<AppSurface, CampaignVariant> {
  const CampaignPresentumOption({
    required this.surface,
    required this.variant,
    required this.isDismissible,
    this.stage,
    this.maxImpressions,
    this.cooldownMinutes,
    this.alwaysOnIfEligible = false,
  });

  @override
  final AppSurface surface;

  @override
  final CampaignVariant variant;

  @override
  final bool isDismissible;

  @override
  final int? stage;

  @override
  final int? maxImpressions;

  @override
  final int? cooldownMinutes;

  @override
  final bool alwaysOnIfEligible;
}

class CampaignPayload
    extends PresentumPayload<AppSurface, CampaignVariant> {
  const CampaignPayload({
    required this.id,
    required this.priority,
    required this.metadata,
    required this.options,
  });

  @override
  final String id;

  @override
  final int priority;

  @override
  final Map<String, Object?> metadata;

  @override
  final List<CampaignPresentumOption> options;
}

class CampaignPresentumItem
    extends PresentumItem<CampaignPayload, AppSurface, CampaignVariant> {
  const CampaignPresentumItem({
    required this.payload,
    required this.option,
  });

  @override
  final CampaignPayload payload;

  @override
  final CampaignPresentumOption option;
}
See production payload with JSON serialization ->

Step 3: Implement storage

Create lib/campaigns/presentum/storage.dart:
lib/campaigns/presentum/storage.dart
import 'dart:async';

import 'package:presentum/presentum.dart';
import 'package:shared_preferences/shared_preferences.dart';

typedef PersistentPresentumStorageKey<
  S extends PresentumSurface,
  V extends PresentumVisualVariant
> = (String itemId, S surface, V variant);

extension type PersistentPresentumStorageKeys<
  S extends PresentumSurface,
  V extends PresentumVisualVariant
>(PersistentPresentumStorageKey<S, V> key) {
  String get shownCount =>
      '__shown_${key.$1}_${key.$2.name}_${key.$3.name}_count_key__';
  String get lastShown =>
      '__shown_${key.$1}_${key.$2.name}_${key.$3.name}_last_shown_key__';
  String get timestamps =>
      '__shown_${key.$1}_${key.$2.name}_${key.$3.name}_timestamps_key__';
  String get dismissedAt =>
      '__dismissed_${key.$1}_${key.$2.name}_${key.$3.name}_at_key__';
  String get convertedAt =>
      '__converted_${key.$1}_${key.$2.name}_${key.$3.name}_at_key__';

  List<String> get allKeys => [
    shownCount,
    lastShown,
    timestamps,
    dismissedAt,
    convertedAt,
  ];
}

class PersistentPresentumStorage<
  S extends PresentumSurface,
  V extends PresentumVisualVariant
>
    implements PresentumStorage<S, V> {
  PersistentPresentumStorage({required SharedPreferencesWithCache prefs})
    : _prefs = prefs;

  final SharedPreferencesWithCache _prefs;

  @override
  Future<void> clearItem(
    String itemId, {
    required S surface,
    required V variant,
  }) => Future.wait(
    PersistentPresentumStorageKeys((
      itemId,
      surface,
      variant,
    )).allKeys.map(_prefs.remove),
  );

  @override
  FutureOr<DateTime?> getLastShown(
    String itemId, {
    required S surface,
    required V variant,
  }) async {
    final key = PersistentPresentumStorageKeys((
      itemId,
      surface,
      variant,
    )).lastShown;
    final timestampStr = _prefs.getString(key);
    return timestampStr != null ? DateTime.parse(timestampStr) : null;
  }

  @override
  FutureOr<void> recordShown(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    final countKey = keys.shownCount;
    final lastShownKey = keys.lastShown;
    final timestampsKey = keys.timestamps;

    final currentCount = _prefs.getInt(countKey) ?? 0;
    final currentTimestamps = _prefs.getStringList(timestampsKey) ?? [];

    await _prefs.setInt(countKey, currentCount + 1);
    await _prefs.setString(lastShownKey, at.toIso8601String());
    await _prefs.setStringList(timestampsKey, [
      ...currentTimestamps,
      at.toIso8601String(),
    ]);
  }

  @override
  FutureOr<int> getShownCount(
    String itemId, {
    required Duration period,
    required S surface,
    required V variant,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    final timestampsKey = keys.timestamps;
    final timestampStrings = _prefs.getStringList(timestampsKey) ?? [];
    final timestamps = timestampStrings.map(DateTime.parse).toList();
    final cutoff = DateTime.now().subtract(period);
    final count = timestamps.where((t) => t.isAfter(cutoff)).length;

    return count;
  }

  @override
  FutureOr<DateTime?> getDismissedAt(
    String itemId, {
    required S surface,
    required V variant,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    final timestampStr = _prefs.getString(keys.dismissedAt);
    return timestampStr != null ? DateTime.parse(timestampStr) : null;
  }

  @override
  FutureOr<void> recordDismissed(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    await _prefs.setString(keys.dismissedAt, at.toIso8601String());
  }

  @override
  FutureOr<void> recordConverted(
    String itemId, {
    required S surface,
    required V variant,
    required DateTime at,
  }) async {
    final keys = PersistentPresentumStorageKeys((itemId, surface, variant));
    await _prefs.setString(keys.convertedAt, at.toIso8601String());
  }
}
This storage implementation is generic and reusable across any Presentum instance. The type parameters <S, V> make it work with any surface and variant enums you define.

Step 4: Create a guard

Create lib/campaigns/presentum/eligibility_scheduling_guard.dart:
lib/campaigns/presentum/eligibility_scheduling_guard.dart
class CampaignSchedulingGuard
    extends PresentumGuard<CampaignPresentumItem, AppSurface, CampaignVariant> {
  CampaignSchedulingGuard({
    required this.eligibilityResolver,
    super.refresh,
  });

  /// The eligibility resolver to use for filtering items.
  final EligibilityResolver<HasMetadata> eligibilityResolver;

  @override
  FutureOr<PresentumState<CampaignPresentumItem, AppSurface, CampaignVariant>> call(
    PresentumStorage storage,
    List<PresentumHistoryEntry> history,
    PresentumState$Mutable state,
    List<CampaignPresentumItem> candidates,
    Map<String, Object?> context,
  ) async {
    final filtered = <CampaignPresentumItem>[];

    for (final item in candidates) {
      // Check eligibility
      final isEligible = await eligibilityResolver.isEligible(item, context);
      if (!isEligible) continue;

      filtered.add(item);
    }

    state.clearAll();

    final bySurface = <AppSurface, List<FeatureItem>>{};
    for (final item in filtered) {
      (bySurface[item.surface] ??= <FeatureItem>[]).add(item);
    }

    for (final entry in bySurface.entries) {
      final surface = entry.key;
      final items = entry.value;

      int stageOf(FeatureItem i) => i.stage ?? 0;

      items.sort((a, b) {
        final stageCmp = stageOf(a).compareTo(stageOf(b));
        if (stageCmp != 0) return stageCmp;
        return b.priority.compareTo(a.priority);
      });

      state.addAll(surface, items);
    }

    return state;
  }
}
See production guards ->

Step 5: Initialize Presentum

Create a mixin for Presentum initialization:
lib/campaigns/presentum/presentum_state_mixin.dart
import 'package:flutter/widgets.dart';
import 'package:presentum/presentum.dart';
import 'surfaces.dart';
import 'payload.dart';
import 'storage.dart';
import 'guards.dart';

mixin CampaignPresentumMixin<T extends StatefulWidget> on State<T> {
  late final Presentum<CampaignPresentumItem, AppSurface, CampaignVariant>
      campaignPresentum;
  late final PresentumStorage<AppSurface, CampaignVariant> _storage;
  late final EligibilityResolver<HasMetadata> _eligibility;

  @override
  void initState() {
    super.initState();

    final deps = Dependencies.of(context);

    // Note: DO NOT asyncronously initilize shared preferences

    _storage = PersistentPresentumStorage<AppSurface, CampaignVariant>(pref: deps.sharedPreferences);

    _eligibility = DefaultEligibilityResolver<HasMetadata>(
      rules: [...createStandardRules()],
      extractors: const [
        TimeRangeExtractor(),
        ConstantExtractor(metadataKey: 'is_active'),
        AnyOfExtractor(
          nestedExtractors: [
            TimeRangeExtractor(),
            ConstantExtractor(metadataKey: 'is_active'),
          ],
        ),
      ],
    );

    campaignPresentum = Presentum(
      storage: _storage,
      eventHandlers: [
        PresentumStorageEventHandler(storage: _storage),
      ],
      guards: [
        SyncStateWithCandidatesGuard<CampaignPresentumItem, AppSurface, CampaignVariant>(),
        CampaignSchedulingGuard(eligibility: _eligibility),
      ],
    );
  }

  @override
  void dispose() {
    campaignPresentum.dispose();
    super.dispose();
  }
}
Create a wrapper widget:
lib/campaigns/presentum/campaign_presentum_widget.dart
import 'package:flutter/material.dart';
import 'presentum_state_mixin.dart';

class CampaignPresentumWidget extends StatefulWidget {
  const CampaignPresentumWidget({required this.child, super.key});

  final Widget child;

  @override
  State<CampaignPresentumWidget> createState() => _CampaignPresentumWidgetState();
}

class _CampaignPresentumWidgetState extends State<CampaignPresentumWidget>
    with CampaignPresentumMixin {
  @override
  Widget build(BuildContext context) {
    // Use config.engine.build, or explicitly use InheritedPresentum.value(value: campaignPresentum)
    return campaignPresentum.config.engine.build(
      context,
      widget.child,
    );
  }
}
Update lib/main.dart:
lib/main.dart
import 'package:flutter/material.dart';
import 'campaigns/presentum/campaign_presentum_widget.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return CampaignPresentumWidget(
      child: MaterialApp(
        title: 'Presentum Quickstart',
        home: const HomeView(),
      ),
    );
  }
}
See production initialization ->

Step 6: Create an outlet

Create lib/campaigns/presentum/campaign_outlet.dart:
lib/campaigns/presentum/campaign_outlet.dart
import 'package:flutter/material.dart';
import 'package:presentum/presentum.dart';
import 'surfaces.dart';
import 'payload.dart';

class HomeTopBannerOutlet extends StatelessWidget {
  const HomeTopBannerOutlet({super.key});

  @override
  Widget build(BuildContext context) {
    return PresentumOutlet<CampaignPresentumItem, AppSurface, CampaignVariant>(
      surface: AppSurface.homeTopBanner,
      builder: (context, item) {
        return Container(
          margin: const EdgeInsets.all(16),
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            color: Colors.blue,
            borderRadius: BorderRadius.circular(12),
          ),
          child: Row(
            children: [
              Expanded(
                child: Text(
                  item.metadata['title'] as String? ?? '',
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              IconButton(
                icon: const Icon(Icons.close, color: Colors.white),
                onPressed: () {
                  context
                      .presentum<CampaignPresentumItem, AppSurface, CampaignVariant>()
                      .markDismissed(item);
                },
              ),
            ],
          ),
        );
      },
    );
  }
}

Step 7: Use the outlet

Add the outlet to your home screen:
lib/home/view/home_view.dart
import 'package:flutter/material.dart';
import 'campaign_outlet.dart';

class HomeView extends StatelessWidget {
  const HomeView({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Column(
        children: [
          const HomeTopBannerOutlet(),
          Expanded(
            child: ListView(
              children: const [
                ListTile(title: Text('Your app content')),
              ],
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _addTestCampaign(context),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _addTestCampaign(BuildContext context) {
    final campaign = CampaignPayload(
      id: 'test-campaign',
      priority: 100,
      metadata: {'title': 'Welcome to Presentum!'},
      options: [
        CampaignPresentumOption(
          surface: AppSurface.homeTopBanner,
          variant: CampaignVariant.banner,
          maxImpressions: 3,
          cooldownMinutes: 60,
          isDismissible: true,
        ),
      ],
    );

    final item = CampaignPresentumItem(
      payload: campaign,
      option: campaign.options.first,
    );

    // Feed to engine
    context
        .presentum<CampaignPresentumItem, AppSurface, CampaignVariant>()
        .config
        .engine
        .setCandidates(
          (state, current) => [...current, item],
        );
  }
}

Next steps

Common questions

Fetch campaigns from Firebase and feed them using setCandidates or setCandidatesWithDiff. See Remote Config recipe for a complete example.
Yes! Create separate instances for different presentation types (campaigns, tips, app updates). Each has independent state.