feat: replace batch operation snackbars with progress dialog

Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
This commit is contained in:
zarzet
2026-03-29 18:04:38 +07:00
parent 8918d74bb5
commit 25de009ebc
4 changed files with 323 additions and 67 deletions
+193
View File
@@ -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<void>(
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<BatchProgressDialog> createState() => _BatchProgressDialogState();
}
class _BatchProgressDialogState extends State<BatchProgressDialog> {
@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),
),
],
);
}
}