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
Createlib/campaigns/presentum/surfaces.dart:
lib/campaigns/presentum/surfaces.dart
Copy
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
Createlib/campaigns/presentum/payload.dart:
lib/campaigns/presentum/payload.dart
Copy
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;
}
Step 3: Implement storage
Createlib/campaigns/presentum/storage.dart:
lib/campaigns/presentum/storage.dart
Copy
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
Createlib/campaigns/presentum/eligibility_scheduling_guard.dart:
lib/campaigns/presentum/eligibility_scheduling_guard.dart
Copy
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;
}
}
Step 5: Initialize Presentum
Create a mixin for Presentum initialization:lib/campaigns/presentum/presentum_state_mixin.dart
Copy
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();
}
}
lib/campaigns/presentum/campaign_presentum_widget.dart
Copy
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,
);
}
}
lib/main.dart:
lib/main.dart
Copy
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(),
),
);
}
}
Step 6: Create an outlet
Createlib/campaigns/presentum/campaign_outlet.dart:
lib/campaigns/presentum/campaign_outlet.dart
Copy
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
Copy
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
How do I integrate with Firebase Remote Config?
How do I integrate with Firebase Remote Config?
Fetch campaigns from Firebase and feed them using
setCandidates or
setCandidatesWithDiff. See Remote Config recipe
for a complete example.Can I use multiple Presentum instances?
Can I use multiple Presentum instances?
Yes! Create separate instances for different presentation types (campaigns,
tips, app updates). Each has independent state.