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
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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…
Reference in new issue