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/presentation/surfaces.dart:
lib/presentation/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/presentation/payload.dart:
lib/presentation/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/presentation/storage.dart:
lib/presentation/storage.dart
import 'dart:async';
import 'package:presentum/presentum.dart';
import 'surfaces.dart';

class InMemoryStorage implements PresentumStorage<AppSurface, CampaignVariant> {
  final Map<String, DateTime> _lastShown = {};
  final Map<String, int> _shownCount = {};
  final Map<String, DateTime> _dismissedAt = {};

  @override
  Future<void> init() async {}

  @override
  Future<void> clear() async {
    _lastShown.clear();
    _shownCount.clear();
    _dismissedAt.clear();
  }

  @override
  FutureOr<void> recordShown(
    String itemId, {
    required AppSurface surface,
    required CampaignVariant variant,
    required DateTime at,
  }) {
    final key = _makeKey(itemId, surface, variant);
    _lastShown[key] = at;
    _shownCount[key] = (_shownCount[key] ?? 0) + 1;
  }

  @override
  FutureOr<DateTime?> getLastShown(
    String itemId, {
    required AppSurface surface,
    required CampaignVariant variant,
  }) {
    final key = _makeKey(itemId, surface, variant);
    return _lastShown[key];
  }

  @override
  FutureOr<int> getShownCount(
    String itemId, {
    required Duration period,
    required AppSurface surface,
    required CampaignVariant variant,
  }) {
    final key = _makeKey(itemId, surface, variant);
    return _shownCount[key] ?? 0;
  }

  @override
  FutureOr<void> recordDismissed(
    String itemId, {
    required AppSurface surface,
    required CampaignVariant variant,
    required DateTime at,
  }) {
    final key = _makeKey(itemId, surface, variant);
    _dismissedAt[key] = at;
  }

  @override
  FutureOr<DateTime?> getDismissedAt(
    String itemId, {
    required AppSurface surface,
    required CampaignVariant variant,
  }) {
    final key = _makeKey(itemId, surface, variant);
    return _dismissedAt[key];
  }

  @override
  FutureOr<void> recordConverted(
    String itemId, {
    required AppSurface surface,
    required CampaignVariant variant,
    required DateTime at,
  }) {
    // Implement conversion tracking
  }

  String _makeKey(String itemId, Enum surface, Enum variant) =>
      '$itemId::${surface.name}::${variant.name}';
}
This is an in-memory implementation for demo purposes. For production, use SharedPreferences or a backend.
See production SharedPreferences storage ->

Step 4: Create a guard

Create lib/presentation/guards.dart:
lib/presentation/guards.dart
import 'dart:async';
import 'package:presentum/presentum.dart';
import 'payload.dart';
import 'surfaces.dart';

class CampaignGuard
    extends PresentumGuard<CampaignPresentumItem, AppSurface, CampaignVariant> {
  @override
  FutureOr<PresentumState<CampaignPresentumItem, AppSurface, CampaignVariant>> call(
    PresentumStorage storage,
    List<PresentumHistoryEntry> history,
    PresentumState$Mutable state,
    List<CampaignPresentumItem> candidates,
    Map<String, Object?> context,
  ) async {
    for (final candidate in candidates) {
      final option = candidate.option;

      // Check impression limit
      if (option.maxImpressions case final max?) {
        final count = await storage.getShownCount(
          candidate.id,
          period: const Duration(days: 365),
          surface: candidate.surface,
          variant: candidate.variant,
        );
        if (count >= max) continue;
      }

      // Check cooldown
      if (option.cooldownMinutes case final cooldown?) {
        final lastShown = await storage.getLastShown(
          candidate.id,
          surface: candidate.surface,
          variant: candidate.variant,
        );
        if (lastShown case final last?) {
          final minutesSince = DateTime.now().difference(last).inMinutes;
          if (minutesSince < cooldown) continue;
        }
      }

      // All checks passed
      state.setActive(candidate.surface, candidate);
    }

    return state;
  }
}
See production guards ->

Step 5: Initialize Presentum

Update lib/main.dart:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:presentum/presentum.dart';
import 'presentation/surfaces.dart';
import 'presentation/payload.dart';
import 'presentation/storage.dart';
import 'presentation/guards.dart';

late final Presentum<CampaignPresentumItem, AppSurface, CampaignVariant>
    campaignPresentum;

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize storage
  final storage = InMemoryStorage();
  await storage.init();

  // Create Presentum instance
  campaignPresentum = Presentum(
    storage: storage,
    eventHandlers: [
      PresentumStorageEventHandler(storage: storage),
    ],
    guards: [
      CampaignGuard(),
    ],
  );

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    // Wrap app with Presentum engine
    return campaignPresentum.config.engine.build(
      context,
      MaterialApp(
        title: 'Presentum Quickstart',
        home: const HomeScreen(),
      ),
    );
  }
}
See production initialization ->

Step 6: Create an outlet

Create lib/widgets/campaign_outlet.dart:
lib/widgets/campaign_outlet.dart
import 'package:flutter/material.dart';
import 'package:presentum/presentum.dart';
import '../presentation/surfaces.dart';
import '../presentation/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/screens/home_screen.dart
import 'package:flutter/material.dart';
import '../widgets/campaign_outlet.dart';

class HomeScreen extends StatelessWidget {
  const HomeScreen({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],
        );
  }
}

Step 8: Run your app

1

Run the app

bash flutter run
2

Tap the + button

This creates and displays a test campaign banner.
3

Dismiss the banner

Click the X to dismiss it.
4

Try again

Notice cooldown and impression limits work automatically!
Congratulations! You’ve built your first Presentum presentation.

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.
Guards are functions - easy to test! Mock storage and verify state mutations. See Testing guide.