just_sonner 0.2.0
just_sonner: ^0.2.0 copied to clipboard
Stacking toasts for Flutter desktop in the spirit of sonner: a deck that fans out on hover, toasts updated in place, promise toasts, no BuildContext.
just_sonner #
Stacking toasts for Flutter desktop, in the spirit of sonner.
Toasts pile up instead of replacing each other. They collapse into a deck and fan out when the
pointer comes over it, pausing while it stays. A toast can change in place, so a loading toast
becomes its result without a second one. You call it from anywhere, with no BuildContext, and it
ships a default look that a builder can replace whole.
toast.showreturns an id;update,dismiss,dismissAllandpromisetake it from there- A deck that fans out on hover, with a cap, a scrollbar, and controls to dismiss or hide it all
- Swipe to dismiss, a close button, and an action slot you fill
- Each toast's time left, drawn as a border, a bar or a ring
- No dependency beyond Flutter
Desktop only, for now: Windows, macOS and Linux. The package builds for mobile and web, but it makes no promises there: nothing is specified or tested for touch, notches or the keyboard.
Quick start #
import 'package:flutter/material.dart';
import 'package:just_sonner/just_sonner.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
builder: (context, child) => SonnerHost(child: child!),
home: Scaffold(
body: Center(
child: FilledButton(
onPressed: () =>
toast.show('Saved', description: 'Your changes are stored.'),
child: const Text('Save'),
),
),
),
);
}
toast is the package's default SonnerController. It holds the toasts, their ids and their
timers, and no widgets, so any code can call it.
Two ways to mount #
Wrap the app (above): SonnerHost in MaterialApp.builder sits above the app's Navigator
for the life of the app.
Or attach to the root overlay: give the app a navigator key and attach the controller to it. The toasts are drawn in the root navigator's overlay, above its dialogs and pages, from the first toast shown.
final navigatorKey = GlobalKey<NavigatorState>();
void main() {
toast.attach(navigatorKey);
runApp(MaterialApp(navigatorKey: navigatorKey, home: const HomePage()));
}
attach can be called before the app is built. Once attached, a toast shown with no navigator to
draw in is an error in debug and dropped in release.
The difference matters for the widgets you put in a toast. Wrapped, the toasts sit above the
Navigator, so Navigator.of(context) finds no navigator there, and a Tooltip or anything else
that looks for an Overlay throws "No Overlay widget found". Attached, they sit inside the root
overlay and find both.
Showing, updating and dismissing #
final id = toast.show('Uploading…', isLoading: true);
// Only the fields passed change. The toast keeps its place and counts down again.
toast.update(
id,
title: 'Uploaded',
isLoading: false,
duration: const Duration(seconds: 3),
);
toast.dismiss(id);
toast.dismissAll();
- Duration. A toast stays
config.duration, 4 s by default, or thedurationit is shown with.Duration.zerokeeps it until it is dismissed. - Loading. A toast with
isLoading: truehas no timer, and draws a spinner in its leading slot. Give it a duration when it stops loading, as above. - Update and replace.
updatechanges the fields passed and keeps the rest.showat the id of a toast on screen replaces it: the content is taken whole, every field not given goes back to its default, and the toast keeps its place. Both count down again.updatereturns false when no toast with that id is on screen. - Your own ids.
ToastId('connection')names a toast you can reach again without keeping the idshowreturned. Ids are equal when their values are. - Leading.
leading:takes any widget, such as an icon, drawn in a box ofconfig.leadingSize.
promise #
One toast for the whole of a future: loading at once, then its result or its error.
import 'dart:async'; // unawaited
unawaited(
toast
.promise(
saveDocument(),
loading: const ToastContent('Saving…'),
success: (_) => const ToastContent('Saved'),
error: (error) => ToastContent('Could not save', description: '$error'),
)
.then<void>((_) {}, onError: (Object _) {}),
);
promise hands the future's own value back to the caller, and rethrows its own error
unchanged. So a call nobody awaits, such as one in an onPressed, needs its error handled, or it
reaches the zone as an unhandled error. The handling above is the one to copy. Do not use
.catchError((Object _) => null as T): it analyses clean and throws a TypeError for any
non-nullable T, exactly when the error path runs. Awaiting it in a try / catch works too.
- Nothing the toast does changes what the caller gets. A state that cannot be shown, or a
successorerrorcallback that throws, is reported throughFlutterError.reportError, and the future's outcome still arrives. - A loading toast the user dismisses does not stop the result: it arrives as a new toast.
id:takes over a toast already on screen, which is how a flow of several steps hands one loading toast on.
The deck #
The newest toast is at the front. The deck draws visibleToasts of them, 3 by default, and keeps
the rest undrawn. Those count down like any other, and one whose time runs out goes without coming
back into view.
The pointer over the deck fans it out and draws every toast. A deck taller than its cap scrolls, and the toasts being read keep their place as others come and go. The pointer counts once it moves, presses or turns the wheel there. A toast that lands under a pointer nobody moves counts down as usual.
Every timer pauses while the pointer is over the deck, while a toast is being dragged, and while the app is hidden. Each resumes with the time it had left. A window that only lost focus does not pause them.
expandByDefault fans the deck out with no pointer. It draws only visibleToasts and does not
pause the timers.
Swipe a toast to dismiss it, in the directions its position names: bottomRight takes down and
right, topCenter up only. config.swipeDirections gives your own set, and an empty set turns the
swipe off. A two-finger trackpad pan scrolls the deck rather than swiping a toast.
Slots, the close button and dismissible #
toast.show(
'Message archived',
action: (context, archived) => TextButton(
onPressed: () {
restoreMessage();
archived.dismiss();
},
child: const Text('Undo'),
),
closeButton: true,
);
actionis yours. It is handed the toast, so the widget decides whether pressing it also dismisses the toast.closeButtondraws the package's close button.config.closeButtonsets it for every toast, andshow(closeButton:)wins over it.dismissiblegoverns every way a user dismisses a toast: the swipe, the close button and the dismiss-all control. Unset, it means "not while it loads". The app's owndismissandToastView.dismissdismiss the toast whatever it says.
Wrapped in SonnerHost, a widget in a slot cannot look up an Overlay or the app's Navigator
(Two ways to mount). Give it a semantics label rather than a Tooltip, and
reach the navigator through a key.
Your own look #
A builder replaces the whole look. Give one to show(builder:) for one toast, or to
config.builder for every toast. The toast still enters, leaves, stacks and swipes on its own.
Widget myToast(BuildContext context, ToastView view) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: ToastFit(
child: FadeTransition(
opacity: ReverseAnimation(view.covered),
child: Text(view.state.title),
),
),
),
);
The builder is handed a ToastView:
| Member | What it is |
|---|---|
id, state |
The toast's id, and what it shows now: title, description, isLoading, dismissible… |
animation |
0 → 1 as the toast enters, 1 → 0 as it leaves |
covered |
How much the deck covers the toast: 0 at the front or expanded, 1 behind the front |
timeLeft |
1 → 0 over the toast's countdown, or null while it has no timer |
dismiss() |
Dismisses the toast, whatever dismissible says |
holdTimer() |
Pauses the timers until the toast is dismissed or changed, for a gesture of your own |
Read covered. The default look draws no content while a toast is covered and keeps its card,
so the deck reads as a pile of cards with only the front one written on. A builder does that only if
it reads covered, as above. One that ignores it paints its text under the front toast.
Fit the content to the card. A toast behind a shorter front is laid out at the front's height,
below its own, so its card is drawn whole at that height. Whatever is written on the card has to
take that: wrap it in ToastFit, inside the card as above, and it keeps its own height, drawn
from the top and clipped at the card's edge. A builder that does not reports an overflow in debug whenever it
is behind a shorter front. In release the deck clips it, so nothing shows past the card.
toastCardBuilder does both for you. Give it the card and the content, and it puts the content in
the card with ToastFit and fades it by covered:
final myToast = toastCardBuilder(
card: (context, view, child) =>
Card(child: Padding(padding: const EdgeInsets.all(16), child: child)),
content: (context, view) => Text(view.state.title),
);
Time left #
A toast counting down draws its time left, as a border sweeping round the card by default.
toast.config = toast.config.copyWith(
timeLeft: () => const ToastTimeLeft(look: TimeLeftLook.bottomBar),
);
- Looks:
border,bottomBar,topBar,cornerRing,leadingRing. - Settings:
startandclockwisefor the border,strokeWidth,color,keepBorder,easeRestartandfadeWhenCovered. - None:
copyWith(timeLeft: () => null). - A builder reads
ToastView.timeLeftwhateverconfig.timeLeftsays. It stands still while the timers are paused, and eases back up when an update or a replace starts the countdown again.
Configuration #
toast.config = toast.config.copyWith(
position: SonnerPosition.topRight,
visibleToasts: 5,
closeButton: true,
);
Assigning a new config moves the toasts on screen with it. Countdowns already under way keep the
duration they started with; a new duration reaches the next toast.
A field that can be null is passed to copyWith as a function, so null can be given:
copyWith(deckCap: () => null).
| Field | Default | |
|---|---|---|
position |
bottomRight |
One of six: top or bottom, left, center or right |
width |
356 | The width of every toast |
gap |
14 | The space between two toasts |
offset |
24 | The distance from the screen edges |
visibleToasts |
3 | How many toasts the deck draws with no pointer over it, 1 to 20 |
duration |
4 s | How long a toast stays; Duration.zero keeps it |
expandByDefault |
false | Fan the deck out with no pointer |
loadingIndicator |
CircularProgressIndicator |
What a loading toast's leading slot holds |
leadingSize |
20 | The side of the leading slot's box |
closeButton |
false | A close button on every toast |
swipeDirections |
null | The position's own directions |
builder |
null | The default look |
timeLeft |
ToastTimeLeft() |
A border; null draws none |
deckCap |
DeckCap.pixels(400) |
Null lets the deck reach the whole window |
scrollbar |
DeckScrollbar() |
Null draws none |
dismissAll |
DeckDismissAll() |
Null draws none |
stowControl |
DeckStowControl() |
Null draws none |
stowMotion |
DeckStowMotion() |
A slide; not nullable |
stowHandle |
null | Nothing left at the edge |
Your own controller. SonnerController(config: …) makes one, which SonnerHost(controller:)
draws, or which you attach yourself.
A long deck: cap, scrollbar, dismiss all #
The cap. The expanded deck reaches no further than config.deckCap from its edge, and fades out
over 24 px there. Past it the toasts scroll. The newest toast is never cut.
toast.config = toast.config.copyWith(
deckCap: () => const DeckCap.share(0.5), // half the window
);
A cap is DeckCap.pixels(400), a share of the window with DeckCap.share, or a number of toasts
with DeckCap.toasts. Each takes fade:, and fade: 0 cuts hard. deckCap: () => null lets
the deck reach the whole window.
The scrollbar. DeckScrollbar() is a draggable thumb outside the deck's right edge, shown all
the while the deck can scroll. placement: DeckScrollbarPlacement.inside puts it over the toasts,
alwaysShown: false shows it only while the deck scrolls, and draggable: false makes it take no
pointer.
Dismiss all. DeckDismissAll() draws a pill reading "Clear all" past the deck's far end. It
shows while the pointer holds the deck and at least two toasts the user may dismiss are up.
- Pressing it leaves loading toasts and
dismissible: falseones.toast.dismissAll()still dismisses everything. look: DeckDismissAllLook.headerdraws a bar as wide as the deck, with the count.builder:draws your own control. It is handed aDeckDismissAllView:count,dismissand the deck'sexpansion.
In another language, set the label, and the count for the header look. Flutter's localizations have no "clear all" string.
toast.config = toast.config.copyWith(
dismissAll: () => DeckDismissAll(
look: DeckDismissAllLook.header,
label: '모두 지우기',
countLabel: (count) => '알림 $count개',
),
);
Hiding the deck #
toast.stow() puts the deck out of sight, keeping its toasts. They count down as they would on
screen. The next new toast brings the deck back with whatever is left, as does
toast.unstow(). An update, a replace, and a promise state landing on a toast already on screen
do not. toast.stowed says whether it is hidden.
stowControl,DeckStowControl()by default, is a pill reading "Hide". It shows while the pointer holds the deck. Its looks arepill,headerandicon, andbuilder:draws your own from aDeckStowView. Where either control asks for a header look, the two share one bar.stowMotion,DeckStowMotion()by default, slides the deck past its edge. Its looks areslide,fadeandshrink, andbuilder:takes the deck out of sight your own way. It is not nullable: an app that callsstow()needs a motion whether or not a control is drawn.stowHandleis null by default.DeckStowHandle()leaves a button at the edge reading how many toasts are hidden, which brings them back.countLabel:words it, andbuilder:draws your own from aDeckStowHandleView.
toast.config = toast.config.copyWith(
stowControl: () => const DeckStowControl(look: DeckStowLook.icon),
stowMotion: const DeckStowMotion(look: DeckStowMotionLook.fade),
stowHandle: () => const DeckStowHandle(),
);
Coming from flash #
A FlashBar from flash can be a toast's look through a small
adapter. It lives in the example app rather than in the package:
example/lib/flash_adapter.dart.
Copy it into your app.
toast.show(
'Saved',
builder: flashToast(
(context, controller, view) => FlashBar(
controller: controller,
dismissDirections: const [],
content: Text(view.state.title),
),
),
);
- The adapter gives the
FlashBaran animation of its own, resting at 1. The toast already enters and leaves by itself, and sharing its animation would run the motion twice. - Give it
dismissDirections: const []. On flash's default aFlashBarswipes in every direction, and its drag wins over the toast's: it ignoresdismissible, which the adapter can only spring back, and takes trackpad pans that would scroll the deck. With no directions of its own, the swipe is the toast's. - A
FlashBardraws its card and its content as one, so fading the content byview.coveredis up to your builder.
Widget tests #
Six things an app's widget tests meet with toasts on screen.
A counting toast's timer outlives the widget tree. The timer belongs to the controller, not to
the host, so unmounting the app does not stop it. testWidgets checks for pending timers before
tearDown runs, so a test that leaves a counting toast up fails with "A Timer is still pending even
after the widget tree was disposed". Before the test body ends, dismiss the toast, or pump past its
duration. Or show it with duration: Duration.zero.
testWidgets('saving shows a toast', (tester) async {
await tester.pumpWidget(const App());
await tester.tap(find.text('Save'));
await tester.pump();
expect(find.text('Saved'), findsOneWidget);
toast.dismissAll();
await tester.pump(const Duration(milliseconds: 200));
});
pumpAndSettle runs out a counting toast. While a toast counting down is drawn, the host
schedules frames for it, so pumpAndSettle keeps pumping until the toast's time runs out and it is
dismissed. Pump by hand, as above.
pumpAndSettle never settles with a loading toast on screen. The default loadingIndicator is
an indefinite CircularProgressIndicator, which never stops scheduling frames, like any spinner in
a Flutter test. Pump by hand, or pass an indicator that ends.
The deck's controls are on by default. deckCap, dismissAll and stowControl change where a
hovered deck takes the pointer and what it draws. A test that relies on the deck taking a click
anywhere down the window's side, or pins the deck's own geometry, sets them to null.
A builder that does not fit its content fails a test with toasts of mixed heights. Behind a
shorter front, its content overflows the height it is laid out at, and a widget test fails on the
overflow. Use ToastFit or toastCardBuilder (Your own look).
toast is one controller for the whole test run. A test that wants a clean slate gives
SonnerHost a SonnerController of its own and disposes of it at the end.
More #
example/: a desktop harness with every behaviour on a buttondocs/spec.md: the full specification, and the reason behind each decision
License #
MIT