ashlarui

Toast

Short messages that go by themselves: types, promises and a stack.

Toast, as it looks in an app Toast, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/toast.dart';

class ToastDemo extends StatelessWidget {
  const ToastDemo({super.key});

  @override
  Widget build(BuildContext context) => Button(
    variant: ButtonVariant.outline,
    onPressed: () => showToast(
      context,
      title: 'Payment sent',
      description: 'KES 2,400 to Wanjiru Mwangi.',
      action: Button(size: ButtonSize.sm, onPressed: () {}, child: const Text('Undo')),
    ),
    child: const Text('Send payment'),
  );
}

Add it

In your Flutter project:

$ ashlar add toast

It writes theme.dart, palette.dart and toast.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs ashlar_icons, which come with it.

Examples

Other ways to use it, each running live, with its code.

Types

Success, info, warning and error.

Types, as it looks in an app Types, as it looks in an app, dark
// Types: Success, info, warning and error.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/toast.dart';

class ToastTypesExample extends StatelessWidget {
  const ToastTypesExample({super.key});

  @override
  Widget build(BuildContext context) => Builder(
    builder: (context) => Wrap(
      spacing: 8,
      children: [
        for (final type in [ToastType.success, ToastType.info, ToastType.warning, ToastType.error])
          Button(
            variant: ButtonVariant.outline,
            onPressed: () => showToast(context, title: 'A ${type.name} toast', type: type),
            child: Text(type.name),
          ),
      ],
    ),
  );
}

Following work

Loading, then done or failed.

Following work, as it looks in an app Following work, as it looks in an app, dark
// Following work: Loading, then done or failed.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/toast.dart';

class ToastPromiseExample extends StatelessWidget {
  const ToastPromiseExample({super.key});

  @override
  Widget build(BuildContext context) => Builder(
    builder: (context) => Button(
      variant: ButtonVariant.outline,
      onPressed: () => showToastPromise<int>(
        context,
        Future.delayed(const Duration(seconds: 2), () => 12),
        loading: 'Uploading…',
        success: (n) => '$n photos uploaded.',
        error: (e) => 'The upload failed.',
      ),
      child: const Text('Upload photos'),
    ),
  );
}

With an action

Undo, at the top of the screen.

With an action, as it looks in an app With an action, as it looks in an app, dark
// With an action: Undo, at the top of the screen.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/toast.dart';

class ToastActionExample extends StatelessWidget {
  const ToastActionExample({super.key});

