diff --git a/lib/helpers/snack_bar_builder.dart b/lib/helpers/snack_bar_builder.dart index d7409b6..1c5626e 100644 --- a/lib/helpers/snack_bar_builder.dart +++ b/lib/helpers/snack_bar_builder.dart @@ -1,56 +1,25 @@ import 'package:flutter/material.dart'; -// showDismissibleSnackBar shows a [SnackBar] with tap to dismiss -// all other properties are default and optional +import '../widgets/top_toast.dart'; + +/// Shows a transient notification that dismisses on tap, on an upward swipe, +/// or after [duration]. +/// +/// Kept under its original name so existing call sites are unchanged; the +/// notification itself moved from a bottom [SnackBar] to a top-anchored +/// overlay (#638), because the bottom placement covered the chat composer. void showDismissibleSnackBar( BuildContext context, { - Key? key, required Widget content, Color? backgroundColor, - double? elevation, - EdgeInsetsGeometry? margin, - EdgeInsetsGeometry? padding, - double? width, - ShapeBorder? shape, - HitTestBehavior? hitTestBehavior, - SnackBarBehavior? behavior, - SnackBarAction? action, - double? actionOverflowThreshold, - bool? showCloseIcon, - Color? closeIconColor, Duration? duration, bool? persist, - Animation? animation, - void Function()? onVisible, - DismissDirection? dismissDirection, - Clip? clipBehavior, }) { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar( - key: key, - content: GestureDetector( - onTap: () => messenger.hideCurrentSnackBar(), - child: content, - ), - backgroundColor: backgroundColor, - elevation: elevation, - margin: margin, - padding: padding, - width: width, - shape: shape, - hitTestBehavior: hitTestBehavior, - behavior: behavior, - action: action, - actionOverflowThreshold: actionOverflowThreshold, - showCloseIcon: showCloseIcon, - closeIconColor: closeIconColor, - duration: duration ?? const Duration(seconds: 4), - persist: persist, - animation: animation, - onVisible: onVisible, - dismissDirection: dismissDirection ?? DismissDirection.down, - clipBehavior: clipBehavior ?? Clip.hardEdge, - ), + TopToast.show( + context, + content: content, + backgroundColor: backgroundColor, + duration: duration ?? TopToast.defaultDuration, + persist: persist ?? false, ); } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 9f4d167..aa292f3 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -644,7 +644,6 @@ class _ChannelChatScreenState extends State { Future _blockChannelSender(ChannelMessage message) async { final connector = context.read(); final blockService = context.read(); - final messenger = ScaffoldMessenger.of(context); final l10n = context.l10n; final name = message.senderName; final keys = connector.resolveContactKeysByName(name); @@ -653,12 +652,13 @@ class _ChannelChatScreenState extends State { await blockService.block(key); } if (!mounted) return; - messenger.showSnackBar(SnackBar(content: Text(l10n.block_blocked(name)))); + showDismissibleSnackBar(context, content: Text(l10n.block_blocked(name))); } else { await blockService.blockName(name); if (!mounted) return; - messenger.showSnackBar( - SnackBar(content: Text(l10n.block_blockedNameOnly(name))), + showDismissibleSnackBar( + context, + content: Text(l10n.block_blockedNameOnly(name)), ); } } diff --git a/lib/screens/serial_capture_screen.dart b/lib/screens/serial_capture_screen.dart index 02564fe..a9d6465 100644 --- a/lib/screens/serial_capture_screen.dart +++ b/lib/screens/serial_capture_screen.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../connector/caplog_reassembler.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/snack_bar_builder.dart'; import '../utils/log_export.dart'; /// Serial-capture diagnostics screen (#430). @@ -255,10 +256,9 @@ class _SerialCaptureScreenState extends State { final result = await c.downloadCaplog(); if (!mounted) return; if (result.bytes.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Buffer is empty, nothing to download.'), - ), + showDismissibleSnackBar( + context, + content: const Text('Buffer is empty, nothing to download.'), ); return; } diff --git a/lib/screens/settings/broker_editor_screen.dart b/lib/screens/settings/broker_editor_screen.dart index 2dc78ab..6b6edb5 100644 --- a/lib/screens/settings/broker_editor_screen.dart +++ b/lib/screens/settings/broker_editor_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../helpers/snack_bar_builder.dart'; import '../../models/observer_config.dart'; import '../../services/observer_config_service.dart'; @@ -133,11 +134,10 @@ class _BrokerEditorScreenState extends State { } void _snack(String msg, {bool isError = false}) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - backgroundColor: isError ? Theme.of(context).colorScheme.error : null, - ), + showDismissibleSnackBar( + context, + content: Text(msg), + backgroundColor: isError ? Theme.of(context).colorScheme.error : null, ); } diff --git a/lib/screens/settings/config_profile_preview_screen.dart b/lib/screens/settings/config_profile_preview_screen.dart index a790fc7..e385331 100644 --- a/lib/screens/settings/config_profile_preview_screen.dart +++ b/lib/screens/settings/config_profile_preview_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../helpers/config_profile_diff.dart'; import '../../helpers/config_profile_writes.dart'; +import '../../helpers/snack_bar_builder.dart'; import '../../models/config_profile.dart'; import '../../services/observer_apply_service.dart'; import '../../services/observer_config_service.dart'; @@ -79,13 +80,12 @@ class _ConfigProfilePreviewScreenState ? '$what applied.' : '$what: ${result.failures.length} of ${result.items.length} failed ' '(${result.failures.first.error ?? 'error'}).'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(msg), - backgroundColor: result.allOk - ? null - : Theme.of(context).colorScheme.error, - ), + showDismissibleSnackBar( + context, + content: Text(msg), + backgroundColor: result.allOk + ? null + : Theme.of(context).colorScheme.error, ); await _load(); // re-diff against true state (partial-save recovery) } finally { diff --git a/lib/screens/settings/observer_settings_view.dart b/lib/screens/settings/observer_settings_view.dart index 64297a6..5170409 100644 --- a/lib/screens/settings/observer_settings_view.dart +++ b/lib/screens/settings/observer_settings_view.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../connector/meshcore_connector.dart'; import '../../models/observer_config.dart'; +import '../../helpers/snack_bar_builder.dart'; import '../../services/observer_config_service.dart'; import 'config_profile_import_screen.dart'; import 'mqtt_brokers_screen.dart'; @@ -163,18 +164,16 @@ class _ObserverSettingsViewState extends State { _seedFromConfig(svc.config); if (!mounted) return; setState(() => _saving = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - invalidInterval - ? 'Status interval must be 10–3600 seconds' - : ok - ? 'Observer settings saved' - : (svc.lastError ?? - 'Some changes failed. Re-read from the device'), - ), - backgroundColor: ok ? null : Theme.of(context).colorScheme.error, + showDismissibleSnackBar( + context, + content: Text( + invalidInterval + ? 'Status interval must be 10–3600 seconds' + : ok + ? 'Observer settings saved' + : (svc.lastError ?? 'Some changes failed. Re-read from the device'), ), + backgroundColor: ok ? null : Theme.of(context).colorScheme.error, ); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 92c5a80..12803c6 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -855,8 +855,9 @@ class _SettingsScreenState extends State { // fall through to the failure message } if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.settings_aboutLinkFailed)), + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_aboutLinkFailed), ); } } @@ -1064,10 +1065,9 @@ class _SettingsScreenState extends State { onPressed: () async { await Clipboard.setData(ClipboardData(text: copyValue)); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.settings_publicKeyCopied), - ), + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_publicKeyCopied), ); }, ), diff --git a/lib/widgets/top_toast.dart b/lib/widgets/top_toast.dart new file mode 100644 index 0000000..b021709 --- /dev/null +++ b/lib/widgets/top_toast.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../utils/app_logger.dart'; + +/// A transient, tap-to-dismiss notification anchored to the TOP of the screen. +/// +/// The app previously used bottom-anchored [SnackBar]s, which sat on top of the +/// chat composer (#638). A [SnackBar] is positioned by its [Scaffold] and can +/// only be pushed upward with a near-full-height bottom margin, which delays +/// its reveal animation and turns the whole screen into a hit-test target, so +/// this renders through the [Overlay] instead. +class TopToast { + TopToast._(); + + static const Duration _animationDuration = Duration(milliseconds: 220); + static const Duration defaultDuration = Duration(seconds: 4); + static const double _maxWidth = 560; + + /// Removes the toast currently on screen. Non-null only while one is visible. + static VoidCallback? _dismissCurrent; + + /// Shows [content] at the top of the screen. + /// + /// When [persist] is true the toast stays until the user dismisses it, for + /// messages that must be read rather than glimpsed (SAFELANE 6). + static void show( + BuildContext context, { + required Widget content, + Color? backgroundColor, + Duration duration = defaultDuration, + bool persist = false, + }) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) { + // SAFELANE 6: a message we cannot display must not disappear silently. + appLogger.warn( + 'No Overlay in this context; notification was not shown', + tag: 'TopToast', + ); + return; + } + + // A newer message replaces the visible one outright: two entries would + // otherwise stack on top of each other at the same position. + _dismissCurrent?.call(); + + late final OverlayEntry entry; + var removed = false; + void remove() { + if (removed) return; + removed = true; + entry.remove(); + if (_dismissCurrent == remove) { + _dismissCurrent = null; + } + } + + entry = OverlayEntry( + builder: (_) => _TopToastHost( + backgroundColor: backgroundColor, + duration: duration, + persist: persist, + animationDuration: _animationDuration, + maxWidth: _maxWidth, + onDismissed: remove, + child: content, + ), + ); + _dismissCurrent = remove; + overlay.insert(entry); + } +} + +class _TopToastHost extends StatefulWidget { + const _TopToastHost({ + required this.child, + required this.duration, + required this.persist, + required this.animationDuration, + required this.maxWidth, + required this.onDismissed, + this.backgroundColor, + }); + + final Widget child; + final Duration duration; + final bool persist; + final Duration animationDuration; + final double maxWidth; + final VoidCallback onDismissed; + final Color? backgroundColor; + + @override + State<_TopToastHost> createState() => _TopToastHostState(); +} + +class _TopToastHostState extends State<_TopToastHost> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + Timer? _timer; + bool _dismissing = false; + + @override + void initState() { + super.initState(); + _controller.forward(); + if (!widget.persist) { + _timer = Timer(widget.duration, _dismiss); + } + } + + Future _dismiss() async { + if (_dismissing || !mounted) return; + _dismissing = true; + _timer?.cancel(); + try { + await _controller.reverse(); + } on TickerCanceled { + // Replaced by a newer toast mid-animation; that path already removed us. + return; + } + if (!mounted) return; + widget.onDismissed(); + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final background = + widget.backgroundColor ?? + theme.snackBarTheme.backgroundColor ?? + theme.colorScheme.inverseSurface; + final textStyle = + theme.snackBarTheme.contentTextStyle ?? + theme.textTheme.titleMedium!.copyWith( + color: theme.colorScheme.onInverseSurface, + ); + + return Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.maxWidth), + child: SlideTransition( + position: + Tween( + begin: const Offset(0, -0.4), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ), + ), + child: FadeTransition( + opacity: _controller, + // Parity with SnackBar, which wraps its content the same way + // (snack_bar.dart): without liveRegion a screen reader never + // announces the message at all. + child: Semantics( + container: true, + liveRegion: true, + onDismiss: _dismiss, + child: Material( + color: background, + elevation: 6, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + // One recognizer for both gestures: a nested InkWell would + // also splash on the tap-up that ends a dismissing swipe. + child: GestureDetector( + onTap: _dismiss, + onVerticalDragEnd: (details) { + if ((details.primaryVelocity ?? 0) < 0) { + _dismiss(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: DefaultTextStyle( + style: textStyle, + child: IconTheme( + data: IconThemeData(color: textStyle.color), + child: widget.child, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +}