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(),
    );
  },
)

Cross-presentum composition with animations

PresentumOutlet$Composition2 supports combining items from different Presentum instances (cross-universe composition), allowing seamless coordination between multiple presentation systems with smooth animated transitions.

Production example

Here’s a real-world outlet that combines campaigns and feature flags, showing the highest-priority item with smooth fade and size transitions:
class BannerOutlet extends StatelessWidget {
  const BannerOutlet({super.key});

  @override
  Widget build(BuildContext context) {
    return PresentumOutlet$Composition2<
      CampaignPresentumItem,
      FeatureItem,
      CampaignSurface,
      CampaignVariant,
      AppSurface,
      AppVariant
    >(
      surface1: CampaignSurface.homeTopBanner,
      surface2: AppSurface.homeHeader,
      resolverMode: OutletGroupMode.custom,
      resolver: (campaignItems, featureItems) {
        // Combine items from both Presentum instances
        final allItems = <PresentumItem>[...campaignItems, ...featureItems]
          ..sort((a, b) => b.priority.compareTo(a.priority));

        if (allItems.isEmpty) {
          return <PresentumItem>[];
        }

        // Show only the highest-priority item
        return [allItems.first];
      },
      compositeBuilder: (context, items) {
        return FadeSizeTransitionSwitcher(
          isForwardMove: true,
          child: switch (items.firstOrNull) {
            CampaignPresentumItem(:final surface) => CampaignOutlet(
              key: ValueKey('campaign_banner_$surface'),
              surface: surface,
              padding: const EdgeInsets.symmetric(vertical: 16),
            ),
            FeatureItem() => const NewYearBanner(
              key: ValueKey('new_year_banner'),
              padding: EdgeInsets.symmetric(vertical: 16),
            ),
            _ => const SizedBox.shrink(key: ValueKey('empty_banner')),
          },
        );
      },
    );
  }
}

How it works

  1. Cross-universe composition: Combines items from two separate Presentum instances:
    • Campaign presentations (CampaignPresentumItem)
    • Feature flag presentations (FeatureItem)
  2. Priority resolution: The resolver merges both lists and sorts by priority, selecting only the highest-priority item to display
  3. Animated transitions: FadeSizeTransitionSwitcher provides smooth animations when:
    • A campaign is dismissed and a feature banner appears
    • A feature is disabled and a campaign takes its place
    • Items change priority dynamically

Smooth transition widget

The FadeSizeTransitionSwitcher combines fade and size animations:
class FadeSizeTransitionSwitcher extends StatelessWidget {
  const FadeSizeTransitionSwitcher({
    required this.child,
    this.sizeDuration = const Duration(milliseconds: 300),
    this.fadeDuration = const Duration(milliseconds: 300),
    this.isForwardMove = true,
    super.key,
  });

  final Widget child;
  final Duration sizeDuration;
  final Duration fadeDuration;
  final bool isForwardMove;

  @override
  Widget build(BuildContext context) {
    return AnimatedSize(
      alignment: Alignment.topLeft,
      duration: sizeDuration,
      curve: Curves.fastOutSlowIn,
      child: AnimatedSwitcher(
        duration: fadeDuration,
        layoutBuilder: (currentChild, previousChildren) {
          return Stack(
            clipBehavior: Clip.none,
            children: [
              // Stack previous children during transition
              ...previousChildren.map(
                (child) => Positioned(left: 0, right: 0, child: child),
              ),
              if (currentChild != null) currentChild,
            ],
          );
        },
        transitionBuilder: (child, animation) {
          final isIncoming = child.key == this.child.key;

          if (isIncoming) {
            // Fade in new content
            return FadeTransition(opacity: animation, child: child);
          } else {
            // Fade out old content
            return FadeTransition(
              opacity: Tween(begin: 1.0, end: 0.0).animate(animation),
              child: child,
            );
          }
        },
        child: child,
      ),
    );
  }
}

Key benefits

Seamless coordination

Multiple Presentum instances can compete for the same UI slot without conflicts. The outlet handles priority resolution automatically.

Smooth transitions

When one banner is dismissed, the next highest-priority item smoothly animates in with fade and size transitions.

Declarative priority

Priority logic is centralized in the resolver. Guards don’t need to know about other presentation types.

Type-safe composition

Despite combining different item types, the outlet remains type-safe with pattern matching.
Use unique ValueKeys for each widget variant to ensure AnimatedSwitcher correctly animates between different content types.
See full production example → 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
        PresentumActiveSurfaceItemObserverMixin<
          CampaignPresentumItem,
          CampaignSurface,
          CampaignVariant,
          CampaignPopupHost
        >,
        PresentumPopupSurfaceStateMixin<
          CampaignPresentumItem,
          CampaignSurface,
          CampaignVariant,
          CampaignPopupHost
        > {

  @override
  CampaignSurface get surface => CampaignSurface.popup;

  @override
  bool get ignoreDuplicates => true;

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

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

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

    if (!mounted) return PopupPresentResult.notPresented;

    final factory = campaignsPresentationWidgetFactory;

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

    // Result is true if dismissed by user, null if dismissed by system
    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;
  }
}
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