diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index 49e996df..e0b25f6c 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -1065,10 +1065,10 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { ); await _checkEmbeddedLyrics(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(reEnrichCompletionMessage(context.l10n, result)), - ), + messenger.hideCurrentSnackBar(); + await showReEnrichResultDialog( + context, + message: reEnrichCompletionMessage(context.l10n, result), ); } } else if (method == 'ffmpeg') { @@ -1138,13 +1138,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { safUri, ); if (!ok && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed( - context.l10n.snackbarFailedToWriteStorage, - ), - ), + messenger.hideCurrentSnackBar(); + await showReEnrichResultDialog( + context, + message: context.l10n.trackSaveFailed( + context.l10n.snackbarFailedToWriteStorage, ), ); return; @@ -1171,17 +1169,17 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { ); await _checkEmbeddedLyrics(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - reEnrichCompletionMessage(context.l10n, result), - ), - ), + messenger.hideCurrentSnackBar(); + await showReEnrichResultDialog( + context, + message: reEnrichCompletionMessage(context.l10n, result), ); } } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)), + messenger.hideCurrentSnackBar(); + await showReEnrichResultDialog( + context, + message: context.l10n.trackReEnrichFfmpegFailed, ); } } finally { @@ -1201,19 +1199,19 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { result['error'], fallback: context.l10n.metadataSaveFailedFfmpeg, ); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(error))), + messenger.hideCurrentSnackBar(); + await showReEnrichResultDialog( + context, + message: context.l10n.trackSaveFailed(error), ); } } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.trackSaveFailed(context.friendlyError(e)), - ), - ), + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + await showReEnrichResultDialog( + context, + message: context.l10n.trackSaveFailed(context.friendlyError(e)), ); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 7e996c34..a6246958 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_snack_bar.dart'; +import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index 95bb75f5..dc0a3c1e 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -303,10 +303,13 @@ Future reEnrichLocalTracks( } if (previews.isEmpty) { - onSelectionRestore(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), + await showReEnrichResultDialog( + context, + message: context.l10n.trackReEnrichNoChanges, ); + // Restore after the result closes so root-overlay fallback bars cannot + // cover this dialog either. Selected IDs remain intact throughout. + if (context.mounted && isActive()) onSelectionRestore(); return; } @@ -354,7 +357,7 @@ Future reEnrichLocalTracks( ScaffoldMessenger.of(context).clearSnackBars(); final summary = lyricsSummary.message(context.l10n, successCount, total); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(summary))); + await showReEnrichResultDialog(context, message: summary); } /// Runs a batch against one settings snapshot per phase. Dependencies are diff --git a/lib/widgets/re_enrich_review_sheet.dart b/lib/widgets/re_enrich_review_sheet.dart index 5f031f21..9c8f552c 100644 --- a/lib/widgets/re_enrich_review_sheet.dart +++ b/lib/widgets/re_enrich_review_sheet.dart @@ -2,11 +2,31 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +/// Results belong above the Library selection card and metadata sheets. +/// A page snackbar can be painted behind either of those surfaces. +Future showReEnrichResultDialog( + BuildContext context, { + required String message, +}) => showAppDialog( + context: context, + builder: (dialogContext) => AppAlertDialog( + title: Text(dialogContext.l10n.trackReEnrich), + content: Text(message), + actions: [ + AppDialogAction( + onPressed: () => Navigator.pop(dialogContext), + child: Text(MaterialLocalizations.of(dialogContext).okButtonLabel), + ), + ], + ), +); + Future showReEnrichReviewSheet( BuildContext context, { required List previews, diff --git a/test/local_track_batch_actions_test.dart b/test/local_track_batch_actions_test.dart index 5e9b7a55..03edd5be 100644 --- a/test/local_track_batch_actions_test.dart +++ b/test/local_track_batch_actions_test.dart @@ -309,6 +309,10 @@ void main() { active = !leaveDuringRefresh; library.refreshed.complete(); await tester.pumpAndSettle(); + if (!leaveDuringRefresh) { + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + } await action; expect(events, leaveDuringRefresh ? ['hide'] : ['hide', 'complete']); expect(tester.takeException(), isNull); diff --git a/test/re_enrich_feedback_test.dart b/test/re_enrich_feedback_test.dart new file mode 100644 index 00000000..4d40f67d --- /dev/null +++ b/test/re_enrich_feedback_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; +import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); + + @override + Future syncLyricsSettingsToBackend({AppSettings? settings}) async {} +} + +void main() { + for (final mornye in [false, true]) { + testWidgets('result is readable above an open metadata sheet ($mornye)', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showAppBottomSheet( + context: context, + builder: (sheetContext) => SizedBox( + height: 400, + child: TextButton( + onPressed: () => showReEnrichResultDialog( + sheetContext, + message: 'Metadata lookup failed', + ), + child: const Text('Look up metadata'), + ), + ), + ), + child: const Text('Open metadata'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open metadata')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Look up metadata')); + await tester.pumpAndSettle(); + expect(find.text('Metadata lookup failed').hitTestable(), findsOneWidget); + expect(find.text('Look up metadata').hitTestable(), findsNothing); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Look up metadata').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final fails in [false, true]) { + testWidgets( + 'empty/failed preview stays visible before selection returns ($mornye/$fails)', + (tester) async { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + expect(call.method, 'reEnrichFile'); + if (fails) throw PlatformException(code: 'lookup_failed'); + return { + 'method': 'preview', + 'enriched_metadata': {}, + }; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final overlay = SelectionOverlayController(); + addTearDown(overlay.hide); + var restored = 0; + Future? action; + await tester.pumpWidget( + ProviderScope( + overrides: [settingsProvider.overrideWith(_Settings.new)], + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () { + action = reEnrichLocalTracks( + context, + ref, + [ + LocalLibraryItem( + id: 'song', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/song.flac', + scannedAt: DateTime(2026), + ), + ], + isActive: () => true, + onSelectionHide: () async => overlay.hide(), + onSelectionRestore: () { + restored++; + overlay.show( + context, + (_) => const SizedBox( + height: 350, + child: ColoredBox( + color: Colors.blue, + child: Center(child: Text('Selection card')), + ), + ), + ); + }, + onComplete: () => fail('No tags should be applied'), + ); + }, + child: const Text('Re-enrich song'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Re-enrich song')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Review changes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Review changes')); + await tester.pumpAndSettle(); + expect( + find + .text('No metadata changes were found for the selected tracks.') + .hitTestable(), + findsOneWidget, + ); + expect(restored, 0); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + await action; + expect(restored, 1); + expect(find.text('Selection card').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + } +}