Presentum: A Declarative Presentation Engine for Flutter
Presentum is a declarative Flutter engine for building dynamic, conditional UI at scale. It helps you manage campaigns, app updates, special offers, tips, notifications and so much more with clean, testable, type-safe code.
Modern apps need personalized, adaptive experiences: show the right message to the right user at the right time, with impression limits, cooldowns, A/B testing, and analytics. Presentum handles all of that.
Instead of spreading show/hide logic across your widgets, you describe what should be shown as data, and Presentumβs engine, guards, and outlets handle where, when, and how it appears.
π Full Documentation Β· π Quick Start
The problem
Most apps manage presentations by mixing logic across widgets and state managers:
// β Violates SOLID, doesn't scale, hard to test
class HomeScreen extends StatefulWidget {
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
bool _showBanner = false;
Campaign? _campaign;
@override
void initState() {
super.initState();
_checkEligibility();
}
Future<void> _checkEligibility() async {
final count = await prefs.getInt('banner_count') ?? 0;
final lastShown = await prefs.getInt('banner_last_shown');
final withinTimeRange =
DateTime.now().difference(DateTime.prase(lastShown)).inHours > 24;
if (count < 3 && (lastShown == null || withinTimeRange)) {
final campaign = await fetchCampaign();
if (campaign case final campaign?
when campaign.isActive && !userIsPremium) {
setState(() {
_showBanner = true;
_campaign = campaign;
});
}
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_campaign case final campaign? when _showBanner)
BannerWidget(
campaign: _campaign,
onClose: () => _handleDismiss(),
),
// other content
],
);
}
}
This doesn't scale. With multiple presentation types, surfaces, eligibility rules, and A/B tests, complexity grows fast.
The solution
Presentum separates what (payloads), when (guards), where (surfaces), and how (outlets):
// β
Declarative, testable, maintainable
// 1. Define domain data
class CampaignPayload extends PresentumPayload<AppSurface, CampaignVariant> {
final String id;
final int priority;
final Map<String, Object?> metadata;
final List<PresentumOption<AppSurface, CampaignVariant>> options;
// Extend as needed, add whatever else you might need...
}
// 2. Define logic in guards
class CampaignGuard extends PresentumGuard<CampaignItem, AppSurface> {
@override
FutureOr<PresentumState<CampaignItem, AppSurface>> call(
storage, history, state, candidates, context,
) async {
for (final candidate in candidates) {
// Check impression count
final count = await storage.getShownCount(
candidate.id,
surface: candidate.surface,
variant: candidate.variant,
);
if (count >= 3) continue;
// Check cooldown
final lastShown = await storage.getLastShown(
candidate.id,
surface: candidate.surface,
variant: candidate.variant,
);
if (lastShown case final lastShown?) {
final hoursSince = DateTime.now().difference(lastShown).inHours;
if (hoursSince < candidate.cooldownHours) continue;
}
// Check user eligibility
if (!await _isEligible(candidate)) continue;
// All checks passed
state.setActive(candidate.surface, candidate);
}
return state;
}
}
// 3. Display widget with built-in outlet
class HomeTopBannerOutlet extends StatelessWidget {
const HomeTopBannerOutlet({
super.key,
});
@override
Widget build(BuildContext context) {
return PresentumOutlet<CampaignItem, AppSurface>(
surface: AppSurface.homeTopBanner,
builder: (context, item) {
return BannerWidget(
campaign: item.payload,
onClose: () => context
.presentum<CampaignItem, AppSurface>()
.markDismissed(item),
);
},
);
}
}
All eligibility logic is centralized. The outlet renders. The payload is data. Guards contain business rules. Everything is testable.
How it works
Presentum coordinates the flow between your data sources, eligibility rules, and UI:
- Data Fetching: Your app fetches candidates from Supabase, Firebase Remote config, APIs, or local sources
- Engine Processing: The Presentum Engine receives candidates and runs eligibility checks through guards
- State Management: Engine updates state, manages slots per surface, and tracks history/transitions
- UI Rendering: Outlets render active items based on current state
- User Interaction: Users interact with presented items (dismiss, convert, etc.)
- Event Recording: Storage layer records all user interactions and state changes
- Re-evaluation: Guards re-evaluate eligibility as needed based on new data or interactions
What you can build
This, and so much more:
|
App updates & maintenance
|
Marketing & promotions
|
|
User onboarding
|
In-app messaging
|
Presentum handles ANY condition you need:
- User segments (premium, free, trial)
- Geographic location (country, region, city)
- App version (force update for old versions)
- Device type (phone, tablet, platform)
- OS type (iPhone, Android, Web)
- User behavior (purchase history, usage patterns)
- Time-based rules (holidays, business hours)
- A/B test groups
- Feature flags (is_active)
- Custom business logic
The engine is flexible and scalable - if you can write a rule for it, Presentum can handle it. The only limit is your imagination.
Installation
Add Presentum to your pubspec.yaml:
dart pub add presentum
Core concepts
Surfaces
Where presentations appear. Named locations in your UI:
enum AppSurface with PresentumSurface {
homeTopBanner, // Top of home screen
watchlistHeader, // Watchlist header area
profileAlert, // Profile page alert
popup, // Modal overlay dialogs
}
Payloads
What you want to show. Your domain objects:
class CampaignPayload extends PresentumPayload<AppSurface, CampaignVariant> {
final String id;
final int priority;
final Map<String, Object?> metadata;
final List<PresentumOption<AppSurface, CampaignVariant>> options;
}
Example: Production campaign payload with JSON serialization
Options
How payloads appear, with constraints:
class CampaignPresentumOption
extends PresentumOption<CampaignSurface, CampaignVariant> {
final CampaignSurface surface;
final CampaignVariant variant;
final bool isDismissible;
final int? stage;
final int? maxImpressions;
final int? cooldownMinutes;
final bool alwaysOnIfEligible;
}
CampaignPresentumOption(
surface: AppSurface.homeTopBanner,
variant: CampaignVariant.banner,
maxImpressions: 3, // Show at most 3 times
cooldownMinutes: 1440, // Wait 24h between shows
isDismissible: true, // User can close it
)
Outlets
Rendering widgets. Just UI code:
class MyOutlet extends StatelessWidget {
const MyOutlet({
required this.surface,
super.key,
});
final MySurface surface;
@override
Widget build(BuildContext context) {
return PresentumOutlet<MyItem, MySurface>(
surface: surface,
builder: (context, item) {
return MyWidget(item);
},
);
}
}
Example: Popup host for dialog presentations
How to present
Use the context.presentum.setState((state) => ...) method as a basic presentum method.
And realize any presentum logic inside the callback as you please.
context.presentum.setState((state) {
state.setActive(AppSurface.homeTopBanner, campaignItem);
state.enqueue(AppSurface.profileAlert, alertItem);
return state;
});
You can truly do anything you want. Just change the state, slots, active items, and queues as you please. Everything is in your hands and just works fine, that's a declarative approach as it should be.
However, guards should be your primary tool for scheduling presentations, removing ineligible items, periodic refreshes, and complex eligibility rules. Use direct state changes when you need a very explicit controll.
Guards
Guards are a powerful tool for controlling presentations. They allow you to check the state and mutate/filter items based on eligibility rules. For example, you can check user preferences, storage, history, impression limits, cooldowns, or A/B test segments to determine what should be shown.
Examples:
- Scheduling guard with priority and sequencing
- Remove ineligible campaigns guard
- Sync state with candidates guard
Eligibility system
Build complex eligibility checks using conditions, rules, and extractors:
// Define eligibility conditions
final eligibility = AllOfEligibility(conditions: [
TimeRangeEligibility(
start: DateTime(2025, 1, 1),
end: DateTime(2025, 12, 31),
),
AnySegmentEligibility(
contextKey: 'user_segments',
requiredSegments: {'premium', 'verified'},
),
NumericComparisonEligibility(
contextKey: 'app_version',
comparison: NumericComparison.greaterThanOrEqual,
threshold: 2.0,
),
]);
// Create resolver with standard rules
final resolver = DefaultEligibilityResolver(
rules: createStandardRules(),
extractors: [
TimeRangeExtractor(),
AnySegmentExtractor(),
NumericComparisonExtractor(),
],
);
// Evaluate in your guard
final context = {
'user_segments': {'premium', 'trial'},
'app_version': 2.1,
};
final isEligible = await resolver.isEligible(candidate.payload, context);
if (isEligible) {
state.setActive(candidate.surface, candidate);
}
Transition observers
React to state changes with comprehensive diff snapshots. Useful for integrating with BLoC, Provider, or other state management:
class StateChangeObserver implements IPresentumTransitionObserver<Item, Surface, Variant> {
StateChangeObserver(this.bloc);
final MyBloc bloc;
@override
FutureOr<void> call(PresentumStateTransition<Item, Surface, Variant> transition) {
final diff = transition.diff;
// Fire events to your business logic layer
for (final change in diff.activated) {
bloc.add(PresentationActivated(change.item, change.surface));
}
for (final change in diff.deactivated) {
bloc.add(PresentationDeactivated(change.item, change.surface));
}
for (final change in diff.queued) {
bloc.add(PresentationQueued(change.item, change.surface));
}
}
}
presentum = Presentum(
storage: storage,
guards: guards,
transitionObservers: [StateChangeObserver(myBloc)],
);
Event system
Capture user interactions with a flexible event system:
// Built-in events: PresentumShownEvent, PresentumDismissedEvent, PresentumConvertedEvent
// Create custom event handlers
class AnalyticsEventHandler implements IPresentumEventHandler<Item, Surface, Variant> {
AnalyticsEventHandler(this.analytics);
final AnalyticsService analytics;
@override
FutureOr<void> call(PresentumEvent<Item, Surface, Variant> event) {
switch (event) {
case PresentumShownEvent(:final item, :final timestamp):
analytics.logImpression(item.id, timestamp);
case PresentumDismissedEvent(:final item, :final timestamp):
analytics.logDismissal(item.id, timestamp);
case PresentumConvertedEvent(:final item, :final timestamp, :final conversionMetadata):
analytics.logConversion(item.id, timestamp, conversionMetadata);
}
}
}
// Register event handlers
presentum = Presentum(
storage: storage,
guards: guards,
eventHandlers: [
PresentumStorageEventHandler(storage: storage), // Built-in storage handler
AnalyticsEventHandler(analyticsService),
// Add more handlers as needed
],
);
// Manually add custom events
await context.presentum.addEvent(MyCustomEvent(item: item, timestamp: DateTime.now()));
Auto-tracking widgets
Widgets that automatically call markShown when widget renders and persists
showed value in PageStorage to prevent any redundant calls:
TrackedWidget(
presentum: presentum,
item: campaignItem,
trackVisibility: true,
builder: (context) => MyCampaignWidget(),
)
State structure
Under the hood, Presentum manages state as a map of slots, where each slot represents one surface in your app.
Imagine you have three surfaces in your app showing different presentations:
homeTopBanner
ββ active: Campaign "Black Friday Sale" (priority: 100)
ββ queue: [
Campaign "New Year Promo" (priority: 80),
Tip "Swipe to refresh" (priority: 50)
]
profileAlert
ββ active: AppUpdate "Version 2.0 Available" (priority: 200)
ββ queue: []
settingsNotice
ββ active: null
ββ queue: [
Tip "Enable notifications" (priority: 60)
]
Let's create the following state to represent our expectations:
final state = PresentumState$Immutable<CampaignItem, AppSurface, CampaignVariant>(
intention: PresentumStateIntention.auto,
slots: {
AppSurface.homeTopBanner: PresentumSlot(
surface: AppSurface.homeTopBanner,
active: CampaignItem(
payload: CampaignPayload(
id: 'black-friday-2025',
priority: 100,
metadata: {
'title': 'Black Friday Sale',
'discount': '50%',
'expiresAt': '2025-11-30T23:59:59Z',
},
options: [
CampaignOption(
surface: AppSurface.homeTopBanner,
variant: CampaignVariant.banner,
maxImpressions: 5,
cooldownMinutes: 1440,
isDismissible: true,
),
],
),
option: CampaignOption(/* ... */),
),
queue: [
CampaignItem(
payload: CampaignPayload(
id: 'new-year-promo-2026',
priority: 80,
metadata: {
'title': 'New Year Promo',
'discount': '30%',
},
options: [/* ... */],
),
option: CampaignOption(/* ... */),
),
TipItem(
payload: TipPayload(
id: 'tip-swipe-refresh',
priority: 50,
metadata: {
'title': 'Swipe to refresh',
'description': 'Pull down to see latest updates',
},
options: [/* ... */],
),
option: TipOption(/* ... */),
),
],
),
AppSurface.profileAlert: PresentumSlot(
surface: AppSurface.profileAlert,
active: AppUpdateItem(
payload: AppUpdatePayload(
id: 'app-update-2.0',
priority: 200,
metadata: {
'version': '2.0.0',
'isForced': false,
'releaseNotes': 'New features and improvements',
},
options: [/* ... */],
),
option: AppUpdateOption(/* ... */),
),
queue: [],
),
AppSurface.settingsNotice: PresentumSlot(
surface: AppSurface.settingsNotice,
active: null, // Nothing currently shown
queue: [
TipItem(
payload: TipPayload(
id: 'tip-enable-notifications',
priority: 60,
metadata: {
'title': 'Enable notifications',
'description': 'Stay updated with important alerts',
},
options: [/* ... */],
),
option: TipOption(/* ... */),
),
],
),
},
);
Each slot is a container for one surface with:
PresentumSlot<TItem, S, V> {
final S surface; // Where it appears
final TItem? active; // What's showing now
final List<TItem> queue; // What's waiting
}
When you dismiss the active item, the next queued item automatically becomes active:
Before dismissing an item:
homeTopBanner
ββ active: "Black Friday Sale"
ββ queue: ["New Year Promo", "Swipe to refresh"]
After dismissing or ineligibility removal:
homeTopBanner
ββ active: "New Year Promo" <- Promoted from queue
ββ queue: ["Swipe to refresh"]
This happens automatically via state.clearActive(surface) or when you call context.presentum.markDismissed(item).
Changelog
See CHANGELOG.md for release notes.
License
MIT License. See LICENSE for details.
Maintainers
Libraries
- presentum
- A declarative cross-platform Flutter engine with focus on state to display presentations, such as campaigns, banners, notifications, etc., anywhere, anytime.