Skip to content

Commit 322c9fa

Browse files
committed
feat(duplicates): implement delete all exact duplicates action
Introduces a bulk deletion feature strictly for exact binary duplicates, streamlining the cleanup process. The implementation ensures visually similar media (like burst shots) are ignored to prevent accidental data loss. - Added `deleteAllExactDuplicates` to `DuplicatesViewModel` to strictly target `DuplicateGroup` items while preserving the oldest file. - Implemented `SHOW_CONFIRM_DELETE_ALL_EXACT` preference in `PreferencesRepository` to persist the "Do not ask again" dialog state. - Added `DeleteSweep` action button to `DuplicatesScreen` TopAppBar, conditionally visible only when exact duplicates exist. - Integrated a confirmation dialog with a "Do not ask again" checkbox to warn users about the bulk operation.
1 parent ddecf7e commit 322c9fa

3 files changed

Lines changed: 122 additions & 1 deletion

File tree

app/src/main/java/com/cleansweep/data/repository/PreferencesRepository.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ class PreferencesRepository @Inject constructor(
158158
val SHOW_CONFIRM_FORGET_FOLDER = booleanPreferencesKey("show_confirm_forget_folder")
159159
val SHOW_CONFIRM_RESET_SOURCE_FAVS = booleanPreferencesKey("show_confirm_reset_source_favs")
160160
val SHOW_CONFIRM_RESET_TARGET_FAVS = booleanPreferencesKey("show_confirm_reset_target_favs")
161+
val SHOW_CONFIRM_DELETE_ALL_EXACT = booleanPreferencesKey("show_confirm_delete_all_exact")
161162
val UNSELECT_ALL_IN_SEARCH_SCOPE = stringPreferencesKey("unselect_all_in_search_scope")
162163
}
163164

