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
Cross-universe composition : Combines items from two separate Presentum instances:
Campaign presentations (CampaignPresentumItem)
Feature flag presentations (FeatureItem)
Priority resolution : The resolver merges both lists and sorts by priority, selecting only the highest-priority item to display
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
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);
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
No business logic in outlets
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); // ✅
Keep builders lightweight
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);
}
Use InheritedPresentumItem for deep trees
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
Banner outlet
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