dialog_controller 0.1.1 copy "dialog_controller: ^0.1.1" to clipboard
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:

  • DialogController manages queueing, priority, and dismissal.
  • DialogHost renders the active dialog layer in the widget tree.
  • DialogScope isolates dialog traffic for specific subtrees.
  • DialogRequest.widgetBuilder lets 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 showDialog and Navigator.pop limitations.
  • 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:

  • DialogController still handles ordering, scoping, and lifecycle.
  • Feature modules own their dialog widget composition.
  • DialogHost remains 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:

  • DialogController decides which request is active.
  • DialogScope provides the controller and optional scopeId.
  • 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 scopeId if your app uses multiple dialog regions
  • support request.widgetBuilder semantics 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 DialogController usage
  • Flutter BLoC integration via listener-driven requests
  • interactive priority control dialogs
1
likes
160
points
147
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter package for managing complex Dialog user flows

Repository (GitHub)
View/report issues
Contributing

Topics

#flutter #dialogs #overlays #state-management #navigation

License

BSD-3-Clause (license)

Dependencies

flutter

More

Packages that depend on dialog_controller