Skip to main content

What are popup hosts?

Popup hosts are widgets that automatically show and dismiss dialogs, fullscreen overlays, or modal presentations based on Presentum surface state. They build on top of surface observers to add popup-specific features:
  • Duplicate detection - Prevent showing the same popup twice in quick succession
  • Conflict resolution - Configure what happens when popups overlap
  • Automatic queuing - Queue popups to show sequentially
  • Smart dismissal tracking - Only track dismissals for system-closed popups
Use popup hosts for dialogs, fullscreen pages, bottom sheets, and any modal UI that requires user interaction and navigation integration.

Prerequisites

The PresentumPopupSurfaceStateMixin requires PresentumActiveSurfaceItemObserverMixin as a base. If you haven’t used surface observers before, read the Surface observers guide first.
Both mixins are required. You must add PresentumActiveSurfaceItemObserverMixin before PresentumPopupSurfaceStateMixin in your mixin list.

Basic usage

Here’s a minimal 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
        PresentumActiveSurfaceItemObserverMixin<
          CampaignItem,
          CampaignSurface,
          CampaignVariant,
          CampaignPopupHost
        >,
        PresentumPopupSurfaceStateMixin<
          CampaignItem,
          CampaignSurface,
          CampaignVariant,
          CampaignPopupHost
        > {

  @override
  CampaignSurface get surface => CampaignSurface.popup;

  @override
  Future<PopupPresentResult> present(CampaignItem entry) async {
    if (!mounted) return PopupPresentResult.notPresented;

    // Record impression
    final campaigns = context.campaignsPresentum;
    await campaigns.markShown(entry);

    if (!mounted) return PopupPresentResult.notPresented;

    // Show dialog
    final result = await showDialog<bool?>(
      context: context,
      builder: (context) => InheritedPresentum.value(
        value: campaigns,
        child: InheritedPresentumItem(
          item: entry,
          child: CampaignDialog(),
        ),
      ),
      barrierDismissible: false,
    );

    // Map to PopupPresentResult
    return result == true
        ? PopupPresentResult.userDismissed
        : PopupPresentResult.systemDismissed;
  }

  @override
  Widget build(BuildContext context) => widget.child;
}

Required implementations

You must implement two members:
@override
PresentumSurface get surface; // Which surface to observe

@override
Future<PopupPresentResult> present(TItem entry); // Show the popup

PopupPresentResult values

Return the appropriate enum value based on how the popup was dismissed:
  • PopupPresentResult.userDismissed - User took action (e.g., clicked β€œBuy Now”, converted). The mixin will NOT call markDismissed() because you should have already handled it.
  • PopupPresentResult.systemDismissed - User closed the popup without taking action (e.g., clicked β€œX” or β€œDismiss”). The mixin WILL call markDismissed() automatically.
  • PopupPresentResult.notPresented - Could not show the popup (e.g., widget not mounted). The mixin will NOT call markDismissed().
The mixin provides a default markDismissed() implementation that delegates to context.presentum().markDismissed(). Override only if you need custom dismissal logic.

What the mixin provides

The PresentumPopupSurfaceStateMixin automatically:
  1. Observes surface state via the required observer mixin
  2. Shows popups when active item changes
  3. Dismisses popups when active item becomes null
  4. Prevents duplicates with optional duplicate detection
  5. Resolves conflicts when multiple popups activate
  6. Manages queue for sequential popup display
  7. Tracks dismissals intelligently based on PopupPresentResult

Optional configuration

Duplicate detection

Prevent showing the same popup multiple times in quick succession:
class _CampaignPopupHostState extends State<CampaignPopupHost>
    with
        PresentumActiveSurfaceItemObserverMixin<...>,
        PresentumPopupSurfaceStateMixin<...> {

  @override
  bool get ignoreDuplicates => true;

  @override
  Duration? get duplicateThreshold => const Duration(seconds: 5);

  // ... rest of implementation
}
  • ignoreDuplicates: Enable/disable duplicate detection (default: false)
  • duplicateThreshold: Time window for considering entries duplicates
    • If null, always ignore duplicates of the same ID
    • If set, only ignore if shown within the threshold

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 users to finish with one popup before seeing another.

