diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 34b643bc..da7254fa 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -854,6 +854,22 @@ "@duplicatesKeepBest": { "description": "Button that deletes all but the highest-quality copy in a duplicate group" }, + "duplicatesKeepBestAll": "Keep best for all", + "@duplicatesKeepBestAll": { + "description": "Button that keeps the highest-quality copy in every duplicate group and deletes the rest" + }, + "duplicatesKeepBestAllMessage": "Keep the best copy of {groupCount} {groupCount, plural, =1{track} other{tracks}} and delete {count} lower-quality {count, plural, =1{copy} other{copies}}?\n\nThe deleted files will be removed from storage.", + "@duplicatesKeepBestAllMessage": { + "description": "Confirmation message for keeping the best copy across every duplicate group", + "placeholders": { + "count": { + "type": "int" + }, + "groupCount": { + "type": "int" + } + } + }, "duplicatesKeepBestMessage": "Delete {count} lower-quality copies of \"{trackName}\"?", "@duplicatesKeepBestMessage": { "description": "Confirmation message for the keep-best action", diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 05c71450..0bbe9076 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -1,7 +1,7 @@ { "@@locale": "es_ES", "@@last_modified": "2026-04-28", - "appName": "SpotiFLAC Móvil", + "appName": "SpotiFLAC Mobile", "@appName": { "description": "App name - DO NOT TRANSLATE" }, @@ -2624,7 +2624,7 @@ } } }, - "tutorialWelcomeTitle": "¡Bienvenido a SpotiFLAC Móvil!", + "tutorialWelcomeTitle": "¡Bienvenido a SpotiFLAC Mobile!", "@tutorialWelcomeTitle": { "description": "Tutorial welcome page title" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index db10abe2..d34cca23 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1551,7 +1551,7 @@ "@searchSortTitleZA": { "description": "Sort option - title descending" }, - "tutorialLibraryTip2": "Tap a track to play it with the built-in player", + "tutorialLibraryTip2": "Ketuk lagu untuk memutarnya dengan pemutar bawaan", "@tutorialLibraryTip2": { "description": "Tutorial library tip 2" }, @@ -6168,6 +6168,8 @@ "duplicatesTitle": "Duplikat", "duplicatesEmpty": "Tidak menemukan lagu duplikat.", "duplicatesKeepBest": "Simpan lagu kualitas terbaik", + "duplicatesKeepBestAll": "Simpan yang terbaik untuk semua", + "duplicatesKeepBestAllMessage": "Simpan salinan terbaik dari {groupCount} lagu dan hapus {count} salinan berkualitas lebih rendah?\n\nFile yang dihapus juga akan dihapus dari penyimpanan.", "duplicatesKeepBestMessage": "Hapus {count} salinan kualitas rendah lagu \"{trackName}\"?", "duplicatesDeleteCopyMessage": "Hapus salinan lagu \"{trackName}\"?", "appearanceForceBlur": "Selalu gunakan efek blur", diff --git a/lib/utils/duplicate_cleanup_policy.dart b/lib/utils/duplicate_cleanup_policy.dart new file mode 100644 index 00000000..e311149a --- /dev/null +++ b/lib/utils/duplicate_cleanup_policy.dart @@ -0,0 +1,39 @@ +import 'package:spotiflac_android/services/library_database.dart'; + +class DuplicateCleanupPlan { + final List retainedEntries; + final List entriesToDelete; + final int groupCount; + + const DuplicateCleanupPlan({ + required this.retainedEntries, + required this.entriesToDelete, + required this.groupCount, + }); +} + +/// Keeps the first (highest-quality) entry from every duplicate group and +/// schedules every remaining copy for deletion. +/// +/// [LibraryDatabase.findIsrcDuplicateGroups] returns entries best-quality +/// first, so this policy deliberately preserves that ordering contract. +DuplicateCleanupPlan buildKeepBestAllDuplicatePlan( + List groups, +) { + final retainedEntries = []; + final entriesToDelete = []; + var groupCount = 0; + + for (final group in groups) { + if (group.entries.length < 2) continue; + groupCount++; + retainedEntries.add(group.entries.first); + entriesToDelete.addAll(group.entries.skip(1)); + } + + return DuplicateCleanupPlan( + retainedEntries: List.unmodifiable(retainedEntries), + entriesToDelete: List.unmodifiable(entriesToDelete), + groupCount: groupCount, + ); +} diff --git a/lib/widgets/duplicate_review_sheet.dart b/lib/widgets/duplicate_review_sheet.dart index 5686d1eb..61b95971 100644 --- a/lib/widgets/duplicate_review_sheet.dart +++ b/lib/widgets/duplicate_review_sheet.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/utils/duplicate_cleanup_policy.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; @@ -41,6 +42,7 @@ class _DuplicateReviewSheetState extends ConsumerState { late final LocalLibraryNotifier _localLibraryNotifier; late Future> _groupsFuture; bool _deletedLocalRows = false; + bool _deleteActionInProgress = false; @override void initState() { @@ -151,37 +153,63 @@ class _DuplicateReviewSheetState extends ConsumerState { _reload(); } + Future _confirmAndDelete({ + required String message, + required List entries, + required List retainedEntries, + }) async { + if (_deleteActionInProgress || entries.isEmpty) return; + setState(() => _deleteActionInProgress = true); + try { + final confirmed = await _confirmDelete(message); + if (confirmed) { + await _deleteEntries(entries, retainedEntries: retainedEntries); + } + } finally { + if (mounted) { + setState(() => _deleteActionInProgress = false); + } + } + } + Future _keepBest(IsrcDuplicateGroup group) async { final toDelete = group.entries.sublist(1); - final confirmed = await _confirmDelete( - context.l10n.duplicatesKeepBestMessage( + await _confirmAndDelete( + message: context.l10n.duplicatesKeepBestMessage( toDelete.length, group.entries.first.trackName, ), + entries: toDelete, + retainedEntries: [group.entries.first], + ); + } + + Future _keepBestAll(List groups) async { + final plan = buildKeepBestAllDuplicatePlan(groups); + await _confirmAndDelete( + message: context.l10n.duplicatesKeepBestAllMessage( + plan.entriesToDelete.length, + plan.groupCount, + ), + entries: plan.entriesToDelete, + retainedEntries: plan.retainedEntries, ); - if (confirmed) { - await _deleteEntries(toDelete, retainedEntries: [group.entries.first]); - } } Future _deleteSingle( IsrcDuplicateGroup group, IsrcDuplicateEntry entry, ) async { - final confirmed = await _confirmDelete( - context.l10n.duplicatesDeleteCopyMessage(entry.trackName), + await _confirmAndDelete( + message: context.l10n.duplicatesDeleteCopyMessage(entry.trackName), + entries: [entry], + retainedEntries: group.entries + .where( + (candidate) => + candidate.source != entry.source || candidate.id != entry.id, + ) + .toList(growable: false), ); - if (confirmed) { - await _deleteEntries( - [entry], - retainedEntries: group.entries - .where( - (candidate) => - candidate.source != entry.source || candidate.id != entry.id, - ) - .toList(growable: false), - ); - } } @override @@ -215,14 +243,50 @@ class _DuplicateReviewSheetState extends ConsumerState { final compact = groups.length <= 12; return ListView.builder( shrinkWrap: compact, - itemCount: groups.length, - itemBuilder: (context, index) => - _buildGroup(context, colorScheme, groups[index]), + itemCount: groups.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return _buildKeepBestAllAction(context, colorScheme, groups); + } + return _buildGroup(context, colorScheme, groups[index - 1]); + }, ); }, ); } + Widget _buildKeepBestAllAction( + BuildContext context, + ColorScheme colorScheme, + List groups, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _deleteActionInProgress + ? null + : () => _keepBestAll(groups), + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ), + icon: _deleteActionInProgress + ? SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onError, + ), + ) + : const Icon(Icons.delete_sweep_outlined), + label: Text(context.l10n.duplicatesKeepBestAll), + ), + ), + ); + } + Widget _buildGroup( BuildContext context, ColorScheme colorScheme, @@ -260,7 +324,9 @@ class _DuplicateReviewSheetState extends ConsumerState { ), ), TextButton( - onPressed: () => _keepBest(group), + onPressed: _deleteActionInProgress + ? null + : () => _keepBest(group), child: Text(context.l10n.duplicatesKeepBest), ), ], @@ -295,7 +361,9 @@ class _DuplicateReviewSheetState extends ConsumerState { : IconButton( tooltip: context.l10n.dialogDelete, icon: Icon(Icons.delete_outline, color: colorScheme.error), - onPressed: () => _deleteSingle(group, group.entries[i]), + onPressed: _deleteActionInProgress + ? null + : () => _deleteSingle(group, group.entries[i]), ), ), ], diff --git a/test/duplicate_cleanup_policy_test.dart b/test/duplicate_cleanup_policy_test.dart new file mode 100644 index 00000000..a5bbcc5e --- /dev/null +++ b/test/duplicate_cleanup_policy_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/utils/duplicate_cleanup_policy.dart'; + +IsrcDuplicateEntry _entry(String id) { + return IsrcDuplicateEntry( + id: id, + source: 'local', + trackName: 'Track $id', + artistName: 'Artist', + albumName: 'Album', + filePath: '/music/$id.flac', + ); +} + +void main() { + test('keep-best-all retains one entry and deletes the rest per group', () { + final firstBest = _entry('first-best'); + final firstLower = _entry('first-lower'); + final firstLowest = _entry('first-lowest'); + final secondBest = _entry('second-best'); + final secondLower = _entry('second-lower'); + + final plan = buildKeepBestAllDuplicatePlan([ + IsrcDuplicateGroup( + isrc: 'FIRST', + entries: [firstBest, firstLower, firstLowest], + ), + IsrcDuplicateGroup(isrc: 'SECOND', entries: [secondBest, secondLower]), + ]); + + expect(plan.groupCount, 2); + expect(plan.retainedEntries, [firstBest, secondBest]); + expect(plan.entriesToDelete, [firstLower, firstLowest, secondLower]); + }); + + test('keep-best-all ignores groups that no longer contain duplicates', () { + final onlyCopy = _entry('only-copy'); + + final plan = buildKeepBestAllDuplicatePlan([ + IsrcDuplicateGroup(isrc: 'ONLY', entries: [onlyCopy]), + const IsrcDuplicateGroup(isrc: 'EMPTY', entries: []), + ]); + + expect(plan.groupCount, 0); + expect(plan.retainedEntries, isEmpty); + expect(plan.entriesToDelete, isEmpty); + }); +}