  @override
  Widget build(BuildContext context) => Builder(
    builder: (context) => Button(
      variant: ButtonVariant.outline,
      onPressed: () => showToast(
        context,
        title: 'Message archived',
        position: ToastPosition.topCenter,
        action: Button(size: ButtonSize.xs, onPressed: () {}, child: const Text('Undo')),
      ),
      child: const Text('Archive'),
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

ToastTypeenum

What kind of news a toast brings; all but normal show an icon.

ValueWhat it is for
normal
success
info
warning
error
loading

ToastPositionenum

Where toasts gather on the screen.

ValueWhat it is for
topLeft
topCenter
topRight
bottomLeft
bottomCenter
bottomRight

ToastHandleclass

A toast on screen: change it or send it away.

showToastfunction

Shows a short message that goes by itself after duration (a loading one waits until it is updated). Recent toasts stack at position; the pointer over them fans them out and holds them. A swipe sends one away, and a screen reader reads each out.

Parameter Type Default What it is for
contextpositional BuildContext Where it is opened from: its theme and navigator come from here.
titlerequired String What happened.
description String? A line under the title.
type ToastType ToastType.normal Plain, success, info, warning, error or loading: its icon and colour.
icon Widget? Its own icon, in place of the type's.
action Widget? A button to act on it: Undo.
cancelWidget?A button to dismiss it.
closeButton bool false A close button in the corner.
duration Duration const Duration(seconds: 4) How long it stays, while the pointer is not over it.
position ToastPosition ToastPosition.bottomRight The corner or edge it appears at.

showToastPromisefunction

A toast that follows future: loading while it runs, then success with its value or error with what went wrong. Returns the future, to await as before.

Parameter Type Default What it is for
contextpositional BuildContext Where it is opened from: its theme and navigator come from here.
futurepositional Future<T> The work it follows.
loadingrequired String Shown while the work runs.
successrequired String Function(T value) Writes what to show when it succeeds, from its result.
errorrequired String Function(Object error) Writes what to show when it fails, from the error.
position ToastPosition ToastPosition.bottomRight The corner or edge it appears at.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/toast.dart 539 lines
// Ashlar UI: Toast. Copied into your app by `ashlar add toast`; it is yours to change.

import 'dart:async';

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'theme.dart';

/// What kind of news a toast brings; all but [normal] show an icon.
enum ToastType { normal, success, info, warning, error, loading }

/// Where toasts gather on the screen.
enum ToastPosition {
  topLeft,
  topCenter,
  topRight,
  bottomLeft,
  bottomCenter,
  bottomRight,
}

/// A toast on screen: change it or send it away.
class ToastHandle {
  ToastHandle._(this._toast, this._host);

  final _Toast _toast;
  final _ToastHost _host;

  void dismiss() => _host.remove(_toast);

  /// Changes what it says. A [type] other than loading starts its timer again.
  void update({
    String? title,
    String? description,
    ToastType? type,
    Duration? duration,
  }) {
    _toast
      ..title = title ?? _toast.title
      ..description = description ?? _toast.description
      ..type = type ?? _toast.type
      ..duration = duration ?? _toast.duration
      ..version += 1;
    _host.changed();
  }
}

/// Shows a short message that goes by itself after [duration] (a loading one waits until
/// it is updated). Recent toasts stack at [position]; the pointer over them fans them out
/// and holds them. A swipe sends one away, and a screen reader reads each out.
///
/// ```dart
/// showToast(context, title: 'Payment sent', description: 'KES 2,400 to Wanjiru.', type: ToastType.success)
/// showToast(context, title: 'Event deleted', action: Button(size: ButtonSize.sm, onPressed: undo, child: Text('Undo')))
/// ```
ToastHandle showToast(
  BuildContext context, {
  required String title,
  String? description,
  ToastType type = ToastType.normal,
  Widget? icon,
  Widget? action,
  Widget? cancel,
  bool closeButton = false,
  Duration duration = const Duration(seconds: 4),
  ToastPosition position = ToastPosition.bottomRight,
}) {
  final overlay = Overlay.of(context, rootOverlay: true);
  final hosts = _hosts[overlay] ??= {};
  final host = hosts[position] ??= _ToastHost(
    overlay,
    AshlarTheme.of(context),
    position,
  );
  final toast = _Toast(
    title: title,
    description: description,
    type: type,
    icon: icon,
    action: action,
    cancel: cancel,
    closeButton: closeButton,
    duration: duration,
  );
  host.add(toast);
  return ToastHandle._(toast, host);
}

/// A toast that follows [future]: [loading] while it runs, then [success] with its value
/// or [error] with what went wrong. Returns the future, to await as before.
Future<T> showToastPromise<T>(
  BuildContext context,
  Future<T> future, {
  required String loading,
  required String Function(T value) success,
  required String Function(Object error) error,
  ToastPosition position = ToastPosition.bottomRight,
}) {
  final handle = showToast(
    context,
    title: loading,
    type: ToastType.loading,
    position: position,
  );
  future.then(
    (v) => handle.update(title: success(v), type: ToastType.success),
    onError: (Object e) =>
        handle.update(title: error(e), type: ToastType.error),
  );
  return future;
}

final _hosts = Expando<Map<ToastPosition, _ToastHost>>();

class _Toast {
  _Toast({
    required this.title,
    this.description,
    required this.type,
    this.icon,
    this.action,
    this.cancel,
    required this.closeButton,
    required this.duration,
  });

  String title;
  String? description;
  ToastType type;
  final Widget? icon, action, cancel;
  final bool closeButton;
  Duration duration;

  /// Counts changes, so a card knows to start its timer again.
  int version = 0;
  final key = UniqueKey();
}

/// The toasts at one position, in one entry on the root overlay.
class _ToastHost {
  _ToastHost(this.overlay, this.theme, this.position) {
    entry = OverlayEntry(
      builder: (_) => AshlarTheme(
        data: theme,
        child: _ToastStack(host: this),
      ),
    );
    overlay.insert(entry);
  }

  final OverlayState overlay;
  final AshlarThemeData theme;
  final ToastPosition position;
  late final OverlayEntry entry;
  final toasts = ValueNotifier<List<_Toast>>([]);

  /// The pointer is over the stack: it is fanned out, and the timers wait.
  final hovered = ValueNotifier(false);

  static const visible = 3;

  void add(_Toast toast) {
    final list = [...toasts.value, toast];
    toasts.value = list.length > visible + 2
        ? list.sublist(list.length - visible - 2)
        : list;
  }

  void remove(_Toast toast) => toasts.value = [...toasts.value]..remove(toast);

  void changed() => toasts.value = [...toasts.value];
}

class _ToastStack extends StatefulWidget {
  const _ToastStack({required this.host});

  final _ToastHost host;

  @override
  State<_ToastStack> createState() => _ToastStackState();
}

class _ToastStackState extends State<_ToastStack> {
  final _heights = <Key, double>{};

  @override
  Widget build(BuildContext context) {
    final padding = MediaQuery.paddingOf(context);
    final p = widget.host.position;
    final top =
        p == ToastPosition.topLeft ||
        p == ToastPosition.topCenter ||
        p == ToastPosition.topRight;
    final alignment = switch (p) {
      ToastPosition.topLeft => Alignment.topLeft,
      ToastPosition.topCenter => Alignment.topCenter,
      ToastPosition.topRight => Alignment.topRight,
      ToastPosition.bottomLeft => Alignment.bottomLeft,
      ToastPosition.bottomCenter => Alignment.bottomCenter,
      ToastPosition.bottomRight => Alignment.bottomRight,
    };
    return ValueListenableBuilder(
      valueListenable: widget.host.toasts,
      builder: (context, toasts, _) {
        if (toasts.isEmpty) return const SizedBox.shrink();
        return ValueListenableBuilder(
          valueListenable: widget.host.hovered,
          builder: (context, open, _) {
            // Newest first; only a few show, the rest wait behind.
            final shown = toasts.reversed.take(_ToastHost.visible).toList();
            final front = _heights[shown.first.key] ?? 64;
            var run = 0.0;
            final cards = <Widget>[];
            for (final (i, toast) in shown.indexed) {
              final height = _heights[toast.key] ?? 64;
              // Fanned out: one above the other. Stacked: each behind the last, a little
              // smaller, and as tall as the front one so only its edge shows.
              final offset = open ? run : i * 14.0;
              run += height + 8;
              cards.add(
                AnimatedPositioned(
                  key: toast.key,
                  duration: const Duration(milliseconds: 250),
                  curve: Curves.easeOutCubic,
                  left: 0,
                  right: 0,
                  top: top ? offset : null,
                  bottom: top ? null : offset,
                  child: AnimatedScale(
                    duration: const Duration(milliseconds: 250),
                    scale: open ? 1 : 1 - i * .05,
                    alignment: top
                        ? Alignment.bottomCenter
                        : Alignment.topCenter,
                    child: _Measured(
                      onHeight: (h) {
                        if (_heights[toast.key] != h) {
                          setState(() => _heights[toast.key] = h);
                        }
                      },
                      child: _ToastCard(
                        toast: toast,
                        host: widget.host,
                        behind: !open && i > 0,
                        frontHeight: front,
                      ),
                    ),
                  ),
                ),
              );
            }
            final height = open ? run : front + (shown.length - 1) * 14.0;
            return Positioned(
              left: 16 + padding.left,
              right: 16 + padding.right,
              top: top ? 16 + padding.top : null,
              bottom: top ? null : 16 + padding.bottom,
              child: Align(
                alignment: alignment,
                child: MouseRegion(
                  onEnter: (_) => widget.host.hovered.value = true,
                  onExit: (_) => widget.host.hovered.value = false,
                  child: AnimatedContainer(
                    duration: const Duration(milliseconds: 250),
                    width: 356,
                    height: height,
                    child: Stack(
                      clipBehavior: Clip.none,
                      children: cards.reversed.toList(),
                    ),
                  ),
                ),
              ),
            );
          },
        );
      },
    );
  }
}

/// Reports its child's height after layout, for the stack to place the cards.
class _Measured extends StatefulWidget {
  const _Measured({required this.onHeight, required this.child});

  final ValueChanged<double> onHeight;
  final Widget child;

  @override
  State<_Measured> createState() => _MeasuredState();
}

class _MeasuredState extends State<_Measured> {
  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final size = mounted ? context.size : null;
      if (size != null) widget.onHeight(size.height);
    });
    return widget.child;
  }
}

