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);
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