refactor(library): share the track selection bar and delete action

The Library list's multi-select toolbar (FLAC upgrade, re-enrich, convert, ReplayGain add/remove, delete) becomes LibraryTrackSelectionBar, and its delete flow becomes deleteLibraryTracks. The Library list renders and behaves as before; other screens can now offer the same actions.
This commit is contained in:
zarzet committed 2026-10-01 18:46:21 +07:00
1 parent a549671c4e
commit 86cf860288
5 files changed
+210 -172

No files matched your search

+1 -2
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
@@ -16,6 +15,7 @@ import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/widgets/app_search_field.dart';
import 'package:spotiflac_android/widgets/app_snack_bar.dart';
import 'package:spotiflac_android/widgets/library_search_results.dart';
import 'package:spotiflac_android/widgets/library_track_selection_bar.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -56,7 +56,6 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/smoothed_progress.dart';
import 'package:spotiflac_android/widgets/scroll_edge_fade.dart';
+10 -104
View File
@@ -11,11 +11,8 @@ extension _QueueTabItemWidgets on _QueueTabState {
final allSelected =
selectedCount == unifiedItems.length && unifiedItems.isNotEmpty;
final localOnlySelection = _isLocalOnlySelection(unifiedItems);
final flacEligibleCount = _selectedFlacEligibleLocalItems(
unifiedItems,
).length;
return SelectionBottomBar(
return LibraryTrackSelectionBar(
selectedCount: selectedCount,
allSelected: allSelected,
onClose: _exitSelectionMode,
@@ -27,106 +24,15 @@ extension _QueueTabItemWidgets on _QueueTabState {
}
},
bottomPadding: bottomPadding,
children: [
LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final columns = (constraints.maxWidth / 320).floor().clamp(2, 4);
final itemWidth =
(constraints.maxWidth - spacing * (columns - 1)) / columns;
final actions = <Widget>[];
if (localOnlySelection && flacEligibleCount > 0) {
actions.add(
SelectionActionButton(
icon: Icons.download_for_offline_outlined,
label: '${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: () => _queueSelectedLocalAsFlac(unifiedItems),
colorScheme: colorScheme,
),
);
}
actions.add(
SelectionActionButton(
icon: Icons.auto_fix_high_outlined,
label: '${context.l10n.trackReEnrich} ($selectedCount)',
onPressed: selectedCount > 0
? () => _reEnrichSelectedFromQueue(unifiedItems)
: null,
colorScheme: colorScheme,
),
);
actions.add(
SelectionActionButton(
icon: Icons.swap_horiz,
label: context.l10n.selectionConvertCount(selectedCount),
onPressed: selectedCount > 0
? () => _showBatchConvertSheet(context, unifiedItems)
: null,
colorScheme: colorScheme,
),
);
for (final remove in [false, true]) {
actions.add(
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => _runBatchReplayGain(unifiedItems, remove: remove)
: null,
colorScheme: colorScheme,
),
);
}
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final action in actions)
SizedBox(width: itemWidth, child: action),
],
);
},
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: AppActionButton(
isDestructive: true,
onPressed: selectedCount > 0
? () => _deleteSelected(unifiedItems)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? context.l10n.selectionDeleteTracksCount(selectedCount)
: context.l10n.selectionSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
],
flacEligibleCount: localOnlySelection
? _selectedFlacEligibleLocalItems(unifiedItems).length
: 0,
onQueueFlac: () => _queueSelectedLocalAsFlac(unifiedItems),
onReEnrich: () => _reEnrichSelectedFromQueue(unifiedItems),
onConvert: () => _showBatchConvertSheet(context, unifiedItems),
onReplayGain: ({required remove}) =>
_runBatchReplayGain(unifiedItems, remove: remove),
onDelete: () => _deleteSelected(unifiedItems),
);
}
+8 -66
View File
@@ -402,72 +402,14 @@ extension _QueueTabSelectionActions on _QueueTabState {
);
}
Future<void> _deleteSelected(List<UnifiedLibraryItem> allItems) async {
final count = _selectedIds.length;
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.dialogDeleteSelectedTitle),
content: Text(context.l10n.dialogDeleteSelectedMessage(count)),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final localLibraryDb = LibraryDatabase.instance;
final itemsById = {for (final item in allItems) item.id: item};
int deletedCount = 0;
for (final id in _selectedIds) {
final item = itemsById[id];
if (item != null) {
final cleanPath = _cleanFilePath(item.filePath);
final fileDeleted = await deleteFile(cleanPath);
if (!fileDeleted) continue;
if (item.source == LibraryItemSource.downloaded) {
historyNotifier.removeFromHistory(item.historyItem!.id);
} else {
await localLibraryDb.deleteByPath(item.filePath);
}
deletedCount++;
}
}
if (allItems.any(
(i) =>
_selectedIds.contains(i.id) && i.source == LibraryItemSource.local,
)) {
ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
}
}
Future<void> _deleteSelected(List<UnifiedLibraryItem> allItems) =>
deleteLibraryTracks(
context,
ref,
_selectedItemsFromAll(allItems),
isActive: () => mounted,
onComplete: _exitSelectionMode,
);
String _cleanFilePath(String? filePath) {
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
@@ -16,6 +16,7 @@ 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/file_access.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -508,3 +509,63 @@ Future<({List<Track> tracks, int skipped})> matchLocalTracksForFlac(
}
return (tracks: tracks, skipped: skipped);
}
/// Confirms, then deletes the files behind [selected] and removes their
/// history or local-index rows. Files that cannot be deleted keep their rows.
Future<void> deleteLibraryTracks(
BuildContext context,
WidgetRef ref,
List<UnifiedLibraryItem> selected, {
required bool Function() isActive,
required VoidCallback onComplete,
}) async {
if (selected.isEmpty) return;
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(context.l10n.dialogDeleteSelectedTitle),
content: Text(context.l10n.dialogDeleteSelectedMessage(selected.length)),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.l10n.dialogDelete),
),
],
),
);
if (confirmed != true || !isActive() || !context.mounted) return;
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final messenger = ScaffoldMessenger.of(context);
final l10n = context.l10n;
var deletedCount = 0;
for (final item in selected) {
final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath(
item.filePath,
);
if (!await deleteFile(cleanPath)) continue;
if (item.source == LibraryItemSource.downloaded) {
historyNotifier.removeFromHistory(item.historyItem!.id);
} else {
await LibraryDatabase.instance.deleteByPath(item.filePath);
}
deletedCount++;
}
if (selected.any((item) => item.source == LibraryItemSource.local)) {
ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
onComplete();
messenger.showSnackBar(
SnackBar(content: Text(l10n.snackbarDeletedTracks(deletedCount))),
);
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
/// Multi-select toolbar for Library tracks: FLAC upgrade (local selections
/// only), re-enrich, convert, ReplayGain add/remove and delete. Shared by the
/// Library list and Library search results so both expose the same actions.
class LibraryTrackSelectionBar extends StatelessWidget {
const LibraryTrackSelectionBar({
super.key,
required this.selectedCount,
required this.allSelected,
required this.onClose,
required this.onToggleSelectAll,
required this.bottomPadding,
required this.onReEnrich,
required this.onConvert,
required this.onReplayGain,
required this.onDelete,
this.flacEligibleCount = 0,
this.onQueueFlac,
});
final int selectedCount;
final bool allSelected;
final VoidCallback onClose;
final VoidCallback onToggleSelectAll;
final double bottomPadding;
final VoidCallback onReEnrich;
final VoidCallback onConvert;
final void Function({required bool remove}) onReplayGain;
final VoidCallback onDelete;
/// FLAC upgrade is offered when this is positive and [onQueueFlac] is set.
final int flacEligibleCount;
final VoidCallback? onQueueFlac;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final hasSelection = selectedCount > 0;
return SelectionBottomBar(
selectedCount: selectedCount,
allSelected: allSelected,
onClose: onClose,
onToggleSelectAll: onToggleSelectAll,
bottomPadding: bottomPadding,
children: [
LayoutBuilder(
builder: (context, constraints) {
const spacing = 8.0;
final columns = (constraints.maxWidth / 320).floor().clamp(2, 4);
final itemWidth =
(constraints.maxWidth - spacing * (columns - 1)) / columns;
final actions = <Widget>[
if (onQueueFlac != null && flacEligibleCount > 0)
SelectionActionButton(
icon: Icons.download_for_offline_outlined,
label: '${context.l10n.queueFlacAction} ($flacEligibleCount)',
onPressed: onQueueFlac,
colorScheme: colorScheme,
),
SelectionActionButton(
icon: Icons.auto_fix_high_outlined,
label: '${context.l10n.trackReEnrich} ($selectedCount)',
onPressed: hasSelection ? onReEnrich : null,
colorScheme: colorScheme,
),
SelectionActionButton(
icon: Icons.swap_horiz,
label: context.l10n.selectionConvertCount(selectedCount),
onPressed: hasSelection ? onConvert : null,
colorScheme: colorScheme,
),
for (final remove in [false, true])
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: hasSelection
? () => onReplayGain(remove: remove)
: null,
colorScheme: colorScheme,
),
];
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final action in actions)
SizedBox(width: itemWidth, child: action),
],
);
},
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: AppActionButton(
isDestructive: true,
onPressed: hasSelection ? onDelete : null,
icon: const Icon(Icons.delete_outline),
label: Text(
hasSelection
? context.l10n.selectionDeleteTracksCount(selectedCount)
: context.l10n.selectionSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: hasSelection
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: hasSelection
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
],
);
}
}