From 09c31d9dba0056b0bb87e6e4b49388aa66b2f8f1 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:04:05 +0700 Subject: [PATCH] refactor(batch): share local track actions and phase settings Use shared coordinators for queueing FLAC replacements and batch metadata enrichment from album and queue screens. Keep selection lifecycle handling in each screen and synchronize a settings snapshot once per phase. Cover cancellation, partial failure, screen disposal, and settings changes between preview and apply. --- lib/providers/settings_provider.dart | 16 +- lib/screens/local_album_screen.dart | 363 +---------------- lib/screens/queue_tab.dart | 8 +- lib/screens/queue_tab_batch_actions.dart | 364 +---------------- lib/services/local_track_batch_actions.dart | 414 ++++++++++++++++++++ test/local_track_batch_actions_test.dart | 389 ++++++++++++++++++ 6 files changed, 853 insertions(+), 701 deletions(-) create mode 100644 lib/services/local_track_batch_actions.dart create mode 100644 test/local_track_batch_actions_test.dart diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 148b7fd0..1b6e373a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -206,22 +206,24 @@ class SettingsNotifier extends Notifier { unawaited(syncLyricsSettingsToBackend()); } - Future syncLyricsSettingsToBackend() async { + Future syncLyricsSettingsToBackend({AppSettings? settings}) async { + final snapshot = settings ?? state; if (!PlatformBridge.supportsCoreBackend) return; try { - await PlatformBridge.setLyricsProviders(state.lyricsProviders); + await PlatformBridge.setLyricsProviders(snapshot.lyricsProviders); } catch (e) { _log.w('Failed to sync lyrics providers to backend: $e'); } try { await PlatformBridge.setLyricsFetchOptions({ - 'include_translation_netease': state.lyricsIncludeTranslationNetease, - 'include_romanization_netease': state.lyricsIncludeRomanizationNetease, - 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, - 'apple_elrc_word_sync': state.lyricsAppleElrcWordSync, - 'musixmatch_language': state.musixmatchLanguage, + 'include_translation_netease': snapshot.lyricsIncludeTranslationNetease, + 'include_romanization_netease': + snapshot.lyricsIncludeRomanizationNetease, + 'multi_person_word_by_word': snapshot.lyricsMultiPersonWordByWord, + 'apple_elrc_word_sync': snapshot.lyricsAppleElrcWordSync, + 'musixmatch_language': snapshot.musixmatchLanguage, }); } catch (e) { _log.w('Failed to sync lyrics fetch options to backend: $e'); diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 40de9f27..39026267 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1,30 +1,21 @@ import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_queue_provider.dart'; -import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart'; -import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; -import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; -import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; -import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; -import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; -import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; -import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; @@ -432,41 +423,6 @@ class _LocalAlbumScreenState extends ConsumerState ); } - Future _reEnrichLocalTrack( - LocalLibraryItem item, { - required List updateFields, - required Map resolvedMetadata, - }) async { - final settings = ref.read(settingsProvider); - final artistTagMode = settings.artistTagMode; - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - final request = buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - resolvedMetadata: resolvedMetadata, - ); - - final result = await PlatformBridge.reEnrichFile(request); - final method = result['method'] as String?; - if (method == 'native') { - // Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin). - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - return true; - } - if (method == 'ffmpeg') { - return applyFfmpegReEnrichResult( - item: item, - result: result, - artistTagMode: artistTagMode, - ); - } - return false; - } - List _selectedFlacEligibleItems( List allTracks, ) { @@ -478,310 +434,31 @@ class _LocalAlbumScreenState extends ConsumerState .toList(growable: false); } - Future _queueSelectedAsFlac(List allTracks) async { - final selected = _selectedFlacEligibleItems(allTracks); - - if (selected.isEmpty) { - return; - } - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.queueFlacAction), - content: Text(context.l10n.queueFlacConfirmMessage(selected.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.queueFlacAction), - ), - ], - ), - ); - - if (confirmed != true || !mounted) { - return; - } - - final settings = ref.read(settingsProvider); - final extensionState = ref.read(extensionProvider); - final includeExtensions = - settings.useExtensionProviders && - extensionState.extensions.any( - (ext) => ext.enabled && ext.hasMetadataProvider, - ); - final targetService = LocalTrackRedownloadService.preferredFlacService( - settings, - extensionState, - ); - if (targetService.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + Future _queueSelectedAsFlac(List allTracks) => + queueLocalTracksAsFlac( + context, + ref, + _selectedFlacEligibleItems(allTracks), + isActive: () => mounted, + onComplete: exitSelectionMode, ); - return; - } - final targetQuality = - LocalTrackRedownloadService.preferredFlacQualityForService( - targetService, - extensionState, - ); - - final matchedTracks = []; - var skippedCount = 0; - final total = selected.length; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.queueFlacAction, - total: total, - icon: Icons.queue_music, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - - BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName); - - try { - final resolution = await LocalTrackRedownloadService.resolveBestMatch( - selected[i], - includeExtensions: includeExtensions, - ); - if (resolution.canQueue && resolution.match != null) { - matchedTracks.add(resolution.match!); - } else { - skippedCount++; - } - } catch (_) { - skippedCount++; - } - } - - if (!mounted) { - return; - } - - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - - if (matchedTracks.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), - ); - return; - } - - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - matchedTracks, - targetService, - qualityOverride: targetQuality, - ); - - final summary = skippedCount == 0 - ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) - : context.l10n.queueFlacQueuedWithSkipped( - matchedTracks.length, - skippedCount, - ); - - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - exitSelectionMode(); - } Future _reEnrichSelected(List allTracks) async { - final tracksById = {for (final t in allTracks) t.id: t}; - final selected = []; - - for (final id in selectedIds) { - final item = tracksById[id]; - if (item != null) { - selected.add(item); - } - } - - if (selected.isEmpty) { - return; - } - - // The bar uses AnimatedPositioned (250ms), so wait for the slide-out. - setState(() => isSelectionMode = false); - await Future.delayed(const Duration(milliseconds: 300)); - if (!mounted) return; - - final selection = await showReEnrichFieldDialog( + final tracksById = {for (final track in allTracks) track.id: track}; + final selected = [for (final id in selectedIds) ?tracksById[id]]; + await reEnrichLocalTracks( context, - selectedCount: selected.length, - ); - - if (selection == null || !mounted) { - // Cancelled — restore selection mode (IDs are still intact). - if (mounted) setState(() => isSelectionMode = true); - return; - } - - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - if (!mounted) return; - final settings = ref.read(settingsProvider); - final previews = []; - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: selection.usesManualValues - ? context.l10n.trackReEnrichPreparing - : context.l10n.trackReEnrichSearching, - total: selected.length, - icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); + ref, + selected, + isActive: () => mounted, + onSelectionHide: () async { + setState(() => isSelectionMode = false); + // Allow the selection bar's 250 ms slide-out to finish. + await Future.delayed(const Duration(milliseconds: 300)); }, + onSelectionRestore: () => setState(() => isSelectionMode = true), + onComplete: exitSelectionMode, ); - - for (var i = 0; i < selected.length; i++) { - if (!mounted || cancelled) break; - final item = selected[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${item.trackName} - ${item.artistName}', - ); - final updateFields = selection.updateFieldsFor(item); - if (updateFields.isEmpty) continue; - if (selection.usesManualValues) { - final preview = buildManualBatchReEnrichPreview(item, selection); - if (preview != null) previews.add(preview); - continue; - } - try { - final result = await PlatformBridge.reEnrichFile( - buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - previewOnly: true, - ), - ); - final rawMetadata = result['enriched_metadata']; - if (result['method'] != 'preview' || rawMetadata is! Map) continue; - final enrichedMetadata = rawMetadata.map( - (key, value) => MapEntry(key.toString(), value), - ); - final changes = buildReEnrichMetadataChanges( - item, - enrichedMetadata, - updateFields, - ); - if (changes.isEmpty) continue; - previews.add( - BatchReEnrichPreview( - item: item, - updateFields: updateFields, - enrichedMetadata: enrichedMetadata, - changes: changes, - ), - ); - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - if (cancelled) { - setState(() => isSelectionMode = true); - return; - } - - if (previews.isEmpty) { - setState(() => isSelectionMode = true); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), - ); - return; - } - - final confirmed = await showReEnrichReviewSheet( - context, - previews: previews, - ); - if (!confirmed || !mounted) { - if (mounted) setState(() => isSelectionMode = true); - return; - } - - var successCount = 0; - final total = previews.length; - cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackReEnrichProgress, - total: total, - icon: Icons.auto_fix_high, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final preview = previews[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${preview.item.trackName} - ${preview.item.artistName}', - ); - try { - final ok = await _reEnrichLocalTrack( - preview.item, - updateFields: preview.updateFields, - resolvedMetadata: preview.enrichedMetadata, - ); - if (ok) successCount++; - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - - try { - if (!ref.read(localLibraryProvider).isScanning) { - await ref.read(localLibraryProvider.notifier).scanAllSources(); - } else { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - } catch (_) { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - - exitSelectionMode(); - - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).clearSnackBars(); - final failedCount = total - successCount; - final summary = failedCount <= 0 - ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' - : context.l10n.trackReEnrichSuccessWithFailures( - successCount, - total, - failedCount, - ); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); } List _selectedUnifiedItems( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 023480a5..e04c813a 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; @@ -17,13 +18,10 @@ import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; -import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/ordered_range_selection.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; -import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -36,14 +34,10 @@ import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; -import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; -import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; -import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; -import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index c9e7c553..fd8494dc 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -1,41 +1,6 @@ part of 'queue_tab.dart'; extension _QueueTabBatchActions on _QueueTabState { - Future _reEnrichQueueLocalTrack( - LocalLibraryItem item, { - required List updateFields, - required Map resolvedMetadata, - }) async { - final settings = ref.read(settingsProvider); - final artistTagMode = settings.artistTagMode; - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - final request = buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - resolvedMetadata: resolvedMetadata, - ); - - final result = await PlatformBridge.reEnrichFile(request); - final method = result['method'] as String?; - if (method == 'native') { - // Filesystem .lrc sidecar (SAF sidecar handled natively in Kotlin). - await writeReEnrichSidecarLrc( - audioFilePath: item.filePath, - reEnrichResult: result, - ); - return true; - } - if (method == 'ffmpeg') { - return applyFfmpegReEnrichResult( - item: item, - result: result, - artistTagMode: artistTagMode, - ); - } - return false; - } - List _selectedFlacEligibleLocalItems( List allItems, ) { @@ -47,321 +12,32 @@ extension _QueueTabBatchActions on _QueueTabState { .toList(growable: false); } - Future _queueSelectedLocalAsFlac( - List allItems, - ) async { - final selectedLocalItems = _selectedFlacEligibleLocalItems(allItems); - - if (selectedLocalItems.isEmpty) { - return; - } - - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(context.l10n.queueFlacAction), - content: Text( - context.l10n.queueFlacConfirmMessage(selectedLocalItems.length), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(context.l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(context.l10n.queueFlacAction), - ), - ], - ), - ); - - if (confirmed != true || !mounted) { - return; - } - - final settings = ref.read(settingsProvider); - final extensionState = ref.read(extensionProvider); - final includeExtensions = - settings.useExtensionProviders && - extensionState.extensions.any( - (ext) => ext.enabled && ext.hasMetadataProvider, - ); - final targetService = LocalTrackRedownloadService.preferredFlacService( - settings, - extensionState, - ); - if (targetService.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + Future _queueSelectedLocalAsFlac(List allItems) => + queueLocalTracksAsFlac( + context, + ref, + _selectedFlacEligibleLocalItems(allItems), + isActive: () => mounted, + onComplete: _exitSelectionMode, ); - return; - } - final targetQuality = - LocalTrackRedownloadService.preferredFlacQualityForService( - targetService, - extensionState, - ); - - final matchedTracks = []; - var skippedCount = 0; - final total = selectedLocalItems.length; - - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.queueFlacAction, - total: total, - icon: Icons.queue_music, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - - BatchProgressDialog.update( - current: i + 1, - detail: selectedLocalItems[i].trackName, - ); - - try { - final resolution = await LocalTrackRedownloadService.resolveBestMatch( - selectedLocalItems[i], - includeExtensions: includeExtensions, - ); - if (resolution.canQueue && resolution.match != null) { - matchedTracks.add(resolution.match!); - } else { - skippedCount++; - } - } catch (_) { - skippedCount++; - } - } - - if (!mounted) { - return; - } - - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } - - if (matchedTracks.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), - ); - return; - } - - ref - .read(downloadQueueProvider.notifier) - .addMultipleToQueue( - matchedTracks, - targetService, - qualityOverride: targetQuality, - ); - - final summary = skippedCount == 0 - ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) - : context.l10n.queueFlacQueuedWithSkipped( - matchedTracks.length, - skippedCount, - ); - - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - _setState(() { - _selectedIds.clear(); - _isSelectionMode = false; - _selectionAnchorId = null; - }); - } Future _reEnrichSelectedLocalFromQueue( List allItems, - ) async { - final selectedItems = _selectedItemsFromAll(allItems); - final selectedLocalItems = selectedItems + ) => reEnrichLocalTracks( + context, + ref, + _selectedItemsFromAll(allItems) .map((item) => item.localItem) .whereType() - .toList(growable: false); - - if (selectedLocalItems.isEmpty) { - return; - } - - // Hide the selection overlay: set the flag (prevents build() from - // re-inserting via postFrameCallback) and remove the entry immediately. - _setState(() => _isSelectionMode = false); - _hideSelectionOverlay(); - - final selection = await showReEnrichFieldDialog( - context, - selectedCount: selectedLocalItems.length, - ); - - if (selection == null || !mounted) { - // Cancelled — restore selection mode; the next build cycle will - // re-create the overlay via _syncSelectionOverlay in postFrameCallback. - if (mounted) _setState(() => _isSelectionMode = true); - return; - } - - await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - if (!mounted) return; - final settings = ref.read(settingsProvider); - final previews = []; - var cancelled = false; - BatchProgressDialog.show( - context: context, - title: selection.usesManualValues - ? context.l10n.trackReEnrichPreparing - : context.l10n.trackReEnrichSearching, - total: selectedLocalItems.length, - icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < selectedLocalItems.length; i++) { - if (!mounted || cancelled) break; - final item = selectedLocalItems[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${item.trackName} - ${item.artistName}', - ); - final updateFields = selection.updateFieldsFor(item); - if (updateFields.isEmpty) continue; - if (selection.usesManualValues) { - final preview = buildManualBatchReEnrichPreview(item, selection); - if (preview != null) previews.add(preview); - continue; - } - try { - final result = await PlatformBridge.reEnrichFile( - buildBatchReEnrichRequest( - item: item, - settings: settings, - updateFields: updateFields, - previewOnly: true, - ), - ); - final rawMetadata = result['enriched_metadata']; - if (result['method'] != 'preview' || rawMetadata is! Map) continue; - final enrichedMetadata = rawMetadata.map( - (key, value) => MapEntry(key.toString(), value), - ); - final changes = buildReEnrichMetadataChanges( - item, - enrichedMetadata, - updateFields, - ); - if (changes.isEmpty) continue; - previews.add( - BatchReEnrichPreview( - item: item, - updateFields: updateFields, - enrichedMetadata: enrichedMetadata, - changes: changes, - ), - ); - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - if (cancelled) { - _setState(() => _isSelectionMode = true); - return; - } - - if (previews.isEmpty) { - _setState(() => _isSelectionMode = true); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), - ); - return; - } - - final confirmed = await showReEnrichReviewSheet( - context, - previews: previews, - ); - if (!confirmed || !mounted) { - if (mounted) _setState(() => _isSelectionMode = true); - return; - } - - var successCount = 0; - final total = previews.length; - cancelled = false; - BatchProgressDialog.show( - context: context, - title: context.l10n.trackReEnrichProgress, - total: total, - icon: Icons.auto_fix_high, - onCancel: () { - cancelled = true; - BatchProgressDialog.dismiss(context); - }, - ); - - for (var i = 0; i < total; i++) { - if (!mounted || cancelled) break; - final preview = previews[i]; - BatchProgressDialog.update( - current: i + 1, - detail: '${preview.item.trackName} - ${preview.item.artistName}', - ); - try { - final ok = await _reEnrichQueueLocalTrack( - preview.item, - updateFields: preview.updateFields, - resolvedMetadata: preview.enrichedMetadata, - ); - if (ok) successCount++; - } catch (_) {} - } - - if (!mounted) return; - if (!cancelled) BatchProgressDialog.dismiss(context); - - try { - if (!ref.read(localLibraryProvider).isScanning) { - await ref.read(localLibraryProvider.notifier).scanAllSources(); - } else { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - } catch (_) { - await ref.read(localLibraryProvider.notifier).reloadFromStorage(); - } - - _exitSelectionMode(); - - if (!mounted) { - return; - } - - ScaffoldMessenger.of(context).clearSnackBars(); - final failedCount = total - successCount; - final summary = failedCount <= 0 - ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' - : context.l10n.trackReEnrichSuccessWithFailures( - successCount, - total, - failedCount, - ); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(summary))); - } + .toList(growable: false), + isActive: () => mounted, + onSelectionHide: () async { + _setState(() => _isSelectionMode = false); + _hideSelectionOverlay(); + }, + onSelectionRestore: () => _setState(() => _isSelectionMode = true), + onComplete: _exitSelectionMode, + ); /// Share selected tracks via system share sheet Future _shareSelected(List allItems) async { diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart new file mode 100644 index 00000000..e05f5dba --- /dev/null +++ b/lib/services/local_track_batch_actions.dart @@ -0,0 +1,414 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/local_track_redownload_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; +import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; +import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; + +Future queueLocalTracksAsFlac( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.queueFlacAction), + content: Text(context.l10n.queueFlacConfirmMessage(selected.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.queueFlacAction), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted || !isActive()) { + return; + } + + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final includeExtensions = + settings.useExtensionProviders && + extensionState.extensions.any( + (ext) => ext.enabled && ext.hasMetadataProvider, + ); + final targetService = LocalTrackRedownloadService.preferredFlacService( + settings, + extensionState, + ); + if (targetService.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + final targetQuality = + LocalTrackRedownloadService.preferredFlacQualityForService( + targetService, + extensionState, + ); + + final total = selected.length; + + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.queueFlacAction, + total: total, + icon: Icons.queue_music, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + final matches = await matchLocalTracksForFlac( + selected, + resolve: (item) => LocalTrackRedownloadService.resolveBestMatch( + item, + includeExtensions: includeExtensions, + ), + shouldStop: () => cancelled || !context.mounted || !isActive(), + onProgress: (index, item) => + BatchProgressDialog.update(current: index + 1, detail: item.trackName), + ); + final matchedTracks = matches.tracks; + final skippedCount = matches.skipped; + + if (!context.mounted || !isActive()) { + return; + } + + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } + + if (matchedTracks.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.queueFlacNoReliableMatches)), + ); + return; + } + + ref + .read(downloadQueueProvider.notifier) + .addMultipleToQueue( + matchedTracks, + targetService, + qualityOverride: targetQuality, + ); + + final summary = skippedCount == 0 + ? context.l10n.snackbarAddedTracksToQueue(matchedTracks.length) + : context.l10n.queueFlacQueuedWithSkipped( + matchedTracks.length, + skippedCount, + ); + + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(summary))); + onComplete(); +} + +Future reEnrichLocalTracks( + BuildContext context, + WidgetRef ref, + List selected, { + required bool Function() isActive, + required Future Function() onSelectionHide, + required VoidCallback onSelectionRestore, + required VoidCallback onComplete, +}) async { + if (selected.isEmpty) return; + // Capture a stable route context before a caller removes its overlay. + context = Navigator.of(context, rootNavigator: true).context; + await onSelectionHide(); + if (!context.mounted || !isActive()) return; + final selection = await showReEnrichFieldDialog( + context, + selectedCount: selected.length, + ); + + if (selection == null || !context.mounted || !isActive()) { + // Cancelled — restore selection mode (IDs are still intact). + if (context.mounted && isActive()) onSelectionRestore(); + return; + } + + final runner = BatchReEnrichRunner( + beginPhase: () async { + final settings = ref.read(settingsProvider); + await ref + .read(settingsProvider.notifier) + .syncLyricsSettingsToBackend(settings: settings); + return settings; + }, + ); + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: selection.usesManualValues + ? context.l10n.trackReEnrichPreparing + : context.l10n.trackReEnrichSearching, + total: selected.length, + icon: selection.usesManualValues ? Icons.edit_note : Icons.manage_search, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + final previews = await runner.preview( + selected, + selection, + shouldStop: () => cancelled || !context.mounted || !isActive(), + onProgress: (index, item) => BatchProgressDialog.update( + current: index + 1, + detail: '${item.trackName} - ${item.artistName}', + ), + ); + + if (!context.mounted || !isActive()) return; + if (!cancelled) BatchProgressDialog.dismiss(context); + if (cancelled) { + onSelectionRestore(); + return; + } + + if (previews.isEmpty) { + onSelectionRestore(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), + ); + return; + } + + final confirmed = await showReEnrichReviewSheet(context, previews: previews); + if (!confirmed || !context.mounted || !isActive()) { + if (context.mounted && isActive()) onSelectionRestore(); + return; + } + + final total = previews.length; + cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackReEnrichProgress, + total: total, + icon: Icons.auto_fix_high, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + final successCount = await runner.apply( + previews, + shouldStop: () => cancelled || !context.mounted || !isActive(), + onProgress: (index, item) => BatchProgressDialog.update( + current: index + 1, + detail: '${item.trackName} - ${item.artistName}', + ), + ); + + if (!context.mounted || !isActive()) return; + if (!cancelled) BatchProgressDialog.dismiss(context); + + try { + if (!ref.read(localLibraryProvider).isScanning) { + await ref.read(localLibraryProvider.notifier).scanAllSources(); + } else { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + } catch (_) { + await ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + + if (!context.mounted || !isActive()) return; + onComplete(); + + ScaffoldMessenger.of(context).clearSnackBars(); + final failedCount = total - successCount; + final summary = failedCount <= 0 + ? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)' + : context.l10n.trackReEnrichSuccessWithFailures( + successCount, + total, + failedCount, + ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(summary))); +} + +/// Runs a batch against one settings snapshot per phase. Dependencies are +/// injectable so cancellation, dispatch, and partial failure can be verified +/// without writing media files or opening dialogs. +class BatchReEnrichRunner { + final Future Function() beginPhase; + final Future> Function(Map) reEnrich; + final Future Function({ + required String audioFilePath, + required Map reEnrichResult, + }) + writeSidecar; + final Future Function({ + required LocalLibraryItem item, + required Map result, + required String artistTagMode, + }) + applyFfmpeg; + + BatchReEnrichRunner({ + required this.beginPhase, + this.reEnrich = PlatformBridge.reEnrichFile, + this.writeSidecar = writeReEnrichSidecarLrc, + this.applyFfmpeg = applyFfmpegReEnrichResult, + }); + + Future> preview( + List items, + ReEnrichFieldSelection selection, { + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, + }) async { + if (shouldStop()) return []; + final settings = await beginPhase(); + final previews = []; + for (var index = 0; index < items.length; index++) { + if (shouldStop()) break; + final item = items[index]; + onProgress(index, item); + final fields = selection.updateFieldsFor(item); + if (fields.isEmpty) continue; + if (selection.usesManualValues) { + final preview = buildManualBatchReEnrichPreview(item, selection); + if (preview != null) previews.add(preview); + continue; + } + try { + final result = await reEnrich( + buildBatchReEnrichRequest( + item: item, + settings: settings, + updateFields: fields, + previewOnly: true, + ), + ); + final rawMetadata = result['enriched_metadata']; + if (result['method'] != 'preview' || rawMetadata is! Map) continue; + final metadata = rawMetadata.map( + (key, value) => MapEntry(key.toString(), value), + ); + final changes = buildReEnrichMetadataChanges(item, metadata, fields); + if (changes.isEmpty) continue; + previews.add( + BatchReEnrichPreview( + item: item, + updateFields: fields, + enrichedMetadata: metadata, + changes: changes, + ), + ); + } catch (_) { + // A failed lookup must not prevent review of other tracks. + } + } + return previews; + } + + Future apply( + List previews, { + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, + }) async { + if (shouldStop()) return 0; + // Settings may have changed while the review sheet was open. + final settings = await beginPhase(); + var successes = 0; + for (var index = 0; index < previews.length; index++) { + if (shouldStop()) break; + final preview = previews[index]; + onProgress(index, preview.item); + try { + final result = await reEnrich( + buildBatchReEnrichRequest( + item: preview.item, + settings: settings, + updateFields: preview.updateFields, + resolvedMetadata: preview.enrichedMetadata, + ), + ); + switch (result['method']) { + case 'native': + await writeSidecar( + audioFilePath: preview.item.filePath, + reEnrichResult: result, + ); + successes++; + case 'ffmpeg': + if (await applyFfmpeg( + item: preview.item, + result: result, + artistTagMode: settings.artistTagMode, + )) { + successes++; + } + } + } catch (_) { + // Keep successful files and continue with the rest of the selection. + } + } + return successes; + } +} + +/// Cancellation stops further matching and retains reliable matches already +/// resolved, matching the queue action's existing partial-completion behavior. +Future<({List tracks, int skipped})> matchLocalTracksForFlac( + List items, { + required Future Function(LocalLibraryItem) + resolve, + required bool Function() shouldStop, + required void Function(int, LocalLibraryItem) onProgress, +}) async { + final tracks = []; + var skipped = 0; + for (var index = 0; index < items.length; index++) { + if (shouldStop()) break; + onProgress(index, items[index]); + try { + final resolution = await resolve(items[index]); + if (resolution.canQueue && resolution.match != null) { + tracks.add(resolution.match!); + } else { + skipped++; + } + } catch (_) { + skipped++; + } + } + return (tracks: tracks, skipped: skipped); +} diff --git a/test/local_track_batch_actions_test.dart b/test/local_track_batch_actions_test.dart new file mode 100644 index 00000000..d0ade150 --- /dev/null +++ b/test/local_track_batch_actions_test.dart @@ -0,0 +1,389 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/local_track_batch_actions.dart'; +import 'package:spotiflac_android/services/local_track_redownload_service.dart'; + +LocalLibraryItem item(String id) => LocalLibraryItem( + id: id, + trackName: 'Song $id', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/$id.flac', + scannedAt: DateTime(2026), +); +void progress(int index, LocalLibraryItem item) {} +bool running() => false; + +class _BatchSettings extends SettingsNotifier { + final phases = []; + @override + AppSettings build() => const AppSettings(); + @override + Future syncLyricsSettingsToBackend({AppSettings? settings}) async { + phases.add(settings ?? state); + } +} + +class _BatchLibrary extends LocalLibraryNotifier { + final refreshed = Completer(); + bool refreshStarted = false; + @override + LocalLibraryState build() => LocalLibraryState(); + @override + Future scanAllSources({bool forceFullScan = false}) { + refreshStarted = true; + return refreshed.future; + } +} + +void main() { + test( + 'lookup/apply each capture settings once, dispatch native/ffmpeg, and retain partial success', + () async { + var phaseCount = 0; + var currentSettings = const AppSettings(lyricsMode: 'preview'); + final requests = >[]; + final sidecars = []; + final ffmpegModes = []; + final runner = BatchReEnrichRunner( + beginPhase: () async { + phaseCount++; + return currentSettings; + }, + reEnrich: (request) async { + requests.add(request); + if (request['preview_only'] == true) { + return { + 'method': 'preview', + 'enriched_metadata': {'isrc': 'USABC2600001'}, + }; + } + // A mid-phase change must not alter the captured apply settings. + currentSettings = currentSettings.copyWith( + artistTagMode: 'later', + lyricsMode: 'later', + ); + return switch (request['file_path']) { + '/music/1.flac' => {'method': 'native'}, + '/music/2.flac' => {'method': 'ffmpeg'}, + '/music/3.flac' => throw StateError('write failed'), + _ => {'method': 'unsupported'}, + }; + }, + writeSidecar: + ({required audioFilePath, required reEnrichResult}) async { + sidecars.add(audioFilePath); + }, + applyFfmpeg: + ({required item, required result, required artistTagMode}) async { + ffmpegModes.add(artistTagMode); + return true; + }, + ); + final previews = await runner.preview( + [item('1'), item('2'), item('3'), item('4')], + const ReEnrichFieldSelection(mode: ReEnrichBatchMode.isrcOnly), + shouldStop: running, + onProgress: progress, + ); + expect(previews.length, 4); + expect(phaseCount, 1); + currentSettings = const AppSettings( + lyricsMode: 'apply', + artistTagMode: 'split_vorbis', + ); + final successes = await runner.apply( + previews, + shouldStop: running, + onProgress: progress, + ); + expect(successes, 2); + expect(phaseCount, 2); + expect(sidecars, ['/music/1.flac']); + expect(ffmpegModes, ['split_vorbis']); + expect( + requests.take(4).map((request) => request['lyrics_mode']), + everyElement('preview'), + ); + expect( + requests.skip(4).map((request) => request['lyrics_mode']), + everyElement('apply'), + ); + expect( + requests.skip(4).map((request) => request['search_online']), + everyElement(false), + ); + }, + ); + + test( + 'manual previews avoid lookups; cancellation before apply performs no writes or phase sync', + () async { + var phases = 0; + var calls = 0; + final runner = BatchReEnrichRunner( + beginPhase: () async { + phases++; + return const AppSettings(); + }, + reEnrich: (_) async { + calls++; + return {}; + }, + ); + final previews = await runner.preview( + [item('1'), item('2')], + const ReEnrichFieldSelection( + mode: ReEnrichBatchMode.manualValues, + manualValues: {'genre': 'Jazz'}, + ), + shouldStop: running, + onProgress: progress, + ); + expect(previews.length, 2); + expect(calls, 0); + expect( + await runner.apply( + previews, + shouldStop: () => true, + onProgress: progress, + ), + 0, + ); + expect(calls, 0); + expect(phases, 1); + }, + ); + + test( + 'lookup failures do not discard useful previews and cancellation stops subsequent tracks', + () async { + var cancelled = false; + var lookups = 0; + final runner = BatchReEnrichRunner( + beginPhase: () async => const AppSettings(), + reEnrich: (_) async { + lookups++; + if (lookups == 1) throw StateError('lookup failed'); + cancelled = true; + return { + 'method': 'preview', + 'enriched_metadata': {'isrc': 'USABC2600001'}, + }; + }, + ); + final previews = await runner.preview( + [item('1'), item('2'), item('3')], + const ReEnrichFieldSelection(mode: ReEnrichBatchMode.isrcOnly), + shouldStop: () => cancelled, + onProgress: progress, + ); + expect(lookups, 2); + expect(previews.single.item.id, '2'); + }, + ); + + test( + 'FLAC matching retains reliable partial results and counts failures when cancelled', + () async { + var cancelled = false; + var calls = 0; + final result = await matchLocalTracksForFlac( + [item('1'), item('2'), item('3'), item('4'), item('5')], + shouldStop: () => cancelled, + onProgress: progress, + resolve: (local) async { + calls++; + if (local.id == '2') throw StateError('lookup failed'); + if (local.id == '4') cancelled = true; + return LocalTrackRedownloadResolution( + localItem: local, + match: local.id == '3' + ? null + : Track( + id: local.id, + name: local.trackName, + artistName: 'Artist', + albumName: 'Album', + duration: 1, + ), + score: 100, + reason: 'fixture', + ); + }, + ); + expect(calls, 4); + expect(result.tracks.map((track) => track.id), ['1', '4']); + expect(result.skipped, 2); + }, + ); + + for (final leaveDuringRefresh in [false, true]) { + testWidgets( + 'batch completion respects owner lifecycle during refresh: $leaveDuringRefresh', + (tester) async { + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final settings = _BatchSettings(); + final library = _BatchLibrary(); + var active = true; + final events = []; + Future? action; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async { + if (call.method != 'reEnrichFile') { + throw StateError('unexpected call'); + } + final request = + jsonDecode((call.arguments as Map)['request_json'] as String) + as Map; + return request['preview_only'] == true + ? { + 'method': 'preview', + 'enriched_metadata': {'isrc': 'USABC2600001'}, + } + : {'method': 'native'}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(() => settings), + localLibraryProvider.overrideWith(() => library), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () { + action = reEnrichLocalTracks( + context, + ref, + [item('1'), item('2')], + isActive: () => active, + onSelectionHide: () async { + events.add('hide'); + }, + onSelectionRestore: () => events.add('restore'), + onComplete: () => events.add('complete'), + ); + }, + child: const Text('Start'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Review changes')); + await tester.pumpAndSettle(); + expect(settings.phases.length, 1); + await tester.tap(find.text('Apply changes')); + await tester.pumpAndSettle(); + expect(library.refreshStarted, isTrue); + expect(settings.phases.length, 2); + active = !leaveDuringRefresh; + library.refreshed.complete(); + await tester.pumpAndSettle(); + await action; + expect(events, leaveDuringRefresh ? ['hide'] : ['hide', 'complete']); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final overlaySelection in [false, true]) { + testWidgets( + 'cancel restores ${overlaySelection ? 'removed overlay' : 'animated bar'} selection', + (tester) async { + final navigatorKey = GlobalKey(); + final events = []; + OverlayEntry? overlay; + Future? action; + Widget button(BuildContext context, WidgetRef ref) => TextButton( + onPressed: () { + action = reEnrichLocalTracks( + context, + ref, + [item('1')], + isActive: () => true, + onSelectionHide: () async { + events.add('hide'); + if (overlaySelection) { + overlay!.remove(); + } else { + await Future.delayed(const Duration(milliseconds: 300)); + } + }, + onSelectionRestore: () => events.add('restore'), + onComplete: () => events.add('complete'), + ); + }, + child: const Text('Start'), + ); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + navigatorKey: navigatorKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Consumer( + builder: (context, ref, _) { + if (!overlaySelection) return button(context, ref); + return TextButton( + onPressed: () { + overlay = OverlayEntry( + builder: (context) => Material( + child: Center(child: button(context, ref)), + ), + ); + Overlay.of(context).insert(overlay!); + }, + child: const Text('Select'), + ); + }, + ), + ), + ), + ), + ); + if (overlaySelection) { + await tester.tap(find.text('Select')); + await tester.pump(); + } + await tester.tap(find.text('Start')); + await tester.pumpAndSettle(); + expect(events, ['hide']); + expect(navigatorKey.currentState!.canPop(), isTrue); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + await action; + expect(events, ['hide', 'restore']); + expect(tester.takeException(), isNull); + overlay?.dispose(); + }, + ); + } +}