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.
This commit is contained in:
zarzet committed 2026-10-02 18:34:36 +07:00
1 parent 1eea52c1c2
commit 799bb0f7a1
5 files changed
+184 -12

No files matched your search

+4 -1
View File
@@ -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 []),
+1 -1
View File
@@ -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,
+90 -3
View File
@@ -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<SnackBar, SnackBarClosedReason> 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<double> 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<SnackBar, SnackBarClosedReason> 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<SnackBar, SnackBarClosedReason> 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,
@@ -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,
),
);
}
+86 -7
View File
@@ -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);
});
}