diff --git a/lib/widget/snacks.dart b/lib/widget/snacks.dart index f00b1ec1..d0adbf63 100644 --- a/lib/widget/snacks.dart +++ b/lib/widget/snacks.dart @@ -1,3 +1,5 @@ +import "dart:async"; + import "package:flutter/material.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:one_context/one_context.dart"; @@ -5,8 +7,10 @@ import "package:one_context/one_context.dart"; import "package:inventree/helpers.dart"; import "package:inventree/l10.dart"; +OverlayEntry? _currentSnackOverlay; + /* - * Display a configurable 'snackbar' at the bottom of the screen + * Display a configurable 'snackbar' at the top of the screen */ void showSnackIcon( String text, { @@ -19,23 +23,21 @@ void showSnackIcon( // Escape quickly if we do not have context if (!hasContext()) { - // Debug message for unit testing return; } BuildContext? context = OneContext().context; + if (context == null) return; - if (context != null) { - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - } + // Dismiss any currently visible snack immediately + _currentSnackOverlay?.remove(); + _currentSnackOverlay = null; Color backgroundColor = Colors.deepOrange; - // Make some selections based on the "success" value if (success != null && success == true) { backgroundColor = Colors.lightGreen; - // Select an icon if we do not have an action if (icon == null && onAction == null) { icon = TablerIcons.circle_check; } @@ -47,35 +49,157 @@ void showSnackIcon( } } - String _action = actionText ?? L10().details; + final String actionLabel = actionText ?? L10().details; + final Duration duration = Duration(seconds: onAction == null ? 5 : 10); - List childs = [Text(text), Spacer()]; - - if (icon != null) { - childs.add(Icon(icon)); - } - - OneContext().showSnackBar( - builder: (context) => SnackBar( - content: GestureDetector( - child: Row(children: childs), - onTap: () { - ScaffoldMessenger.of(context!).hideCurrentSnackBar(); - }, - ), + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => _TopSnackBar( + text: text, + icon: icon, backgroundColor: backgroundColor, - showCloseIcon: true, - action: onAction == null - ? null - : SnackBarAction( - label: _action, - onPressed: () { - // Immediately dismiss the notification - ScaffoldMessenger.of(context!).hideCurrentSnackBar(); - onAction(); - }, - ), - duration: Duration(seconds: onAction == null ? 5 : 10), + onAction: onAction, + actionText: actionLabel, + duration: duration, + onDismiss: () { + entry.remove(); + if (_currentSnackOverlay == entry) { + _currentSnackOverlay = null; + } + }, ), ); + + _currentSnackOverlay = entry; + Overlay.of(context).insert(entry); +} + +class _TopSnackBar extends StatefulWidget { + const _TopSnackBar({ + required this.text, + required this.backgroundColor, + required this.duration, + required this.onDismiss, + this.icon, + this.onAction, + this.actionText, + }); + + final String text; + final IconData? icon; + final Color backgroundColor; + final Function()? onAction; + final String? actionText; + final Duration duration; + final VoidCallback onDismiss; + + @override + _TopSnackBarState createState() => _TopSnackBarState(); +} + +class _TopSnackBarState extends State<_TopSnackBar> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnimation; + Timer? _timer; + bool _dismissed = false; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + _slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + + _controller.forward(); + _timer = Timer(widget.duration, _dismiss); + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _dismiss() { + if (_dismissed) return; + _dismissed = true; + _timer?.cancel(); + _timer = null; + _controller.reverse().then((_) { + if (mounted) widget.onDismiss(); + }); + } + + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + left: 0, + right: 0, + child: SlideTransition( + position: _slideAnimation, + child: Material( + color: Colors.transparent, + child: ColoredBox( + color: widget.backgroundColor, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: _dismiss, + behavior: HitTestBehavior.opaque, + child: Row( + children: [ + Expanded( + child: Text( + widget.text, + style: const TextStyle(color: Colors.white), + ), + ), + if (widget.icon != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(widget.icon, color: Colors.white), + ), + ], + ), + ), + ), + if (widget.onAction != null) + TextButton( + onPressed: () { + _dismiss(); + widget.onAction!(); + }, + child: Text( + widget.actionText ?? "", + style: const TextStyle(color: Colors.white), + ), + ), + IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: _dismiss, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } }