diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 3a35dee0..c5ee5d1d 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -1164,19 +1165,23 @@ class _DownloadedAlbumScreenState extends ConsumerState { final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackConvertConverting, + total: total, + icon: Icons.transform, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + for (int i = 0; i < total; i++) { - if (!mounted) break; + if (!mounted || cancelled) break; final item = selected[i]; - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionBatchConvertProgress(i + 1, total), - ), - duration: const Duration(seconds: 30), - ), - ); + BatchProgressDialog.update(current: i + 1, detail: item.trackName); try { final metadata = { @@ -1335,6 +1340,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { _exitSelectionMode(); if (mounted) { + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 09ff33df..e7e3f419 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; +import 'package:spotiflac_android/widgets/batch_progress_dialog.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'; @@ -957,16 +958,22 @@ class _LocalAlbumScreenState extends ConsumerState { var skippedCount = 0; final total = selected.length; - for (var i = 0; i < total; i++) { - if (!mounted) break; + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.queueFlacAction, + total: total, + icon: Icons.queue_music, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)), - duration: const Duration(seconds: 30), - ), - ); + 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( @@ -987,7 +994,9 @@ class _LocalAlbumScreenState extends ConsumerState { return; } - ScaffoldMessenger.of(context).clearSnackBars(); + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } if (matchedTracks.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -1063,18 +1072,25 @@ class _LocalAlbumScreenState extends ConsumerState { var successCount = 0; final total = selected.length; + var 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) break; + if (!mounted || cancelled) break; final item = selected[i]; - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', - ), - duration: const Duration(seconds: 30), - ), + BatchProgressDialog.update( + current: i + 1, + detail: '${item.trackName} - ${item.artistName}', ); try { @@ -1114,6 +1130,9 @@ class _LocalAlbumScreenState extends ConsumerState { return; } + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } ScaffoldMessenger.of(context).clearSnackBars(); final failedCount = total - successCount; final summary = failedCount <= 0 @@ -1422,19 +1441,23 @@ class _LocalAlbumScreenState extends ConsumerState { final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackConvertConverting, + total: total, + icon: Icons.transform, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + for (int i = 0; i < total; i++) { - if (!mounted) break; + if (!mounted || cancelled) break; final item = selected[i]; - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionBatchConvertProgress(i + 1, total), - ), - duration: const Duration(seconds: 30), - ), - ); + BatchProgressDialog.update(current: i + 1, detail: item.trackName); try { final metadata = { @@ -1621,6 +1644,9 @@ class _LocalAlbumScreenState extends ConsumerState { _exitSelectionMode(); if (mounted) { + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0dbf003e..46ab17a9 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -28,6 +28,7 @@ import 'package:spotiflac_android/services/history_database.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/downloaded_album_screen.dart'; +import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -4463,15 +4464,24 @@ class _QueueTabState extends ConsumerState { var skippedCount = 0; final total = selectedLocalItems.length; - for (var i = 0; i < total; i++) { - if (!mounted) break; + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.queueFlacAction, + total: total, + icon: Icons.queue_music, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)), - duration: const Duration(seconds: 30), - ), + for (var i = 0; i < total; i++) { + if (!mounted || cancelled) break; + + BatchProgressDialog.update( + current: i + 1, + detail: selectedLocalItems[i].trackName, ); try { @@ -4493,7 +4503,9 @@ class _QueueTabState extends ConsumerState { return; } - ScaffoldMessenger.of(context).clearSnackBars(); + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } if (matchedTracks.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -4567,18 +4579,25 @@ class _QueueTabState extends ConsumerState { var successCount = 0; final total = selectedLocalItems.length; + var 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) break; + if (!mounted || cancelled) break; final item = selectedLocalItems[i]; - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '${context.l10n.trackReEnrichProgress} (${i + 1}/$total)', - ), - duration: const Duration(seconds: 30), - ), + BatchProgressDialog.update( + current: i + 1, + detail: '${item.trackName} - ${item.artistName}', ); try { @@ -4618,6 +4637,9 @@ class _QueueTabState extends ConsumerState { return; } + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } ScaffoldMessenger.of(context).clearSnackBars(); final failedCount = total - successCount; final summary = failedCount <= 0 @@ -4978,19 +5000,23 @@ class _QueueTabState extends ConsumerState { final shouldEmbedLyrics = settings.embedLyrics && settings.lyricsMode != 'external'; + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackConvertConverting, + total: total, + icon: Icons.transform, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + for (int i = 0; i < total; i++) { - if (!mounted) break; + if (!mounted || cancelled) break; final item = selectedItems[i]; - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.selectionBatchConvertProgress(i + 1, total), - ), - duration: const Duration(seconds: 30), - ), - ); + BatchProgressDialog.update(current: i + 1, detail: item.trackName); try { final metadata = { @@ -5244,6 +5270,9 @@ class _QueueTabState extends ConsumerState { _exitSelectionMode(); if (mounted) { + if (!cancelled) { + BatchProgressDialog.dismiss(context); + } ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/widgets/batch_progress_dialog.dart b/lib/widgets/batch_progress_dialog.dart new file mode 100644 index 00000000..457c2bef --- /dev/null +++ b/lib/widgets/batch_progress_dialog.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; + +/// Progress state communicated from caller to dialog via [ValueNotifier]. +class _BatchProgress { + final int current; + final String? detail; + const _BatchProgress({this.current = 0, this.detail}); +} + +/// A reusable progress dialog for batch operations like conversion and +/// re-enrich. Follows the same visual style as [_FetchingProgressDialog] in +/// artist_screen.dart. +/// +/// Uses a static [ValueNotifier] so callers do not need the dialog's +/// [BuildContext] to push updates – unlike `findAncestorStateOfType` which +/// fails because the dialog lives in a separate navigator route. +/// +/// Usage: +/// ```dart +/// var cancelled = false; +/// BatchProgressDialog.show( +/// context: context, +/// title: 'Converting...', +/// total: items.length, +/// icon: Icons.transform, +/// onCancel: () { +/// cancelled = true; +/// BatchProgressDialog.dismiss(context); +/// }, +/// ); +/// +/// for (int i = 0; i < items.length; i++) { +/// if (cancelled) break; +/// BatchProgressDialog.update(current: i + 1, detail: items[i].name); +/// await doWork(items[i]); +/// } +/// +/// BatchProgressDialog.dismiss(context); +/// ``` +class BatchProgressDialog extends StatefulWidget { + final String title; + final int total; + final IconData icon; + final VoidCallback onCancel; + final ValueNotifier<_BatchProgress> _progressNotifier; + + // ignore: prefer_const_constructors_in_immutables + BatchProgressDialog._({ + required this.title, + required this.total, + required this.icon, + required this.onCancel, + required ValueNotifier<_BatchProgress> progressNotifier, + }) : _progressNotifier = progressNotifier; + + // ── Static bookkeeping ────────────────────────────────────────────── + + static ValueNotifier<_BatchProgress>? _activeNotifier; + + /// Show the dialog. Call [update] to push progress, [dismiss] to close. + static void show({ + required BuildContext context, + required String title, + required int total, + required VoidCallback onCancel, + IconData icon = Icons.transform, + }) { + _activeNotifier = ValueNotifier(const _BatchProgress()); + final notifier = _activeNotifier!; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => BatchProgressDialog._( + title: title, + total: total, + icon: icon, + onCancel: onCancel, + progressNotifier: notifier, + ), + ); + } + + /// Update the progress of the currently visible dialog. + /// No [BuildContext] needed – communicates via [ValueNotifier]. + static void update({required int current, String? detail}) { + _activeNotifier?.value = _BatchProgress(current: current, detail: detail); + } + + /// Dismiss the dialog and clean up. + static void dismiss(BuildContext context) { + _activeNotifier = null; + Navigator.of(context, rootNavigator: true).pop(); + } + + @override + State createState() => _BatchProgressDialogState(); +} + +class _BatchProgressDialogState extends State { + @override + void initState() { + super.initState(); + widget._progressNotifier.addListener(_onChanged); + } + + @override + void dispose() { + widget._progressNotifier.removeListener(_onChanged); + super.dispose(); + } + + void _onChanged() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final current = widget._progressNotifier.value.current; + final detail = widget._progressNotifier.value.detail; + final progress = widget.total > 0 ? current / widget.total : 0.0; + + return AlertDialog( + backgroundColor: colorScheme.surfaceContainerHigh, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + SizedBox( + width: 64, + height: 64, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: progress > 0 ? progress : null, + strokeWidth: 4, + backgroundColor: colorScheme.surfaceContainerHighest, + ), + Icon(widget.icon, color: colorScheme.primary, size: 24), + ], + ), + ), + const SizedBox(height: 20), + Text( + widget.title, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + '$current / ${widget.total}', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if (detail != null && detail.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + detail, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress > 0 ? progress : null, + backgroundColor: colorScheme.surfaceContainerHighest, + minHeight: 6, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: widget.onCancel, + child: Text(context.l10n.dialogCancel), + ), + ], + ); + } +}