Popup hosts are widgets that watch a popup surface and automatically show/dismiss dialogs based on state changes. They handle the complexity of dialog lifecycle, queueing, and navigation integration.
Use popup hosts for dialogs, fullscreen overlays, and modal presentations that
need to appear over your app content.
Built-in mixin
Presentum provides PresentumPopupSurfaceStateMixin for automatic popup management:
class CampaignPopupHost extends StatefulWidget {
const CampaignPopupHost ({ required this .child, super .key});
final Widget child;
@override
State < CampaignPopupHost > createState () => _CampaignPopupHostState ();
}
class _CampaignPopupHostState extends State < CampaignPopupHost >
with PresentumPopupSurfaceStateMixin <
CampaignItem ,
AppSurface ,
CampaignVariant ,
CampaignPopupHost
> {
@override
AppSurface get surface => AppSurface .popup;
@override
Future < void > markDismissed ({ required CampaignItem entry}) async {
await context. presentum < CampaignItem , AppSurface , CampaignVariant >()
. markDismissed (entry);
}
@override
Future < PopupPresentResult > present ( CampaignItem entry) async {
if ( ! mounted) return PopupPresentResult .notPresented;
// Record impression
await context. presentum < CampaignItem , AppSurface , CampaignVariant >()
. markShown (entry);
if ( ! mounted) return PopupPresentResult .notPresented;
final presentum = context. presentum < CampaignItem , AppSurface , CampaignVariant >();
// Show dialog
final result = await showDialog < bool ?>(
context : context,
builder : (context) => InheritedPresentum . value (
value : presentum,
child : InheritedPresentumItem (
item : entry,
child : CampaignDialogWidget (entry),
),
),
barrierDismissible : false ,
);
// Map dialog result to PopupPresentResult
if (result == true ) {
return PopupPresentResult .userDismissed;
}
return PopupPresentResult .systemDismissed;
}
@override
Widget build ( BuildContext context) => widget.child;
}
What the mixin provides
The PresentumPopupSurfaceStateMixin automatically:
Watches the popup surface for state changes
Shows dialogs when active item changes
Dismisses dialogs when active becomes null
Handles conflicts - configurable behavior when popups overlap
Prevents duplicates - optional duplicate detection with threshold
Manages queue - optional queueing based on conflict strategy
Required implementations
You must implement:
@override
PresentumSurface get surface; // Which surface to watch
@override
Future < void > markDismissed ({ required TItem entry});
@override
Future < PopupPresentResult > present ( TItem entry); // Returns present result
Present result values:
PopupPresentResult.userDismissed - User dismissed (won’t call markDismissed)
PopupPresentResult.systemDismissed - System dismissed (will call markDismissed)
PopupPresentResult.notPresented - Not presented (won’t call markDismissed)
Optional overrides
Configure popup behavior:
@override
PopupConflictStrategy get conflictStrategy => PopupConflictStrategy .ignore;
@override
bool get ignoreDuplicates => false ;
@override
Duration ? get duplicateThreshold => const Duration (seconds : 3 );
Conflict strategies
Control what happens when a new popup activates while another is showing:
Ignore (default)
Keep showing the current popup, ignore new ones:
@override
PopupConflictStrategy get conflictStrategy => PopupConflictStrategy .ignore;
Use when: You want to finish showing one popup before considering others.
Replace
Immediately dismiss current popup and show the new one:
@override
PopupConflictStrategy get conflictStrategy => PopupConflictStrategy .replace;
Use when: Newer popups have higher priority than older ones.
Queue
Queue new popups to show after current one is dismissed:
@override
PopupConflictStrategy get conflictStrategy => PopupConflictStrategy .queue;
Use when: You want to show all popups sequentially without skipping any.
Duplicate detection
Prevent showing the same popup multiple times in quick succession:
class _CampaignPopupHostState extends State < CampaignPopupHost >
with PresentumPopupSurfaceStateMixin <...> {
@override
bool get ignoreDuplicates => true ;
@override
Duration ? get duplicateThreshold => const Duration (seconds : 5 );
// ... rest of implementation
}
ignoreDuplicates : Enable/disable duplicate detection
duplicateThreshold : Time window for considering something a duplicate
If null, always ignore duplicates of the same ID
If set, only ignore if shown within the threshold
Production example
Here’s the full production popup host:
class CampaignPopupHost extends StatefulWidget {
const CampaignPopupHost ({ required this .child, super .key});
final Widget child;
@override
State < CampaignPopupHost > createState () => _CampaignPopupHostState ();
}
class _CampaignPopupHostState extends State < CampaignPopupHost >
with PresentumPopupSurfaceStateMixin <
CampaignPresentumItem ,
CampaignSurface ,
CampaignVariant ,
CampaignPopupHost
> {
@override
CampaignSurface get surface => CampaignSurface .popup;
@override
Future < void > markDismissed ({ required CampaignPresentumItem entry}) async {
final campaigns = context.campaigns;
await campaigns. markDismissed (entry);
}
@override
Future < PopupPresentResult > present ( CampaignPresentumItem entry) async {
try {
if ( ! mounted) return PopupPresentResult .notPresented;
final campaigns = context.campaigns;
await campaigns. markShown (entry);
if ( ! mounted) return PopupPresentResult .notPresented;
final factory = campaignsPresentationWidgetFactory;
final isFullscreen = entry.option.variant == CampaignVariant .fullscreenDialog;
// Show appropriate dialog type
final result = await showDialog < bool ?>(
context : context,
builder : (context) => InheritedPresentum . value (
value : campaigns,
child : InheritedPresentumItem (
item : entry,
child : factory . buildPopup (context, entry),
),
),
barrierDismissible : false ,
useSafeArea : ! isFullscreen,
);
// Map dialog result to PopupPresentResult
return result == true
? PopupPresentResult .userDismissed
: PopupPresentResult .systemDismissed;
} catch (error, stackTrace) {
FlutterError . reportError (
FlutterErrorDetails (
exception : error,
stack : stackTrace,
library : 'CampaignPopupHost' ,
),
);
return PopupPresentResult .systemDismissed;
}
}
@override
Widget build ( BuildContext context) => widget.child;
}
See full source ->
Usage
Wrap your app or a subtree with the popup host:
class MyApp extends StatelessWidget {
@override
Widget build ( BuildContext context) {
return presentum.config.engine. build (
context,
CampaignPopupHost (
child : MaterialApp (
home : HomeScreen (),
),
),
);
}
}
The host watches for popup surface changes and shows dialogs automatically.
Create dialogs that access the item via InheritedPresentumItem:
class CampaignDialogWidget extends StatelessWidget {
@override
Widget build ( BuildContext context) {
// Access item from InheritedPresentumItem
final item = context.presentumItem <
CampaignItem ,
AppSurface ,
CampaignVariant
> ();
return Dialog (
child : Padding (
padding : const EdgeInsets . all ( 24 ),
child : Column (
mainAxisSize : MainAxisSize .min,
children : [
Text (
item.metadata[ 'title' ] as String ,
style : Theme . of (context).textTheme.headlineSmall,
),
const SizedBox (height : 16 ),
Text (item.metadata[ 'message' ] as String ),
const SizedBox (height : 24 ),
Row (
mainAxisAlignment : MainAxisAlignment .end,
children : [
TextButton (
onPressed : () => Navigator . pop (context, false ),
child : const Text ( 'Dismiss' ),
),
const SizedBox (width : 8 ),
ElevatedButton (
onPressed : () async {
await context
. presentum < CampaignItem , AppSurface , CampaignVariant >()
. markConverted (item);
if (context.mounted) {
Navigator . pop (context, true );
}
},
child : const Text ( 'Take Action' ),
),
],
),
],
),
),
);
}
}
Custom show logic
Override present for custom dialog behavior:
@override
Future < PopupPresentResult > present ( CampaignItem entry) async {
if ( ! mounted) return PopupPresentResult .notPresented;
final presentum = context. presentum < CampaignItem , AppSurface , CampaignVariant >();
await presentum. markShown (entry);
if ( ! mounted) return PopupPresentResult .notPresented;
// Custom routing or modal presentation
final result = await Navigator . of (context). push < bool >(
MaterialPageRoute (
builder : (context) => InheritedPresentum . value (
value : presentum,
child : InheritedPresentumItem (
item : entry,
child : FullscreenCampaignPage (entry),
),
),
fullscreenDialog : true ,
),
);
// Map route result to PopupPresentResult
return result == true
? PopupPresentResult .userDismissed
: PopupPresentResult .systemDismissed;
}
The mixin automatically handles the result:
PopupPresentResult.userDismissed: Won’t call markDismissed() (user handled it)
PopupPresentResult.systemDismissed: Will call markDismissed() automatically
PopupPresentResult.notPresented: Won’t call markDismissed() (wasn’t shown)
Queuing behavior
When using PopupConflictStrategy.queue, the mixin automatically queues popups:
State: popup surface has item A showing, items B and C activate
With conflictStrategy = queue:
├─ active: Campaign A (showing)
└─ queue: [Campaign B, Campaign C]
User flow:
1. Campaign A dialog shows
2. User dismisses A
3. Campaign B dialog shows automatically (from queue)
4. User dismisses B
5. Campaign C dialog shows automatically (from queue)
With other strategies:
ignore : Items B and C are ignored while A shows
replace : A is dismissed, B shows immediately (then C replaces B)
Best practices
Wrap InheritedPresentumItem
Always wrap dialog content with InheritedPresentumItem so descendants can access the item: builder : (context) => InheritedPresentumItem (
item : entry,
child : MyDialogContent (),
)
Record impressions before showing
Call markShown before displaying the dialog, not after: // ✅ Good
await presentum. markShown (entry);
await showDialog (...);
// ❌ Bad
await showDialog (...);
await presentum. markShown (entry); // Too late
Return correct PopupPresentResult
Next steps