mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
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:
1 parent
1eea52c1c2
commit
799bb0f7a1
5 files changed
+184
-12
No files matched your search
+4
-1
@@ -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 []),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user