class _ToastCard extends StatefulWidget {
  const _ToastCard({
    required this.toast,
    required this.host,
    required this.behind,
    required this.frontHeight,
  });

  final _Toast toast;
  final _ToastHost host;

  /// Behind the front card in the stack: only its edge shows.
  final bool behind;
  final double frontHeight;

  @override
  State<_ToastCard> createState() => _ToastCardState();
}

class _ToastCardState extends State<_ToastCard>
    with SingleTickerProviderStateMixin {
  late final _enter = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 200),
  )..forward();
  Timer? _timer;
  late int _version = widget.toast.version;

  @override
  void initState() {
    super.initState();
    widget.host.hovered.addListener(_hover);
    _start();
  }

  void _hover() => widget.host.hovered.value ? _timer?.cancel() : _start();

  void _start() {
    _timer?.cancel();
    if (widget.toast.type == ToastType.loading || widget.host.hovered.value) {
      return;
    }
    _timer = Timer(widget.toast.duration, _leave);
  }

  Future<void> _leave() async {
    _timer?.cancel();
    if (!mounted) return;
    await _enter.reverse();
    widget.host.remove(widget.toast);
  }

  @override
  void didUpdateWidget(_ToastCard old) {
    super.didUpdateWidget(old);
    if (widget.toast.version != _version) {
      _version = widget.toast.version;
      _start();
    }
  }

  @override
  void dispose() {
    widget.host.hovered.removeListener(_hover);
    _timer?.cancel();
    _enter.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final toast = widget.toast;
    final icon =
        toast.icon ??
        switch (toast.type) {
          ToastType.normal => null,
          ToastType.success => const AshlarIcon(
            AshlarIconsRoundedStroke.circleCheck,
          ),
          ToastType.info => const AshlarIcon(AshlarIconsRoundedStroke.info),
          ToastType.warning => const AshlarIcon(
            AshlarIconsRoundedStroke.triangleAlert,
          ),
          ToastType.error => const AshlarIcon(AshlarIconsRoundedStroke.circleX),
          ToastType.loading => const _Turning(),
        };
    final content = Row(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        if (icon != null) ...[icon, const SizedBox(width: 12)],
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                toast.title,
                style: t.text.sm.copyWith(
                  color: c.popoverForeground,
                  fontWeight: FontWeight.w500,
                ),
              ),
              if (toast.description != null)
                Text(
                  toast.description!,
                  style: t.text.sm.copyWith(color: c.mutedForeground),
                ),
            ],
          ),
        ),
        if (toast.cancel != null) ...[const SizedBox(width: 8), toast.cancel!],
        if (toast.action != null) ...[const SizedBox(width: 8), toast.action!],
      ],
    );
    final card = Container(
      height: widget.behind ? widget.frontHeight : null,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: c.popover,
        borderRadius: BorderRadius.circular(t.radiusMd),
        border: Border.all(color: c.border),
        boxShadow: t.shadowLg,
      ),
      child: IconTheme.merge(
        data: IconThemeData(color: c.popoverForeground, size: 16),
        // Behind the front card, the content would only peek out: it waits, unseen.
        child: widget.behind ? const SizedBox() : content,
      ),
    );
    final framed = toast.closeButton && !widget.behind
        ? Stack(
            clipBehavior: Clip.none,
            children: [
              card,
              PositionedDirectional(
                top: -6,
                start: -6,
                child: _Close(onPressed: _leave),
              ),
            ],
          )
        : card;
    final still = MediaQuery.maybeDisableAnimationsOf(context) ?? false;
    final curve = CurvedAnimation(parent: _enter, curve: Curves.easeOut);
    final top = widget.host.position.name.startsWith('top');
    return Semantics(
      liveRegion: !widget.behind,
      container: true,
      excludeSemantics: widget.behind,
      child: Dismissible(
        key: toast.key,
        direction: DismissDirection.horizontal,
        onDismissed: (_) => widget.host.remove(toast),
        child: still
            ? framed
            : FadeTransition(
                opacity: curve,
                child: SlideTransition(
                  position: Tween(
                    begin: Offset(0, top ? -.5 : .5),
                    end: Offset.zero,
                  ).animate(curve),
                  child: framed,
                ),
              ),
      ),
    );
  }
}

class _Close extends StatelessWidget {
  const _Close({required this.onPressed});

  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Semantics(
      container: true,
      button: true,
      label: 'Close',
      onTap: onPressed,
      child: GestureDetector(
        onTap: onPressed,
        child: Container(
          width: 20,
          height: 20,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: c.popover,
            shape: BoxShape.circle,
            border: Border.all(color: c.border),
          ),
          child: AshlarIcon(
            AshlarIconsRoundedStroke.x,
            size: 12,
            color: c.foreground,
          ),
        ),
      ),
    );
  }
}

/// The loading icon, turning.
class _Turning extends StatefulWidget {
  const _Turning();

  @override
  State<_Turning> createState() => _TurningState();
}

class _TurningState extends State<_Turning>
    with SingleTickerProviderStateMixin {
  late final _turn = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 1),
  )..repeat();

  @override
  void dispose() {
    _turn.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => RotationTransition(
    turns: _turn,
    child: const AshlarIcon(AshlarIconsRoundedStroke.loaderCircle),
  );
}