dialog_dock 0.1.3
dialog_dock: ^0.1.3 copied to clipboard
Minimize Flutter dialogs into a floating holder instead of closing them, then restore them with their state intact. A drop-in for showDialog.
dialog_dock #
Minimize Flutter dialogs instead of closing them. A minimized dialog becomes a small bubble in the corner of the app; tapping it brings the dialog back exactly as it was: typed text, scroll position, open tabs, everything.
It's a drop-in for showDialog: your existing dialog widgets work unchanged.



- Minimize, pin, maximize and close buttons on every window: macOS traffic lights by default, with Dock-style hover magnification
- Bubbles in a collapsible holder; pinned windows on the screen edge
- macOS-style Genie minimize, and Hero-style open/close from the tapped widget
- Optional macOS "Liquid Glass" windows, holder bar and dock
Navigator.pop(context, result)returns the result, likeshowDialog- Dialogs opened from a window stay above it
- Back (Android, browser) and Esc dismiss the window, never the page below
- Keyboard focus stays in the window and returns to the app afterwards
- Memory limit and lifetime for minimized windows, with optional state saving
- Custom colors, icons (including SVG), header buttons and window design
- Light / dark themes, RTL, phones, tablets and desktop; all platforms
Getting started #
1. Install #
flutter pub add dialog_dock
and import it:
import 'package:dialog_dock/dialog_dock.dart';
2. Wrap your app once #
Put FloatingDialogHolder in MaterialApp.builder (or CupertinoApp /
WidgetsApp), so windows float above every page:
MaterialApp(
builder: (context, child) => FloatingDialogHolder(child: child!),
home: const HomePage(),
);
There is no controller, provider or state management to set up.
3. Show a dialog #
Use showFloatingDialog where you used showDialog. Your dialog widget stays
the same:
final confirmed = await showFloatingDialog<bool>(
context: context,
id: 'delete-item', // one window per id
title: 'Delete item', // window title and bubble label
icon: Icons.delete_outline, // shown on the bubble
builder: (context) => AlertDialog(
content: const Text('Delete this item?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
),
);
// true / false from Navigator.pop, or null when closed another way.
A complete app #
import 'package:dialog_dock/dialog_dock.dart';
import 'package:flutter/material.dart';
void main() => runApp(
MaterialApp(
builder: (context, child) => FloatingDialogHolder(child: child!),
home: const HomePage(),
),
);
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: FilledButton(
onPressed: () => showFloatingDialog<void>(
context: context,
id: 'notes',
title: 'Notes',
icon: Icons.edit_note,
builder: (context) => const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: TextField(
maxLines: null,
decoration: InputDecoration(
hintText: 'Type, minimize, come back...',
),
),
),
),
child: const Text('Open notes'),
),
),
);
}
}
Type something, press the yellow minimize button, then tap the bubble in the corner: the text is still there.
What your users get #
- Minimize (yellow): the window pours into a bubble in the corner.
- Pin (blue): minimized pinned windows sit on the screen edge instead.
- Maximize (green): a larger window; press again to restore.
- Close (red), tap outside, Back or Esc: closes it. A window that was minimized before, or is pinned, goes back to its bubble instead.
- Tap a bubble: the window comes back exactly as it was.
- Drag the holder anywhere on the screen. It stays there while the app
runs and goes back to its corner after a restart
(
holderDraggable: falseturns this off).
showFloatingDialog parameters #
| Parameter | What it does |
|---|---|
context, id, title, builder |
Required. One window per id; opening it again brings that window back. |
icon / iconBuilder |
Bubble icon: an IconData, or any widget (SVG, image). |
headerActions |
Extra title-bar buttons, each with its own onPressed. |
windowButtons |
Which built-in buttons to show (minimize, pin, maximize, close). |
size |
Window size as a fraction of the screen. |
showFrame |
false gives the dialog the whole window (draw your own header). |
frameBuilder |
Draw this window's chrome yourself. |
originKey |
GlobalKey of the tapped widget: open from it and close back into it. |
canPause |
Return false to refuse minimizing right now. |
isAvailable |
Permission check before every restore. |
isRestorable |
Bring the bubble back after an app restart (see below). |
Configure the look once #
Pass a FloatingDialogConfig to the holder; every field is optional:
FloatingDialogHolder(
config: const FloatingDialogConfig(
liquidGlass: true, // macOS glass
windowButtonStyle: FloatingDialogWindowButtonStyle.trafficLights,
minimizeEffect: FloatingDialogMinimizeEffect.genie, // or scale / fade
closeEffect: FloatingDialogCloseEffect.slideDown, // or fade
maxMountedWindows: 10, // memory limit
holderBottomOffset: 72, // clear a bottom bar
),
child: child!,
);
The holder reads its config once. To change it while the app runs, give the
holder a new key; this closes open windows.
The example app shows every feature with live switches for the look.
How it behaves #
| State | Dialog in memory | Shown as |
|---|---|---|
| Open | yes | the window, above a barrier |
| Minimized | yes, paused (animations stopped, no focus) | a bubble |
| Evicted (lifetime ended or memory limit) | no | a greyed bubble; tap reopens it fresh |
| Closed | no | removed |
- One window per id. Opening an id that is already minimized brings that window back and returns the same result future.
- One open window at a time. Opening another while one is open is refused, and the user is asked to minimize or close the first.
- Tapping outside, Back or Esc closes a new window: it drops towards the bottom of the screen. A window that was minimized before, or is pinned, goes back to the holder (pinned: to the edge dock) instead.
- The result completes with the value given to
Navigator.pop, ornullwhen closed otherwise. Minimizing and eviction don't complete it. - Without a
FloatingDialogHolderabove the context,showFloatingDialogfalls back to a normalshowDialog.
Existing dialogs #
Dialog widgets work unchanged inside a window:
Navigator.pop(context, value)closes the window with that value.showDialogfrom inside the window opens above it; Back closes it first.AlertDialog/Dialogblend into the window (no second card).FloatingDialogFrameshows only its body inside a window, so the same widget can also be used withshowDialog.
Minimized windows stay mounted. If a dialog listens to app-wide state, skip events while it's minimized:
if (!isFloatingDialogActive(context)) return;
Dialog routes don't see providers scoped to a page, and the same is true
here. Pass blocs in, as you would for showDialog:
final cubit = context.read<InvoiceCubit>();
showFloatingDialog(
context: context,
id: 'invoice',
title: 'Invoice',
builder: (_) => BlocProvider.value(value: cubit, child: const InvoiceDialog()),
);
Refusing to minimize #
showFloatingDialog(
...,
canPause: () => !payment.isProcessing,
);
// Or from inside the dialog, based on its own state:
FloatingDialogPauseGuard(
canPause: () => !cubit.state.isSaving,
child: content,
);
Animations #
Windows animate like macOS:
- Minimize pours the window into its bubble with the Dock's Genie
effect; restore plays it backwards. Choose
scale(the Dock's Scale effect) orfadeinstead:FloatingDialogConfig(minimizeEffect: FloatingDialogMinimizeEffect.scale). - Open zooms in; close drops towards the bottom center of the
screen while shrinking and fading. For a fade in place instead:
FloatingDialogConfig(closeEffect: FloatingDialogCloseEffect.fade). - Like a Hero: give the tapped widget a
GlobalKeyand pass it asoriginKey. The window grows out of that widget and shrinks back into it when closed.
final photoKey = GlobalKey();
GestureDetector(
onTap: () => showFloatingDialog(
context: context,
id: 'photo-1',
title: 'Photo',
originKey: photoKey,
builder: (_) => Image.asset('assets/photo.jpg'),
),
child: Image.asset('assets/photo.jpg', key: photoKey, width: 96),
);
Durations: minimizeEffectDuration, openEffectDuration,
closeEffectDuration (zero turns one off). All effects are skipped when
the platform asks to reduce motion.
Memory and eviction #
Minimized, unpinned windows are evicted (disposed, bubble greyed) when:
- their lifetime ends (default 15 minutes; users can change it from the holder's settings button), or
- more than
maxMountedWindows(default 10) are minimized. The least recently minimized one goes first.
Pinned windows are never evicted. To keep content across eviction, wrap it
in a FloatingDialogStateKeeper (memory only, nothing is written to disk):
FloatingDialogStateKeeper<String>(
onSave: () => _note.text,
onRestore: (text) => _note.text = text,
child: TextField(controller: _note),
)
Customization #
Icons (IconData, SVG, images) #
showFloatingDialog(
...,
iconBuilder: (context, color, size) => SvgPicture.asset(
'assets/treasury.svg',
width: size,
height: size,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
),
);
Liquid Glass #
FloatingDialogHolder(
config: const FloatingDialogConfig(liquidGlass: true),
child: child!,
);
Windows, the holder bar and the dock become macOS-style frosted glass with a light rim; the header blends into the window.
Window buttons #
By default the built-in buttons are macOS traffic lights on the leading side (close, minimize, maximize, pin) with a centered title. Hovering magnifies them like the Dock.
const FloatingDialogConfig(
windowButtonStyle: FloatingDialogWindowButtonStyle.tonal, // or plain
windowButtonsPlacement: FloatingDialogWindowButtonsPlacement.trailing,
windowButtonSize: 18,
windowButtonHoverScale: 1.4,
headerHeight: 56,
// A background for every built-in button:
windowButtonDecoration: BoxDecoration(color: Colors.indigo, shape: BoxShape.circle),
// Or replace a button completely (shown as is, no decoration):
windowButtonIcons: FloatingDialogWindowButtonIcons(
minimize: MyMinimizeSvg(),
close: MyCloseSvg(),
),
);
Header actions take their own decoration, which replaces the style's:
FloatingDialogHeaderAction(
tooltip: 'Print',
icon: Icons.print_outlined,
decoration: BoxDecoration(color: Colors.teal.shade100, shape: BoxShape.circle),
onPressed: (context) => printInvoice(),
);
Extra header buttons, and which built-in ones to show #
showFloatingDialog(
...,
headerActions: [
FloatingDialogHeaderAction(
tooltip: 'Print',
icon: Icons.print_outlined,
onPressed: (context) => printInvoice(),
),
],
windowButtons: {
FloatingDialogWindowButton.minimize,
FloatingDialogWindowButton.close,
},
);
Colors #
Every color defaults to your ColorScheme. Override some for all themes:
FloatingDialogHolder(
config: const FloatingDialogConfig(
colors: FloatingDialogColors(headerBackground: Colors.teal),
),
child: child!,
);
Or per theme, as a theme extension:
ThemeData(extensions: const [
FloatingDialogColors(item: Colors.orange, barrier: Colors.black38),
]);
Available: headerBackground, headerForeground, windowBackground,
barrier, holderBackground, holderBorder, holderShadow, item,
dimmedItem, badgeBackground, badgeForeground, dockIndicator,
dockClose.
Window design #
Simple tweaks go in the config: headerTextStyle, headerHeight,
frameShape, frameRadius, headerPadding, windowElevation and sizes. To draw your own
header, shape or background, use a frame builder, either for every window
(FloatingDialogConfig.windowFrameBuilder) or for one dialog:
showFloatingDialog(
...,
frameBuilder: (context, frame) => Card(
clipBehavior: Clip.antiAlias,
child: Column(children: [
ListTile(title: Text(frame.title), trailing: frame.buttons),
Expanded(child: frame.body),
]),
),
);
frame.buttons holds your header actions and the window buttons. Set
showFrame: false to give the dialog the whole window, and put
FloatingDialogWindowActions() in its own header.
Everything else #
Pinned windows sit on the screen edge as a thin indicator that expands on
hover or tap, on every device. To always show them:
FloatingDialogConfig(dockAlwaysExpanded: true).
FloatingDialogConfig holds sizes (per device), holder placement
(holderBottomOffset to clear a bottom bar), animation timings, lifetime
presets, the memory limit, breakpoints and keyboard shortcuts
(dismissShortcut = Esc, minimizeShortcut = Ctrl+M).
Texts and localization #
FloatingDialogHolder(
host: FloatingDialogHostDelegate(
strings: (context) => FloatingDialogStrings(
minimize: context.l10n.minimize,
// ...
),
),
child: child!,
);
FloatingDialogHostDelegate also takes showMessage (defaults to a
SnackBar) and deviceType (defaults to width breakpoints).
Keeping bubbles across restarts (optional) #
Off by default. To bring minimized windows back (greyed) after a restart,
give the holder storage and a user, mark dialogs isRestorable: true, and
register them on start so the holder knows how to rebuild them:
final prefs = await SharedPreferences.getInstance();
FloatingDialogHolder(
host: FloatingDialogHostDelegate(
storage: KeyValueFloatingDialogStorage(
readString: prefs.getString,
writeString: prefs.setString,
),
userId: () => auth.currentUser?.id, // null disables saving
),
child: child!,
);
// After login, with the dialogs this user may open:
FloatingDialogHolder.of(context).registerActions([
FloatingDialogAction(
id: 'treasury',
title: 'Treasury',
icon: Icons.account_balance_outlined,
isRestorable: true,
builder: (_) => const TreasuryDialog(),
),
]);
Only ids, flags and times are stored, never dialog content.
Permissions #
isAvailable is checked before every restore. Pass availabilityChanges
(any Listenable) to re-check every window when permissions change; windows
that lost access are closed:
FloatingDialogHolder(availabilityChanges: permissions, child: child!);
showFloatingDialog(..., isAvailable: () => permissions.can('treasury'));
Advanced #
FloatingDialogHolder.of(context)returns the controller:open,restore,minimize,closeDialog(id, result),togglePin,toggleSize,setLifetime,registerActions.- Pass
controller:to create and own it yourself (tests, DI). - One holder per app: a
FloatingDialogHolderplaced inside another one (say, around a single page) reuses the outer one, so users never see two bars and windows opened from that page survive leaving it. Without an app-wide holder, a holder around one page keeps its windows to that page (they close when the page closes). - Pass
navigatorKey:if the holder can't find your app's navigator.
Platform notes #
Works on Android, iOS, web, Windows, macOS and Linux. Minimized windows stay
mounted, so they keep their memory until evicted; tune maxMountedWindows
for heavy dialogs.
