From 799bb0f7a1bfbac3ebb38fe05d74f07d1092ee4d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:34:36 +0700 Subject: [PATCH] fix(ui): make snackbars transient and replace stale messages Limit transient messages to three seconds, keep action snackbars on a timer, and provide close controls. Apply one latest-message policy to the app and metadata sheet while preserving glass animations. --- lib/app.dart | 5 +- lib/screens/track_metadata_edit_sheet.dart | 2 +- lib/widgets/app_snack_bar.dart | 93 ++++++++++++++++++++- lib/widgets/view_queue_snackbar_action.dart | 3 + test/app_snack_bar_test.dart | 93 +++++++++++++++++++-- 5 files changed, 184 insertions(+), 12 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 9f941149..c0e63680 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/l10n/supported_locales.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/widgets/app_snack_bar.dart'; String initialLocationForAppState({ required bool isFirstLaunch, @@ -203,7 +204,9 @@ class SpotiFLACApp extends ConsumerWidget { builder: (context, child) { final mediaQuery = MediaQuery.of(context); final appContent = _OrientationFade( - child: child ?? const SizedBox.shrink(), + child: AppScaffoldMessenger( + child: child ?? const SizedBox.shrink(), + ), ); return MediaQuery( data: mediaQuery.copyWith(displayFeatures: const []), diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index b38d9e40..04c11171 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -2174,7 +2174,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { minChildSize: 0.5, maxChildSize: 0.95, expand: false, - builder: (context, scrollController) => ScaffoldMessenger( + builder: (context, scrollController) => AppScaffoldMessenger( key: _sheetMessengerKey, child: Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/widgets/app_snack_bar.dart b/lib/widgets/app_snack_bar.dart index aba4eae3..f2231507 100644 --- a/lib/widgets/app_snack_bar.dart +++ b/lib/widgets/app_snack_bar.dart @@ -3,6 +3,73 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +/// Applies the same transient-message policy to existing snackbar call sites. +/// A new message replaces the old one instead of building a stale FIFO queue. +class AppScaffoldMessenger extends ScaffoldMessenger { + const AppScaffoldMessenger({super.key, required super.child}); + + @override + ScaffoldMessengerState createState() => _AppScaffoldMessengerState(); +} + +class _AppScaffoldMessengerState extends ScaffoldMessengerState { + @override + ScaffoldFeatureController showSnackBar( + SnackBar snackBar, { + AnimationStyle? snackBarAnimationStyle, + }) { + removeCurrentSnackBar(); + return super.showSnackBar( + _TransientSnackBar(snackBar), + snackBarAnimationStyle: snackBarAnimationStyle, + ); + } +} + +class _TransientSnackBar extends SnackBar { + _TransientSnackBar(this.original) + : super( + content: original.content, + duration: original.duration > const Duration(seconds: 3) + ? const Duration(seconds: 3) + : original.duration, + persist: false, + ); + + final SnackBar original; + + @override + SnackBar withAnimation(Animation newAnimation, {Key? fallbackKey}) { + // Delegate first so custom glass animations remain intact. + final animated = original.withAnimation( + newAnimation, + fallbackKey: fallbackKey, + ); + return SnackBar( + key: animated.key, + content: animated.content, + backgroundColor: animated.backgroundColor, + elevation: animated.elevation, + margin: animated.margin, + padding: animated.padding, + width: animated.width, + shape: animated.shape, + hitTestBehavior: animated.hitTestBehavior, + behavior: animated.behavior, + action: animated.action, + actionOverflowThreshold: animated.actionOverflowThreshold, + showCloseIcon: animated.showCloseIcon ?? true, + closeIconColor: animated.closeIconColor, + duration: duration, + persist: false, + animation: animated.animation, + onVisible: animated.onVisible, + dismissDirection: animated.dismissDirection, + clipBehavior: animated.clipBehavior, + ); + } +} + /// Reports a failure to open a local file with a plain Material snackbar. void showCannotOpenFileSnackBar(BuildContext context, Object error) { ScaffoldMessenger.of(context).showSnackBar( @@ -18,13 +85,19 @@ void showCannotOpenFileSnackBar(BuildContext context, Object error) { ScaffoldFeatureController showAppSnackBar( BuildContext context, { required Widget content, - Duration duration = const Duration(seconds: 4), + Duration duration = const Duration(seconds: 3), SnackBarBehavior? behavior, }) { final messenger = ScaffoldMessenger.of(context); if (!context.isMornye) { return messenger.showSnackBar( - SnackBar(content: content, duration: duration, behavior: behavior), + SnackBar( + content: content, + duration: duration, + behavior: behavior, + persist: false, + showCloseIcon: true, + ), ); } final theme = Theme.of(context); @@ -44,7 +117,17 @@ ScaffoldFeatureController showAppSnackBar( style: theme.textTheme.bodyMedium!.copyWith( color: theme.colorScheme.onSurface, ), - child: content, + child: Row( + children: [ + Expanded(child: content), + IconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + color: theme.colorScheme.onSurface, + onPressed: messenger.hideCurrentSnackBar, + icon: const Icon(Icons.close, size: 20), + ), + ], + ), ), ), ), @@ -63,6 +146,8 @@ class _MornyeSnackBar extends SnackBar { backgroundColor: Colors.transparent, elevation: 0, clipBehavior: Clip.none, + persist: false, + showCloseIcon: false, padding: EdgeInsets.zero, margin: const EdgeInsets.fromLTRB(20, 8, 20, 12), shape: const RoundedRectangleBorder( @@ -79,6 +164,8 @@ class _MornyeSnackBar extends SnackBar { return SnackBar( key: key ?? fallbackKey, duration: duration, + persist: false, + showCloseIcon: false, behavior: behavior, backgroundColor: backgroundColor, elevation: elevation, diff --git a/lib/widgets/view_queue_snackbar_action.dart b/lib/widgets/view_queue_snackbar_action.dart index 4618c4ed..d9c0bdde 100644 --- a/lib/widgets/view_queue_snackbar_action.dart +++ b/lib/widgets/view_queue_snackbar_action.dart @@ -18,6 +18,9 @@ void showAddedToQueueSnackBar(BuildContext context, String trackName) { SnackBar( content: Text(context.l10n.snackbarAddedToQueue(trackName)), action: buildViewQueueSnackBarAction(context), + duration: const Duration(seconds: 3), + persist: false, + showCloseIcon: true, ), ); } diff --git a/test/app_snack_bar_test.dart b/test/app_snack_bar_test.dart index c39598bb..6ddfe658 100644 --- a/test/app_snack_bar_test.dart +++ b/test/app_snack_bar_test.dart @@ -4,9 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; void main() { for (final brightness in Brightness.values) { @@ -22,7 +24,10 @@ void main() { ProviderScope( child: MaterialApp( theme: MornyeTheme.build(brightness), - builder: (_, child) => RepaintBoundary(key: capture, child: child), + builder: (_, child) => RepaintBoundary( + key: capture, + child: AppScaffoldMessenger(child: child!), + ), home: Builder( builder: (context) => Scaffold( body: Stack( @@ -102,13 +107,14 @@ void main() { for (final mornye in [true, false]) { testWidgets( - 'messages still queue, time out and dismiss (Mornye: $mornye)', + 'new messages replace old ones and still dismiss (Mornye: $mornye)', (tester) async { late BuildContext messageContext; await tester.pumpWidget( ProviderScope( child: MaterialApp( theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + builder: (_, child) => AppScaffoldMessenger(child: child!), home: Builder( builder: (context) { messageContext = context; @@ -123,16 +129,15 @@ void main() { content: const Text('First'), duration: const Duration(seconds: 1), ); + await tester.pumpAndSettle(); + expect(find.text('First'), findsOneWidget); final second = showAppSnackBar( messageContext, content: const Text('Second'), ); await tester.pumpAndSettle(); - expect(find.text('First'), findsOneWidget); - expect(find.text('Second'), findsNothing); - await tester.pump(const Duration(seconds: 1)); - await tester.pumpAndSettle(); - expect(await first.closed, SnackBarClosedReason.timeout); + expect(await first.closed, SnackBarClosedReason.remove); + expect(find.text('First'), findsNothing); expect(find.text('Second'), findsOneWidget); await tester.drag(find.text('Second'), const Offset(0, 300)); await tester.pumpAndSettle(); @@ -141,5 +146,79 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets('View Queue times out and has a close button ($mornye)', ( + tester, + ) async { + late BuildContext messageContext; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + builder: (_, child) => AppScaffoldMessenger(child: child!), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + messageContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ), + ); + showAddedToQueueSnackBar(messageContext, 'Track'); + await tester.pumpAndSettle(); + expect(find.text('View Queue'), findsOneWidget); + expect(find.byTooltip('Close'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + + showAddedToQueueSnackBar(messageContext, 'Another track'); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + expect(tester.takeException(), isNull); + }); } + + testWidgets('rapid messages never leave a backlog or persist an action', ( + tester, + ) async { + late BuildContext messageContext; + await tester.pumpWidget( + MaterialApp( + builder: (_, child) => AppScaffoldMessenger(child: child!), + home: Builder( + builder: (context) { + messageContext = context; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ); + final controllers = [ + for (var i = 0; i < 10; i++) + ScaffoldMessenger.of(messageContext).showSnackBar( + SnackBar( + content: Text('Message $i'), + action: SnackBarAction(label: 'Action', onPressed: () {}), + duration: const Duration(minutes: 1), + ), + ), + ]; + await tester.pumpAndSettle(); + expect(find.text('Message 9'), findsOneWidget); + expect(find.text('Message 0'), findsNothing); + for (final controller in controllers.take(9)) { + expect(await controller.closed, SnackBarClosedReason.remove); + } + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(await controllers.last.closed, SnackBarClosedReason.timeout); + expect(find.byType(SnackBar), findsNothing); + expect(tester.takeException(), isNull); + }); }