@@ -453,6 +454,9 @@ class PreferencesRepository @Inject constructor(
453454
val showConfirmResetTargetFavsFlow: Flow<Boolean> = context.dataStore.data
454455
.map { preferences -> preferences[PreferencesKeys.SHOW_CONFIRM_RESET_TARGET_FAVS] ?: true }
455456

457+
val showConfirmDeleteAllExactFlow: Flow<Boolean> = context.dataStore.data
458+
.map { preferences -> preferences[PreferencesKeys.SHOW_CONFIRM_DELETE_ALL_EXACT] ?: true }
459+
456460
val unselectAllInSearchScopeFlow: Flow<UnselectScanScope> = context.dataStore.data
457461
.map { preferences ->
458462
val scopeName = preferences[PreferencesKeys.UNSELECT_ALL_IN_SEARCH_SCOPE] ?: UnselectScanScope.GLOBAL.name
@@ -873,13 +877,18 @@ class PreferencesRepository @Inject constructor(
873877
context.dataStore.edit { prefs -> prefs[PreferencesKeys.SHOW_CONFIRM_RESET_TARGET_FAVS] = enabled }
874878
}
875879

880+
suspend fun setShowConfirmDeleteAllExact(enabled: Boolean) {
881+
context.dataStore.edit { prefs -> prefs[PreferencesKeys.SHOW_CONFIRM_DELETE_ALL_EXACT] = enabled }
882+
}
883+
876884
suspend fun resetDialogConfirmations() {
877885
context.dataStore.edit { preferences ->
878886
preferences[PreferencesKeys.SHOW_CONFIRM_MARK_AS_SORTED] = true
879887
preferences[PreferencesKeys.SHOW_CONFIRM_RESET_ALL_HISTORY] = true
880888
preferences[PreferencesKeys.SHOW_CONFIRM_FORGET_FOLDER] = true
881889
preferences[PreferencesKeys.SHOW_CONFIRM_RESET_SOURCE_FAVS] = true
882890
preferences[PreferencesKeys.SHOW_CONFIRM_RESET_TARGET_FAVS] = true
891+
preferences[PreferencesKeys.SHOW_CONFIRM_DELETE_ALL_EXACT] = true
883892
}
884893
}
885894

app/src/main/java/com/cleansweep/ui/screens/duplicates/DuplicatesScreen.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ fun DuplicatesScreen(
9595
val uiState by viewModel.uiState.collectAsState()
9696
val context = LocalContext.current
9797
var showConfirmDeleteDialog by remember { mutableStateOf(false) }
98+
var showConfirmDeleteAllExactDialog by remember { mutableStateOf(false) }
9899
val displayedUnscannableFiles by viewModel.displayedUnscannableFiles.collectAsState()
99100

100101
val permissionLauncher = rememberLauncherForActivityResult(
@@ -153,6 +154,53 @@ fun DuplicatesScreen(
153154
)
154155
}
155156

157+
if (showConfirmDeleteAllExactDialog) {
158+
var doNotAskAgain by remember { mutableStateOf(false) }
159+
160+
AlertDialog(
161+
onDismissRequest = { showConfirmDeleteAllExactDialog = false },
162+
title = { Text("Delete All Exact Duplicates?") },
163+
text = {
164+
Column {
165+
Text("This will automatically keep the oldest file in each group of exact duplicates and delete the rest.\n\nNote: even groups flagged as exact duplicates might have false positives. Reviewing manually is recommended.")
166+
Spacer(modifier = Modifier.height(16.dp))
167+
Row(
168+
modifier = Modifier
169+
.fillMaxWidth()
170+
.clickable { doNotAskAgain = !doNotAskAgain },
171+
verticalAlignment = Alignment.CenterVertically
172+
) {
173+
Checkbox(
174+
checked = doNotAskAgain,
175+
onCheckedChange = { doNotAskAgain = it }
176+
)
177+
Spacer(modifier = Modifier.width(8.dp))
178+
Text("Do not ask again")
179+
}
180+
}
181+
},
182+
confirmButton = {
183+
Button(
184+
onClick = {
185+
if (doNotAskAgain) {
186+
viewModel.setShowConfirmDeleteAllExact(false)
187+
}
188+
viewModel.deleteAllExactDuplicates()
189+
showConfirmDeleteAllExactDialog = false
190+
},
191+
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
192+
) {
193+
Text("Delete All")
194+
}
195+
},
196+
dismissButton = {
197+
TextButton(onClick = { showConfirmDeleteAllExactDialog = false }) {
198+
Text("Cancel")
199+
}
200+
}
201+
)
202+
}
203+
156204
if (uiState.showUnscannableFilesDialog) {
157205
UnscannableFilesDialog(
158206
filePaths = displayedUnscannableFiles,
@@ -184,6 +232,25 @@ fun DuplicatesScreen(
184232
},
185233
actions = {
186234
if (uiState.scanState == ScanState.Complete) {
235+
val hasExactDuplicates = uiState.resultGroups.any { it is DuplicateGroup }
236+
if (hasExactDuplicates) {
237+
TooltipBox(
238+
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
239+
tooltip = { PlainTooltip { Text("Delete All Exact Duplicates") } },
240+
state = rememberTooltipState()
241+
) {
242+
IconButton(onClick = {
243+
if (uiState.showConfirmDeleteAllExact) {
244+
showConfirmDeleteAllExactDialog = true
245+
} else {
246+
viewModel.deleteAllExactDuplicates()
247+
}
248+
}) {
249+
Icon(Icons.Outlined.DeleteSweep, contentDescription = "Delete All Exact Duplicates", tint = MaterialTheme.colorScheme.error)
250+
}
251+
}
252+
}
253+
187254
TooltipBox(
188255
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
189256
tooltip = { PlainTooltip { Text("New Scan") } },

app/src/main/java/com/cleansweep/ui/screens/duplicates/DuplicatesViewModel.kt

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
*
55
* This program is free software: you can redistribute it and/or modify
66
* it under the terms of the GNU General Public License as published by
7-
87
* the Free Software Foundation, either version 3 of the License, or any later version.
98
*
109
* This program is distributed in the hope that it will be useful,
@@ -88,6 +87,7 @@ data class DuplicatesUiState(
8887
val detailedGroup: ScanResultGroup? = null,
8988
val detailViewColumnCount: Int = 2,
9089
val gridViewColumnCount: Int = 2,
90+
val showConfirmDeleteAllExact: Boolean = true,
9191
// Scan Scope properties
9292
val scanScope: DuplicateScanScope = DuplicateScanScope.ALL_FILES,
9393
val includeList: Set<String> = emptySet(),
@@ -175,6 +175,13 @@ class DuplicatesViewModel @Inject constructor(
175175
}
176176
}
177177

178+
// Collect preference for confirm dialog
179+
viewModelScope.launch {
180+
preferencesRepository.showConfirmDeleteAllExactFlow.collectLatest { show ->
181+
_uiState.update { it.copy(showConfirmDeleteAllExact = show) }
182+
}
183+
}
184+
178185

179186
// One-time check for valid cache to show/hide the "Load" button
180187
viewModelScope.launch {
@@ -471,6 +478,44 @@ class DuplicatesViewModel @Inject constructor(
471478
_uiState.update { it.copy(selectedForDeletion = newSelection, spaceToReclaim = reclaimableSpace) }
472479
}
473480

481+
fun setShowConfirmDeleteAllExact(enabled: Boolean) {
482+
viewModelScope.launch {
483+
preferencesRepository.setShowConfirmDeleteAllExact(enabled)
484+
}
485+
}
486+
487+
fun deleteAllExactDuplicates() {
488+
val allIds = mutableSetOf<String>()
489+
val currentState = _uiState.value
490+
491+
// Only process EXACT duplicates (DuplicateGroup), ignore SimilarGroup
492+
val exactGroups = currentState.resultGroups.filterIsInstance<DuplicateGroup>()
493+
494+
if (exactGroups.isEmpty()) {
495+
_uiState.update { it.copy(toastMessage = "No exact duplicates found.") }
496+
return
497+
}
498+
499+
exactGroups.forEach { group ->
500+
// Sort by date added (oldest first), then by ID for deterministic behavior
501+
val sorted = group.items.sortedWith(compareBy<MediaItem> { it.dateAdded }.thenBy { it.id })
502+
if (sorted.size > 1) {
503+
// Keep the first one (oldest), mark the rest for deletion
504+
val toDelete = sorted.drop(1).map { it.id }
505+
allIds.addAll(toDelete)
506+
}
507+
}
508+
509+
if (allIds.isEmpty()) {
510+
_uiState.update { it.copy(toastMessage = "No duplicates found to delete.") }
511+
return
512+
}
513+
514+
updateSelection(allIds)
515+
// Trigger the existing deletion logic which consumes the selection we just set
516+
deleteSelectedFiles()
517+
}
518+
474519
fun deleteSelectedFiles() {
475520
val currentState = _uiState.value
476521
val itemsToProcess = currentState.selectedForDeletion

0 commit comments

Comments
 (0)