Documentation Index
Fetch the complete documentation index at: https://docs.presentum.dev/llms.txt
Use this file to discover all available pages before exploring further.
Goal
When a user achieves a milestone (e.g. “7‑day streak”, “100 workouts”, “first purchase”), show a fullscreen congratulations dialog.
Key constraints:
- No imperative UI calls from business logic (your feature code never calls
showDialog)
- Fully declarative: reaching a milestone updates state; a popup host reacts to state changes
- Safe + testable: eligibility is in a guard, and “shown/dismissed” is tracked via storage
1) Surfaces + variants
enum AppSurface with PresentumSurface {
popup,
}
enum AppVariant with PresentumVisualVariant {
fullscreenCongrats,
}
2) Domain: milestone payload + option + item
@immutable
final class MilestoneOption extends PresentumOption<AppSurface, AppVariant> {
const MilestoneOption({
required this.surface,
required this.variant,
required this.isDismissible,
this.stage,
this.maxImpressions,
this.cooldownMinutes,
this.alwaysOnIfEligible = true,
});
@override
final AppSurface surface;
@override
final AppVariant variant;
@override
final int? stage;
@override
final int? maxImpressions;
@override
final int? cooldownMinutes;
@override
final bool alwaysOnIfEligible;
@override
final bool isDismissible;
}
@immutable
final class MilestonePayload extends PresentumPayload<AppSurface, AppVariant> {
const MilestonePayload({
required this.id,
required this.priority,
required this.metadata,
required this.options,
});
@override
final String id;
@override
final int priority;
@override
final Map<String, Object?> metadata;
@override
final List<PresentumOption<AppSurface, AppVariant>> options;
String get milestoneKey => metadata['milestoneKey'] as String;
String get title => metadata['title'] as String;
String get subtitle => metadata['subtitle'] as String;
}
@immutable
final class MilestoneItem
extends PresentumItem<MilestonePayload, AppSurface, AppVariant> {
const MilestoneItem({required this.payload, required this.option});
@override
final MilestonePayload payload;
@override
final PresentumOption<AppSurface, AppVariant> option;
}
3) “User progress store” (the only thing your app updates)
Your product logic can update this store from anywhere (BLoC, Riverpod, Provider, your own service).
final class UserProgressStore extends ChangeNotifier {
final Set<String> _reached = <String>{};
bool hasReached(String milestoneKey) => _reached.contains(milestoneKey);
void markReached(String milestoneKey) {
if (_reached.add(milestoneKey)) {
notifyListeners();
}
}
}
No dialogs. No navigation. Just state.
4) Provider: generate candidates from current app state
This provider maps “user progress” → candidate milestone items.
final class MilestoneProvider extends ChangeNotifier {
MilestoneProvider({
required this.engine,
required this.progress,
}) {
progress.addListener(_sync);
// Initial sync so already-reached milestones can show.
Future.microtask(_sync);
}
final PresentumEngine<MilestoneItem, AppSurface, AppVariant> engine;
final UserProgressStore progress;
static const _milestones = <MilestonePayload>[
MilestonePayload(
id: 'milestone:streak_7',
priority: 100,
metadata: {
'milestoneKey': 'streak_7',
'title': '7‑day streak!',
'subtitle': 'You’re building something great. Keep going.',
},
options: [
MilestoneOption(
surface: AppSurface.popup,
variant: AppVariant.fullscreenCongrats,
isDismissible: true,
stage: 0,
maxImpressions: 1,
cooldownMinutes: null,
alwaysOnIfEligible: true,
),
],
),
];
Future<void> _sync() async {
final candidates = <MilestoneItem>[];
for (final payload in _milestones) {
if (!progress.hasReached(payload.milestoneKey)) continue;
for (final opt in payload.options) {
candidates.add(MilestoneItem(payload: payload, option: opt));
}
}
await engine.setCandidatesWithDiff((state) => candidates);
notifyListeners();
}
@override
void dispose() {
progress.removeListener(_sync);
super.dispose();
}
}
5) Guard: “show once” + queue behavior
Here’s a simple rule:
- if it was dismissed (or shown once, depending on your policy), don’t show again
- otherwise schedule per surface by priority
final class MilestoneGuard
extends PresentumGuard<MilestoneItem, AppSurface, AppVariant> {
MilestoneGuard({required Listenable refresh}) : super(refresh: refresh);
@override
Future<PresentumState<MilestoneItem, AppSurface, AppVariant>> call(
PresentumStorage<AppSurface, AppVariant> storage,
history,
PresentumState$Mutable<MilestoneItem, AppSurface, AppVariant> state,
List<MilestoneItem> candidates,
context,
) async {
final eligible = <MilestoneItem>[];
for (final item in candidates) {
// Treat dismissal as “done”
final dismissedAt = await storage.getDismissedAt(
item.id,
surface: item.surface,
variant: item.variant,
);
if (dismissedAt != null) continue;
// Or treat “shown once” as “done”
final shown = await storage.getShownCount(
item.id,
period: const Duration(days: 3650),
surface: item.surface,
variant: item.variant,
);
if (item.option.maxImpressions != null && shown >= item.option.maxImpressions!) {
continue;
}
eligible.add(item);
}
// Schedule: popup surface gets items by priority (active + queue)
eligible.sort((a, b) => b.priority.compareTo(a.priority));
state.clearSurface(AppSurface.popup);
state.addAll(AppSurface.popup, eligible);
return state;
}
}
Use refresh so the guard re-runs whenever progress changes:
final guard = MilestoneGuard(refresh: progress);
Your business logic never calls showDialog. The host watches AppSurface.popup and does it automatically.
This recipe uses the built-in PresentumPopupSurfaceStateMixin described in Popup hosts.
class MilestonePopupHost extends StatefulWidget {
const MilestonePopupHost({required this.child, super.key});
final Widget child;
@override
State<MilestonePopupHost> createState() => _MilestonePopupHostState();
}
class _MilestonePopupHostState extends State<MilestonePopupHost>
with PresentumPopupSurfaceStateMixin<
MilestoneItem,
AppSurface,
AppVariant,
MilestonePopupHost
> {
@override
AppSurface get surface => AppSurface.popup;
@override
Future<void> markDismissed({required MilestoneItem entry, bool pop = false}) async {
await context.presentum<MilestoneItem, AppSurface, AppVariant>().markDismissed(entry);
if (pop && mounted) {
await Navigator.maybePop(context, true);
}
}
@override
Future<void> present(MilestoneItem entry) async {
if (!mounted) return;
final presentum = context.presentum<MilestoneItem, AppSurface, AppVariant>();
await presentum.markShown(entry);
if (!mounted) return;
final result = await showDialog<bool?>(
context: context,
barrierDismissible: false,
builder: (context) => InheritedPresentum.value(
value: presentum,
child: InheritedPresentumItem(
item: entry,
child: const MilestoneFullscreenDialog(),
),
),
);
// If user closed without converting, treat as dismissed.
if (result != true) {
await markDismissed(entry: entry);
}
}
@override
Widget build(BuildContext context) => widget.child;
}
class MilestoneFullscreenDialog extends StatelessWidget {
const MilestoneFullscreenDialog({super.key});
@override
Widget build(BuildContext context) {
final item = context.presentumItem<MilestoneItem, AppSurface, AppVariant>();
return Dialog.fullscreen(
child: Column(
children: [
Text(item.payload.title),
Text(item.payload.subtitle),
Row(
children: [
if (item.option.isDismissible)
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Not now'),
),
ElevatedButton(
onPressed: () async {
await context
.presentum<MilestoneItem, AppSurface, AppVariant>()
.markConverted(item, conversionMetadata: {
'milestoneKey': item.payload.milestoneKey,
});
if (context.mounted) Navigator.pop(context, true);
},
child: const Text('Nice!'),
),
],
),
],
),
);
}
}
Why this pattern scales
- Feature code is pure state: update
progress.markReached(...)
- Eligibility is centralized: show-once, cooldowns, queueing, priorities live in guards
- UI is reactive: popup host reacts to Presentum state, so you don’t sprinkle dialog logic through the app