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

Core concepts

Deep dive into architecture

Production storage

Implement persistent storage

Advanced guards

Build complex eligibility rules

Real example

See production implementation

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.