dialog_controller 0.1.1
dialog_controller: ^0.1.1 copied to clipboard
A Flutter package for managing complex Dialog user flows
dialog_controller #
An out-of-the-box Dialog system for apps with complex dialog flows.
This package separates dialog coordination from dialog rendering:
DialogControllermanages queueing, priority, and dismissal.DialogHostrenders the active dialog layer in the widget tree.DialogScopeisolates dialog traffic for specific subtrees.DialogRequest.widgetBuilderlets each feature provide a custom dialog widget.enqueueForOutcome<T>()returns a formal typed outcome model.
The package uses only first-party Flutter and Dart dependencies for portability.
Table of Contents #
Why use this package #
- Avoids
showDialogandNavigator.poplimitations. - Supports multiple dialogs with deterministic ordering.
- Supports subtree scoping so independent areas of UI can host their own dialogs.
- Exposes interfaces (
ChangeNotifier, callbacks, payload data) that are easy to bridge into Riverpod, BLoC, or custom state management systems.
Installation #
Add the dependency:
dependencies:
dialog_controller: ^0.1.1
Core Concepts #
1. Queue requests with priority #
final controller = DialogController();
controller.enqueue(
const DialogRequest(
id: 'network-alert',
priority: 100,
payload: 'Connection lost.',
),
);
Higher priority values win. Requests with equal priority keep insertion order.
2. Render dialogs through a host #
MaterialApp(
home: DialogScope(
controller: controller,
child: DialogHost(
builder: (context, request, dismiss) {
return AlertDialog(
title: const Text('Dialog'),
content: Text('${request.payload}'),
actions: [
TextButton(onPressed: dismiss, child: const Text('Close')),
],
);
},
child: const HomeScreen(),
),
),
);
The host-level builder is a default builder for the Dialog. Any request can override it by providing widgetBuilder. The DialogHost manages the overlay.
2.1 Queue a feature-owned custom widget #
controller.enqueue(
DialogRequest(
id: 'billing-upgrade',
priority: 50,
widgetBuilder: (context, request, dismiss) {
return UpgradeDialog(onDismiss: dismiss);
},
),
);
This keeps architecture boundaries intact:
DialogControllerstill handles ordering, scoping, and lifecycle.- Feature modules own their dialog widget composition.
DialogHostremains the single rendering layer entrypoint.
2.2 Await dialog outcomes #
final DialogOutcome<bool> outcome = await controller.enqueueForOutcome<bool>(
DialogRequest(
id: 'delete-confirmation',
widgetBuilder: (context, request, dismiss) {
return AlertDialog(
title: const Text('Delete item?'),
actions: [
TextButton(
onPressed: () => dismiss(const DialogOutcome<Object?>.cancelled(false)),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => dismiss(const DialogOutcome<Object?>.confirmed(true)),
child: const Text('Delete'),
),
],
);
},
),
);
if (outcome.isConfirmed && outcome.value == true) {
// Proceed with deletion.
}
Use dismiss() without an argument for a neutral close. The returned type is
DialogOutcomeType.dismissed.
enqueueForResult<T>() is still available if you only need the payload value
without outcome metadata.
3. Scope dialogs to a region #
DialogScope(
controller: controller,
scopeId: 'details-panel',
child: DialogHost(
scopeId: 'details-panel',
builder: dialogBuilder,
child: const DetailsPanel(),
),
)
Only requests whose scopeId matches details-panel render in that host.
Custom ordering strategy #
If you need behavior other than priority-first ordering, provide orderStrategy.
Use orderingData to attach domain-specific comparison metadata without
overloading payload:
class TicketDialogOrder {
const TicketDialogOrder(this.rank);
final int rank;
}
final controller = DialogController(
orderStrategy: (a, b) {
final TicketDialogOrder left = a.orderingData! as TicketDialogOrder;
final TicketDialogOrder right = b.orderingData! as TicketDialogOrder;
return left.rank.compareTo(right.rank);
},
);
controller.enqueue(
DialogRequest(
id: 'billing-warning',
orderingData: const TicketDialogOrder(20),
payload: 'Billing warning',
),
);
controller.enqueue(
DialogRequest(
id: 'security-warning',
orderingData: const TicketDialogOrder(5),
payload: 'Security warning',
),
);
In this example, security-warning appears before billing-warning because
its domain rank is lower.
If your strategy only needs the built-in fields, you can still compare those directly:
final controller = DialogController(
orderStrategy: (a, b) => a.priority.compareTo(b.priority),
);
Custom overlay #
DialogHost is the default overlay implementation, but you can build your own
overlay widget if you want different transitions, hit testing, layering, or
placement behavior.
The package responsibilities stay the same:
DialogControllerdecides which request is active.DialogScopeprovides the controller and optionalscopeId.- Your custom overlay decides how to render and dismiss the active request.
Minimal pattern:
class CustomDialogOverlay extends StatelessWidget {
const CustomDialogOverlay({
super.key,
required this.child,
required this.builder,
this.controller,
this.scopeId,
});
final Widget child;
final DialogWidgetBuilder builder;
final DialogController? controller;
final String? scopeId;
@override
Widget build(BuildContext context) {
final DialogController resolvedController =
controller ?? DialogScope.controllerOf(context);
final String? resolvedScopeId =
scopeId ?? DialogScope.scopeIdOf(context);
return AnimatedBuilder(
animation: resolvedController,
builder: (context, _) {
final DialogRequest? request =
resolvedController.current(scopeId: resolvedScopeId);
return Stack(
children: [
child,
if (request != null)
Positioned.fill(
child: ColoredBox(
color: const Color(0x66000000),
child: Center(
child: builder(
context,
request,
([outcome]) => resolvedController.dismissWithOutcome(
request.id,
outcome,
),
),
),
),
),
],
);
},
);
}
}
Preserve these behaviors when building your own overlay:
- read the active request with
controller.current(scopeId: ...) - dismiss through
dismissWithOutcome(...)so typed outcomes still resolve - respect
scopeIdif your app uses multiple dialog regions - support
request.widgetBuildersemantics if you want feature-owned dialog widgets to keep working
Example app #
The repository includes a full example application under example/ showing:
- global dialogs
- scoped dialogs
- custom widget dialogs with outcomes
- Riverpod-provided
DialogControllerusage - Flutter BLoC integration via listener-driven requests
- interactive priority control dialogs