dialog_controller 1.0.0
dialog_controller: ^1.0.0 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.DialogScopeprovides a controller to descendants.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.
- Uses a simple one-overlay-per-controller model.
- 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.',
),
);
final DialogRequestHandle handle = controller.enqueue(
const DialogRequest(
id: 'network-alert-follow-up',
priority: 80,
payload: 'Retry connection?',
),
);
controller.dismiss(handle);
Higher priority values win. Requests with equal priority keep insertion order.
Each queued dialog must have a unique DialogRequest.id within its controller.
All enqueue methods throw ArgumentError if the ID is already queued, including
when the same request object is enqueued twice. Requests are never silently
replaced or deduplicated. Replacing a specific request also rejects an ID used
by another entry without changing the queue or pending outcomes.
An ID can be reused after removal, and separate controllers can use the same ID.
Use distinct IDs when queueing multiple instances of the same dialog, such as
'network-alert-$sequence'. Priority is the only ordering input; higher numbers
appear first, including when priorities are negative. Equal priorities retain
insertion order. There is no custom ordering strategy or ordering metadata.
Retain the DialogRequestHandle returned by enqueue() (or the handle on
enqueueForOutcomeWithHandle<T>()'s return value) for targeted dismissal on the
same controller. Handles identify a particular enqueue operation, so a stale
callback cannot dismiss a new dialog that reuses a removed ID. Dismissing a stale
handle returns false. Dialog widgets can simply use the host's dismiss
callback, which captures the correct handle automatically.
1.1 Replace a specific request #
final request = DialogRequest(id: 'sync-status', payload: 'Syncing...');
final handle = controller.enqueue(request);
final bool replaced = controller.replace(
handle,
request.copyWith(payload: 'Sync complete.', priority: 10),
);
replace(handle, request) updates only the targeted entry. It preserves the
handle, any pending outcome future, and the original insertion order for priority
ties. Changing priority immediately reorders the queue. The replacement may keep
its ID or use a new unique ID; a renamed entry releases its previous ID.
The method returns false for a stale handle and throws ArgumentError if the
new ID belongs to another queued entry. Other requests remain unchanged. Use the
controller that created the handle. Bulk queue replacement is not supported.
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);
},
),
);
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.
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.- 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,
});
final Widget child;
final DialogWidgetBuilder builder;
final DialogController? controller;
@override
Widget build(BuildContext context) {
// Alternative: Inject with GetX, Riverpod, or other providers
final DialogController resolvedController =
controller ?? DialogScope.controllerOf(context);
return AnimatedBuilder(
animation: resolvedController,
builder: (context, _) {
final DialogRequest? request = resolvedController.current();
final DialogRequestHandle? handle = resolvedController.currentHandle();
return Stack(
children: [
child,
if (request != null && handle != null)
Positioned.fill(
child: ColoredBox(
color: const Color(0x66000000),
child: Center(
child: builder(
context,
request,
([outcome]) => resolvedController.dismissWithOutcome(
handle,
outcome,
),
),
),
),
),
],
);
},
);
}
}
Example app #
The repository includes a full example application under example/ showing:
- global dialogs
- custom widget dialogs with outcomes
- Riverpod-provided
DialogControllerusage - Flutter BLoC integration via listener-driven requests
- interactive priority control dialogs