Replace

Immediately dismiss the 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 the current one is dismissed:
@override
PopupConflictStrategy get conflictStrategy => PopupConflictStrategy.queue;
Use when: You want to show all popups sequentially without skipping any.

Queuing behavior example

When using PopupConflictStrategy.queue:
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)

Production example

This is the actual popup host from the example app, managing campaign dialogs and fullscreen promos:
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
        PresentumActiveSurfaceItemObserverMixin<
          CampaignPresentumItem,
          CampaignSurface,
          CampaignVariant,
          CampaignPopupHost
        >,
        PresentumPopupSurfaceStateMixin<
          CampaignPresentumItem,
          CampaignSurface,
          CampaignVariant,
          CampaignPopupHost
        > {
  @override
  CampaignSurface get surface => CampaignSurface.popup;

  @override
  bool get ignoreDuplicates => true;

  @override
  Future<PopupPresentResult> present(CampaignPresentumItem entry) async {
    if (!mounted) return PopupPresentResult.notPresented;

    final campaigns = context.campaignsPresentum;
    await campaigns.markShown(entry);

    if (!mounted) return PopupPresentResult.notPresented;

    final factory = campaignsPresentationWidgetFactory;
    final fullscreenDialog =
        entry.option.variant == CampaignVariant.fullscreenDialog;

    final result = await showDialog<bool?>(
      context: context,
      builder: (context) => InheritedPresentum.value(
        value: campaigns,
        child: InheritedPresentumItem<
          CampaignPresentumItem,
          CampaignSurface,
          CampaignVariant
        >(
          item: entry,
          child: factory.buildPopup(context, entry),
        ),
      ),
      barrierDismissible: false,
      fullscreenDialog: fullscreenDialog,
    );

    return result == true
        ? PopupPresentResult.userDismissed
        : PopupPresentResult.systemDismissed;
  }

  @override
  Widget build(BuildContext context) => widget.child;
}
See full source β†’
Notice markDismissed is not implemented. The default implementation handles it automatically.

Custom presentation

Fullscreen routes

Use Navigator.push instead of showDialog for fullscreen presentations:
@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;

  final result = await Navigator.of(context).push<bool>(
    MaterialPageRoute(
      builder: (context) => InheritedPresentum.value(
        value: presentum,
        child: InheritedPresentumItem(
          item: entry,
          child: FullscreenCampaignPage(),
        ),
      ),
      fullscreenDialog: true,
    ),
  );

  return result == true
      ? PopupPresentResult.userDismissed
      : PopupPresentResult.systemDismissed;
}

Bottom sheets

Use showModalBottomSheet for bottom sheet presentations:
@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;

  final result = await showModalBottomSheet<bool>(
    context: context,
    builder: (context) => InheritedPresentumItem(
      item: entry,
      child: CampaignBottomSheet(),
    ),
    isDismissible: false,
  );

  return result == true
      ? PopupPresentResult.userDismissed
      : PopupPresentResult.systemDismissed;
}

Custom pop behavior

Override pop() for custom navigation:
@override
void pop() {
  if (mounted) {
    // Use a custom navigator or close logic
    MyCustomNavigator.of(context).pop();
  }
}

Dialog widgets

Create dialog widgets that access the item via InheritedPresentumItem:
class CampaignDialog extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final item = context.presentumItem<
      CampaignItem,
      CampaignSurface,
      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 {
                    final campaigns = context.presentum<
                      CampaignItem,
                      CampaignSurface,
                      CampaignVariant
                    >();
                    await campaigns.markConverted(item);
                    if (context.mounted) {
                      Navigator.pop(context, true);
                    }
                  },
                  child: const Text('Take Action'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Integration

Initialize the presentum and wrap your app with the popup host:
class CampaignsPresentum extends StatefulWidget {
  const CampaignsPresentum({required this.child, super.key});

  final Widget child;

  @override
  State<CampaignsPresentum> createState() => _CampaignsPresentumState();
}

class _CampaignsPresentumState extends State<CampaignsPresentum>
    with CampaignsPresentumStateMixin {
  @override
  Widget build(BuildContext context) {
    return campaignsPresentum.config.engine.build(
      context,
      CampaignsProviderScope(
        provider: provider,
        child: CampaignPopupHost(
          child: widget.child,
        ),
      ),
    );
  }
}
Then use it in your app:
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CampaignsPresentum(
      child: MaterialApp(
        home: HomeScreen(),
      ),
    );
  }
}
The host watches for popup surface changes and shows dialogs automatically.
This pattern initializes the presentum engine, wraps the child with the provider scope for context access, and adds the popup host for automatic dialog management.

Best practices

This ensures descendants can access the item and presentum instance:
// βœ… Good
builder: (context) => InheritedPresentum.value(
  value: presentum,
  child: InheritedPresentumItem(
    item: entry,
    child: MyDialog(),
  ),
)

// ❌ Bad - descendants can't access item
builder: (context) => MyDialog()
Call markShown before displaying the popup:
// βœ… Good
await presentum.markShown(entry);
await showDialog(...);

// ❌ Bad - impression not recorded
await showDialog(...);
Always check if the widget is mounted before showing popups:
// βœ… Good
@override
Future<PopupPresentResult> present(Item entry) async {
  if (!mounted) return PopupPresentResult.notPresented;
  
  await markShown(entry);
  
  if (!mounted) return PopupPresentResult.notPresented;
  
  final result = await showDialog(...);
  return result == true 
    ? PopupPresentResult.userDismissed 
    : PopupPresentResult.systemDismissed;
}
Map dialog results to the appropriate enum value:
// User converted - return userDismissed
onPressed: () async {
  await presentum.markConverted(item);
  if (context.mounted) {
    Navigator.pop(context, true); // true = user action
  }
}

// User just closed - return systemDismissed
onPressed: () => Navigator.pop(context, false) // false = system close

// Map in present method
final result = await showDialog<bool?>(...);
return result == true
    ? PopupPresentResult.userDismissed // Won't call markDismissed
    : PopupPresentResult.systemDismissed; // Will call markDismissed
This ensures dismissal tracking is only recorded for non-conversions.
Always add PresentumActiveSurfaceItemObserverMixin before PresentumPopupSurfaceStateMixin:
// βœ… Good
class _MyState extends State<MyWidget>
    with
        PresentumActiveSurfaceItemObserverMixin<...>,
        PresentumPopupSurfaceStateMixin<...> { }

// ❌ Bad - wrong order will cause compilation errors
class _MyState extends State<MyWidget>
    with
        PresentumPopupSurfaceStateMixin<...>,
        PresentumActiveSurfaceItemObserverMixin<...> { }

Advanced: Custom dismissal tracking

Override markDismissed for custom logic:
@override
Future<void> markDismissed({required CampaignItem entry}) async {
  // Custom dismissal logic
  await myAnalytics.trackDismissal(entry.id);
  await myStorage.saveDismissedAt(entry.id, DateTime.now());

  // Still call the default if needed
  await super.markDismissed(entry: entry);
}

When to use popup hosts

Use PresentumPopupSurfaceStateMixin when you need:

Dialogs

Modal dialogs that require user interaction

Fullscreen pages

Fullscreen promotional content or onboarding flows

Bottom sheets

Modal bottom sheets with actions

Conflict resolution

Smart handling of overlapping popups with queueing
Don’t use popup hosts for non-modal UI like snackbars or banners. Use Surface observers instead for simpler, more appropriate handling.

Next steps