From e9b4561de09b4a0b5c2ba2c8f1867f0397e20f3e Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Mon, 27 Jul 2026 00:38:39 +0800 Subject: [PATCH] feat: single-instance floating feedback banner that glides with the bottom nav Replace ScaffoldMessenger snackbars with an app-level AdaptiveFeedbackHost overlay above the Navigator. The messenger rendered the same snackbar in both scaffolds during route transitions, ghosting when pages with and without the bottom nav gave it different offsets. The single banner now animates its bottom clearance and corner radius in step with the page transition: floating above the glass capsule on shell pages, near the edge on pushed pages. Route depth is tracked via a NavigatorObserver (dialogs excluded); showAdaptiveFeedback API unchanged. Theme-level floating snackbar style kept for direct showSnackBar callers. Co-Authored-By: Claude Fable 5 --- lib/main.dart | 5 + lib/services/theme_service.dart | 20 +++ lib/widgets/adaptive_feedback.dart | 269 +++++++++++++++++++++++++---- 3 files changed, 265 insertions(+), 29 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index c9dabcc..ba19aa0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -282,6 +282,11 @@ class _TechPieAppState extends State { syncService: widget.syncService, child: MaterialApp( scaffoldMessengerKey: rootMessengerKey, + navigatorObservers: [FeedbackRouteObserver()], + builder: (context, child) => AdaptiveFeedbackHost( + key: adaptiveFeedbackHostKey, + child: child ?? const SizedBox.shrink(), + ), title: 'TechPie', theme: widget.themeService.lightTheme, darkTheme: widget.themeService.darkTheme, diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart index ae97c3f..d6c655c 100644 --- a/lib/services/theme_service.dart +++ b/lib/services/theme_service.dart @@ -159,6 +159,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -171,6 +176,13 @@ class ThemeService extends ChangeNotifier { return _buildIosTheme(theme, brightness: Brightness.dark); } + /// Snackbars float as rounded cards above the glass bottom nav (which is a + /// 56dp capsule with 8dp margins) instead of a full-width strip glued to it. + static const _snackBarShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + static const _snackBarInsets = EdgeInsets.fromLTRB(12, 0, 12, 12); + ThemeData _buildDesktopTheme(ThemeData base) { return base.copyWith( appBarTheme: base.appBarTheme.copyWith( @@ -179,6 +191,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: base.snackBarTheme.copyWith( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -258,6 +275,9 @@ class ThemeService extends ChangeNotifier { contentTextStyle: TextStyle( color: isDark ? Colors.white : Colors.black, ), + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, ), inputDecorationTheme: InputDecorationTheme( filled: true, diff --git a/lib/widgets/adaptive_feedback.dart b/lib/widgets/adaptive_feedback.dart index 8886e57..feda53e 100644 --- a/lib/widgets/adaptive_feedback.dart +++ b/lib/widgets/adaptive_feedback.dart @@ -1,8 +1,38 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import '../utils/platform.dart'; +import 'app_shell/tg_bottom_nav_bar.dart'; + final GlobalKey rootMessengerKey = GlobalKey(); +final GlobalKey adaptiveFeedbackHostKey = + GlobalKey(); + +/// Tracks how many page routes are stacked above the shell so the feedback +/// banner knows whether the bottom nav is on screen. Dialog/popup routes +/// don't count — the nav stays visible underneath them. +final ValueNotifier _pageRouteDepth = ValueNotifier(0); + +class FeedbackRouteObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value++; + } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } +} + enum AdaptiveFeedbackStyle { info, success, error } void showAdaptiveFeedback({ @@ -13,39 +43,220 @@ void showAdaptiveFeedback({ String? actionLabel, VoidCallback? onAction, }) { - final messenger = context != null - ? ScaffoldMessenger.maybeOf(context) - : rootMessengerKey.currentState; - if (messenger == null) return; - - final theme = messenger.context.mounted - ? Theme.of(messenger.context) - : ThemeData.fallback(); - final feedbackStyle = _FeedbackStyle.from(theme, style); - - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - backgroundColor: feedbackStyle.backgroundColor, - content: Row( - children: [ - Icon(feedbackStyle.icon, color: feedbackStyle.foregroundColor), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: TextStyle(color: feedbackStyle.foregroundColor), - ), + adaptiveFeedbackHostKey.currentState?.show( + message: message, + style: style, + duration: duration, + actionLabel: actionLabel, + onAction: onAction, + ); +} + +/// App-level host for feedback banners, mounted above the [Navigator] (via +/// `MaterialApp.builder`). +/// +/// A [SnackBar] can't be used here: the root [ScaffoldMessenger] renders the +/// current snackbar in BOTH the outgoing and incoming route's Scaffold during +/// a page transition, and since pages with and without the bottom nav give it +/// different bottom offsets, the same bar briefly shows twice ("ghosting"). +/// Hosting a single banner above the Navigator means there is exactly one +/// instance, and its bottom clearance simply animates in step with the route +/// transition: flush-ish near the screen edge on pushed pages, floating above +/// the glass capsule (with a larger corner radius) on shell pages. +class AdaptiveFeedbackHost extends StatefulWidget { + final Widget child; + + const AdaptiveFeedbackHost({super.key, required this.child}); + + @override + State createState() => AdaptiveFeedbackHostState(); +} + +class AdaptiveFeedbackHostState extends State + with SingleTickerProviderStateMixin { + // Matches the FadeThroughTransition page-transition timing so the banner + // glides together with the bottom nav's appearance. + static const _repositionDuration = Duration(milliseconds: 300); + static const _repositionCurve = Curves.easeInOutCubic; + + late final AnimationController _controller; + late final Animation _fade; + late final Animation _slide; + + _FeedbackEntry? _entry; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + // The host is (re)built before the Navigator pushes its initial route + // (fresh start and hot restart alike), so the route count starts clean. + _pageRouteDepth.value = 0; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + reverseDuration: const Duration(milliseconds: 200), + ); + _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + _slide = Tween( + begin: const Offset(0, 0.4), + end: Offset.zero, + ).animate(_fade); + } + + void show({ + required String message, + required AdaptiveFeedbackStyle style, + required Duration duration, + String? actionLabel, + VoidCallback? onAction, + }) { + _dismissTimer?.cancel(); + setState(() { + _entry = _FeedbackEntry( + message: message, + style: style, + actionLabel: actionLabel, + onAction: onAction, + ); + }); + _controller.forward(from: _controller.value == 0 ? 0 : _controller.value); + _dismissTimer = Timer(duration, dismiss); + } + + void dismiss() { + _dismissTimer?.cancel(); + _dismissTimer = null; + unawaited( + _controller.reverse().whenComplete(() { + if (mounted && _dismissTimer == null) setState(() => _entry = null); + }), + ); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (_entry != null) + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _pageRouteDepth, + builder: (context, depth, banner) { + final width = MediaQuery.sizeOf(context).width; + final safeBottom = MediaQuery.viewPaddingOf(context).bottom; + final navVisible = depth <= 1 && width < 600; + final navClearance = isIos() + ? 52.0 + 12 + : TgBottomNavBar.barHeight + 2 * TgBottomNavBar.margin; + final bottom = safeBottom + (navVisible ? navClearance : 12); + return IgnorePointer( + ignoring: _entry!.actionLabel == null, + child: AnimatedPadding( + duration: _repositionDuration, + curve: _repositionCurve, + padding: EdgeInsets.fromLTRB(12, 0, 12, bottom), + child: Align( + alignment: Alignment.bottomCenter, + child: FadeTransition( + opacity: _fade, + child: SlideTransition( + position: _slide, + child: _FeedbackBanner( + entry: _entry!, + rounded: navVisible, + repositionDuration: _repositionDuration, + repositionCurve: _repositionCurve, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _FeedbackEntry { + const _FeedbackEntry({ + required this.message, + required this.style, + this.actionLabel, + this.onAction, + }); + + final String message; + final AdaptiveFeedbackStyle style; + final String? actionLabel; + final VoidCallback? onAction; +} + +class _FeedbackBanner extends StatelessWidget { + const _FeedbackBanner({ + required this.entry, + required this.rounded, + required this.repositionDuration, + required this.repositionCurve, + }); + + final _FeedbackEntry entry; + final bool rounded; + final Duration repositionDuration; + final Curve repositionCurve; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = _FeedbackStyle.from(theme, entry.style); + final radius = BorderRadius.circular(rounded ? 16 : 10); + + return AnimatedContainer( + duration: repositionDuration, + curve: repositionCurve, + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: radius, + boxShadow: kElevationToShadow[6], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(style.icon, color: style.foregroundColor), + const SizedBox(width: 12), + Flexible( + child: Text( + entry.message, + style: theme.textTheme.bodyMedium + ?.copyWith(color: style.foregroundColor), + ), + ), + if (entry.actionLabel != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: () { + entry.onAction?.call(); + adaptiveFeedbackHostKey.currentState?.dismiss(); + }, + child: Text(entry.actionLabel!), ), ], - ), - duration: duration, - action: actionLabel != null && onAction != null - ? SnackBarAction(label: actionLabel, onPressed: onAction) - : null, + ], ), ); + } } class _FeedbackStyle {