feat(#638): move notifications from bottom SnackBar to a top toast

The bottom-anchored SnackBar sat on top of the chat composer. A SnackBar is
positioned by its Scaffold and can only be raised with a near-full-height
bottom margin, which (per Flutter's ClipRect + heightFactor reveal) delays the
content until the end of the animation and makes the whole screen a hit
target, so this renders through the root Overlay instead.

showDismissibleSnackBar keeps its name and every existing call site. Its
parameter list is trimmed to the four callers actually pass; the rest were
used by nobody, so any future use is now a compile error rather than a
silently ignored argument. persist is preserved: the sender-error path relies
on it to keep an error on screen until read.

Also routes the two composer-screen block confirmations and the five inline
ScaffoldMessenger call sites through the helper so placement is consistent
where a composer exists.

Gemini review: nested-InkWell splash on a dismissing swipe fixed; the
route-scoping finding did not apply (MaterialApp installs one root
ScaffoldMessenger, app.dart:1047, and this app has no nested one, so the old
SnackBars already outlived route pops); replace-instead-of-queue is
deliberate and noted on #522.

Agent: TurquoiseBridge (session 82d77b2f)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dev
diffwireauto 22 hours ago committed by Benjamin Wiechel
parent b33e8485f3
commit 64978061ea

@ -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<double>? 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,
);
}

@ -644,7 +644,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Future<void> _blockChannelSender(ChannelMessage message) async {
final connector = context.read<MeshCoreConnector>();
final blockService = context.read<BlockService>();
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<ChannelChatScreen> {
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)),
);
}
}

@ -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<SerialCaptureScreen> {
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;
}

@ -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<BrokerEditorScreen> {
}
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,
);
}

@ -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 {

@ -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<ObserverSettingsView> {
_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,
);
}

@ -855,8 +855,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 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<SettingsScreen> {
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),
);
},
),

@ -0,0 +1,214 @@
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<void> _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<Offset>(
begin: const Offset(0, -0.4),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
),
),
child: FadeTransition(
opacity: _controller,
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,
),
),
),
),
),
),
),
),
),
),
),
);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.