Skip to main content

What are outlets?

Outlets are widgets that render presentations. They watch a specific surface and rebuild when the active item changes. Outlets contain zero business logic. They just render what the engine tells them to show.
Think of outlets as “slots” in your UI where presentations appear. The engine controls what goes in the slot.

Basic outlet

The simplest outlet uses PresentumOutlet:
class HomeTopBannerOutlet extends StatelessWidget {
  const HomeTopBannerOutlet({super.key});

  @override
  Widget build(BuildContext context) {
    return PresentumOutlet<CampaignItem, AppSurface, CampaignVariant>(
      surface: AppSurface.homeTopBanner,
      builder: (context, item) {
        return BannerWidget(
          title: item.metadata['title'] as String,
          onClose: () => context
              .presentum<CampaignItem, AppSurface, CampaignVariant>()
              .markDismissed(item),
        );
      },
    );
  }
}
When no item is active for the surface, the outlet renders SizedBox.shrink() by default.

Custom placeholder

Provide a custom widget when nothing is shown:
PresentumOutlet<CampaignItem, AppSurface, CampaignVariant>(
  surface: AppSurface.homeTopBanner,
  builder: (context, item) => BannerWidget(item),
  placeholderBuilder: (context) => const SizedBox(height: 60),
)

Composition outlets

PresentumOutlet$Composition gives you access to both active and queued items:
PresentumOutlet$Composition<CampaignItem, AppSurface, CampaignVariant>(
  surface: AppSurface.homeTopBanner,
  builder: (context, active, queue) {
    return Column(
      children: [
        if (active != null)
          ActiveBannerWidget(active),
        ...queue.map((item) => QueuedBannerWidget(item)),
      ],
    );
  },
)
Composition outlets are useful for showing “next up” indicators or carousels.

Multi-surface composition

Combine items from multiple surfaces:
PresentumOutlet$Composition2<
  CampaignItem,
  TipItem,
  AppSurface,
  CampaignVariant,
  TipVariant
>(
  surface1: AppSurface.homeTopBanner,
  surface2: AppSurface.homeTip,
  builder: (context, items1, items2) {
    final allItems = [...items1, ...items2];
    return ListView(
      children: allItems.map((item) => ItemWidget(item)).toList(),
    );
  },
)
For dialogs and overlays, use a popup host that watches a surface and shows dialogs:
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, bool pop = false}) async {
    await context.campaigns.markDismissed(entry);
    if (pop) {
      await Navigator.maybePop(context, true);
    }
  }

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

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

    if (!mounted) return;

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

    if (result == null || result == false) {
      await markDismissed(entry: entry);
    }
  }

  @override
  Widget build(BuildContext context) => widget.child;
}
See production popup host ->
Use the PresentumPopupSurfaceStateMixin for automatic dialog lifecycle management. It handles showing, dismissing, and queueing popups automatically.

Accessing Presentum

Inside outlets, access the Presentum instance via context:
// Short form
final presentum = context.presentum<CampaignItem, AppSurface, CampaignVariant>();

// Long form
final presentum = Presentum.of<CampaignItem, AppSurface, CampaignVariant>(context);

// Mark events
presentum.markShown(item);
presentum.markDismissed(item);
presentum.markConverted(item);

Inherited widgets

InheritedPresentumItem

Outlets automatically wrap builders with InheritedPresentumItem, giving child widgets access to the current item:
// Inside outlet builder
widget.builder(context, item)

// Wrapped automatically with:
InheritedPresentumItem<TItem, S, V>(
  item: item,
  child: widget.builder(context, item),
)
Access the item in descendants:
class MyCampaignButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Get item from ancestor outlet
    final item = context.presentumItem<CampaignItem, AppSurface, CampaignVariant>();

    return ElevatedButton(
      onPressed: () => context
          .presentum<CampaignItem, AppSurface, CampaignVariant>()
          .markConverted(item),
      child: const Text('Learn More'),
    );
  }
}
If you create custom observer widgets (not using PresentumOutlet), wrap descendants with InheritedPresentumItem so they can access the item:
class MyCustomObserver extends StatefulWidget {
  @override
  Widget build(BuildContext context) {
    final item = _getCurrentItem();

    // Wrap child so descendants can access item
    return InheritedPresentumItem(
      item: item,
      child: MyWidget(),
    );
  }
}
Alternatively, pass the item down manually if you prefer explicit props.

Outlet patterns

Conditional rendering

PresentumOutlet<CampaignItem, AppSurface, CampaignVariant>(
  surface: AppSurface.homeTopBanner,
  builder: (context, item) {
    return switch (item.variant) {
      CampaignVariant.banner => BannerWidget(item),
      CampaignVariant.inline => InlineWidget(item),
      CampaignVariant.dialog => DialogWidget(item),
    };
  },
)

With animations

PresentumOutlet<CampaignItem, AppSurface, CampaignVariant>(
  surface: AppSurface.homeTopBanner,
  builder: (context, item) {
    return TweenAnimationBuilder<double>(
      tween: Tween(begin: 0.0, end: 1.0),
      duration: const Duration(milliseconds: 300),
      builder: (context, value, child) {
        return Opacity(
          opacity: value,
          child: Transform.translate(
            offset: Offset(0, 20 * (1 - value)),
            child: child,
          ),
        );
      },
      child: BannerWidget(item),
    );
  },
)

Async loading

PresentumOutlet<CampaignItem, AppSurface, CampaignVariant>(
  surface: AppSurface.homeTopBanner,
  builder: (context, item) {
    final imageUrl = item.metadata['imageUrl'] as String?;

    return FutureBuilder<ui.Image>(
      future: _loadImage(imageUrl),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return BannerWithImage(item, snapshot.data!);
        }
        return const CircularProgressIndicator();
      },
    );
  },
)

Best practices

Don’t check eligibility, fetch data, or make decisions in outlets. That belongs in guards.Bad:
builder: (context, item) {
  if (userIsPremium) return SizedBox.shrink(); // ❌
  return BannerWidget(item);
}
Good:
// In guard
if (userIsPremium) return state; // Don't add item

// In outlet
builder: (context, item) => BannerWidget(item); // ✅
Outlets rebuild when state changes. Keep builders fast and simple.
// ✅ Good
builder: (context, item) => BannerWidget(item)

// ❌ Bad
builder: (context, item) {
  // Complex calculations, API calls, etc.
  final data = await fetchMoreData(); // Don't do this
  return ComplexWidget(data);
}
When you have complex widget hierarchies, use the inherited item instead of prop drilling:
builder: (context, item) {
  return ComplexLayout(
    header: Header(), // Can access item via context
    body: Body(),     // Can access item via context
    footer: Footer(), // Can access item via context
  );
}

Common patterns

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

  @override
  Widget build(BuildContext context) {
    return PresentumOutlet<CampaignItem, AppSurface, CampaignVariant>(
      surface: AppSurface.homeTopBanner,
      builder: (context, item) {
        return Container(
          padding: const EdgeInsets.all(16),
          decoration: BoxDecoration(
            gradient: LinearGradient(
              colors: [Colors.purple, Colors.blue],
            ),
            borderRadius: BorderRadius.circular(12),
          ),
          child: Row(
            children: [
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      item.metadata['title'] as String,
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 18,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      item.metadata['message'] as String,
                      style: const TextStyle(
                        color: Colors.white70,
                        fontSize: 14,
                      ),
                    ),
                  ],
                ),
              ),
              IconButton(
                icon: const Icon(Icons.close, color: Colors.white),
                onPressed: () => context
                    .presentum<CampaignItem, AppSurface, CampaignVariant>()
                    .markDismissed(item),
              ),
            ],
          ),
        );
      },
    );
  }
}

Card outlet with actions

PresentumOutlet<CampaignItem, AppSurface, CampaignVariant>(
  surface: AppSurface.profileAlert,
  builder: (context, item) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              item.metadata['title'] as String,
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            Text(item.metadata['description'] as String),
            const SizedBox(height: 16),
            Row(
              children: [
                TextButton(
                  onPressed: () => context
                      .presentum<CampaignItem, AppSurface, CampaignVariant>()
                      .markDismissed(item),
                  child: const Text('Dismiss'),
                ),
                const Spacer(),
                ElevatedButton(
                  onPressed: () => context
                      .presentum<CampaignItem, AppSurface, CampaignVariant>()
                      .markConverted(item),
                  child: const Text('Take Action'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  },
)

Next steps