Skip to content

Commit 276c8c3

Browse files
committed
feat(images): add batch select-to-delete and clear unmounted
Long-press cards to enter selection mode, tap to toggle. Red delete FAB appears only when images are marked. Overflow menu provides "Clear Unmounted" to bulk-delete all non-mounted images. Mounted images are always protected from selection and deletion.
1 parent 9297d5c commit 276c8c3

2 files changed

Lines changed: 204 additions & 51 deletions

File tree

app/src/main/java/com/enginex0/usbmassstorage/ui/ImageManagerScreen.kt

Lines changed: 196 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import androidx.compose.animation.core.rememberInfiniteTransition
1010
import androidx.compose.animation.core.tween
1111
import androidx.compose.foundation.background
1212
import androidx.compose.foundation.border
13+
import androidx.compose.foundation.ExperimentalFoundationApi
14+
import androidx.compose.foundation.combinedClickable
1315
import androidx.compose.foundation.layout.Arrangement
1416
import androidx.compose.foundation.layout.Box
1517
import androidx.compose.foundation.layout.Column
@@ -29,13 +31,19 @@ import androidx.compose.material.icons.Icons
2931
import androidx.compose.material.icons.automirrored.filled.ArrowBack
3032
import androidx.compose.material.icons.filled.Add
3133
import androidx.compose.material.icons.filled.Album
34+
import androidx.compose.material.icons.filled.CheckCircle
35+
import androidx.compose.material.icons.filled.Close
36+
import androidx.compose.material.icons.filled.Delete
37+
import androidx.compose.material.icons.filled.MoreVert
3238
import androidx.compose.material.icons.filled.Storage
3339
import androidx.compose.material3.AlertDialog
3440
import androidx.compose.material3.Button
3541
import androidx.compose.material3.ButtonDefaults
3642
import androidx.compose.material3.Card
3743
import androidx.compose.material3.CardDefaults
3844
import androidx.compose.material3.CircularProgressIndicator
45+
import androidx.compose.material3.DropdownMenu
46+
import androidx.compose.material3.DropdownMenuItem
3947
import androidx.compose.material3.ExperimentalMaterial3Api
4048
import androidx.compose.material3.FloatingActionButton
4149
import androidx.compose.material3.Icon
@@ -123,7 +131,12 @@ fun ImageManagerScreen(
123131
var showCreateSheet by remember { mutableStateOf(false) }
124132
var deleteTarget by remember { mutableStateOf<DiskImage?>(null) }
125133
var exportingPath by remember { mutableStateOf<String?>(null) }
134+
var selected by remember { mutableStateOf(emptySet<String>()) }
135+
var showMenu by remember { mutableStateOf(false) }
136+
var showClearConfirm by remember { mutableStateOf(false) }
137+
var showBatchDelete by remember { mutableStateOf(false) }
126138

139+
val inSelectionMode = selected.isNotEmpty()
127140
val exportSuccessMsg = stringResource(R.string.images_export_success)
128141
val exportFailedMsg = stringResource(R.string.images_export_failed)
129142

@@ -167,29 +180,123 @@ fun ImageManagerScreen(
167180
)
168181
}
169182

183+
if (showClearConfirm) {
184+
val count = images.count { it.name !in mountedNames }
185+
AlertDialog(
186+
onDismissRequest = { showClearConfirm = false },
187+
title = { Text(stringResource(R.string.images_clear_unmounted_title)) },
188+
text = { Text(stringResource(R.string.images_clear_unmounted_message, count)) },
189+
confirmButton = {
190+
TextButton(onClick = {
191+
showClearConfirm = false
192+
scope.launch {
193+
withContext(Dispatchers.IO) {
194+
images.filter { it.name !in mountedNames }
195+
.forEach { it.file.delete() }
196+
}
197+
selected = emptySet()
198+
refreshTrigger++
199+
}
200+
}) {
201+
Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
202+
}
203+
},
204+
dismissButton = {
205+
TextButton(onClick = { showClearConfirm = false }) {
206+
Text(stringResource(R.string.action_cancel))
207+
}
208+
}
209+
)
210+
}
211+
212+
if (showBatchDelete) {
213+
AlertDialog(
214+
onDismissRequest = { showBatchDelete = false },
215+
title = { Text(stringResource(R.string.images_batch_delete_title, selected.size)) },
216+
text = { Text(stringResource(R.string.images_batch_delete_message, selected.size)) },
217+
confirmButton = {
218+
TextButton(onClick = {
219+
val toDelete = selected.toSet()
220+
showBatchDelete = false
221+
scope.launch {
222+
withContext(Dispatchers.IO) {
223+
toDelete.forEach { path ->
224+
val f = java.io.File(path)
225+
if (f.name !in mountedNames) f.delete()
226+
}
227+
}
228+
selected = emptySet()
229+
refreshTrigger++
230+
}
231+
}) {
232+
Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error)
233+
}
234+
},
235+
dismissButton = {
236+
TextButton(onClick = { showBatchDelete = false }) {
237+
Text(stringResource(R.string.action_cancel))
238+
}
239+
}
240+
)
241+
}
242+
170243
Scaffold(
171244
topBar = {
172245
TopAppBar(
173-
title = { Text(stringResource(R.string.images_title)) },
246+
title = {
247+
if (inSelectionMode) Text(stringResource(R.string.images_selected_count, selected.size))
248+
else Text(stringResource(R.string.images_title))
249+
},
174250
colors = TopAppBarDefaults.topAppBarColors(
175251
containerColor = MaterialTheme.colorScheme.surface
176252
),
177253
navigationIcon = {
178-
IconButton(onClick = onBack) {
179-
Icon(
180-
Icons.AutoMirrored.Filled.ArrowBack,
181-
contentDescription = stringResource(R.string.action_back)
182-
)
254+
if (inSelectionMode) {
255+
IconButton(onClick = { selected = emptySet() }) {
256+
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_close))
257+
}
258+
} else {
259+
IconButton(onClick = onBack) {
260+
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
261+
}
262+
}
263+
},
264+
actions = {
265+
if (!inSelectionMode) {
266+
IconButton(onClick = { showCreateSheet = true }) {
267+
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.images_create_new))
268+
}
269+
Box {
270+
IconButton(onClick = { showMenu = true }) {
271+
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_menu))
272+
}
273+
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
274+
DropdownMenuItem(
275+
text = { Text(stringResource(R.string.images_clear_unmounted)) },
276+
onClick = {
277+
showMenu = false
278+
if (images.isEmpty() || images.none { it.name !in mountedNames }) {
279+
Toast.makeText(context, context.getString(R.string.images_no_unmounted), Toast.LENGTH_SHORT).show()
280+
} else {
281+
showClearConfirm = true
282+
}
283+
}
284+
)
285+
}
286+
}
183287
}
184288
}
185289
)
186290
},
187291
floatingActionButton = {
188-
FloatingActionButton(onClick = { showCreateSheet = true }) {
189-
Icon(
190-
Icons.Filled.Add,
191-
contentDescription = stringResource(R.string.images_create_new)
192-
)
292+
if (inSelectionMode) {
293+
FloatingActionButton(
294+
onClick = { showBatchDelete = true },
295+
containerColor = MaterialTheme.colorScheme.error,
296+
contentColor = MaterialTheme.colorScheme.onError
297+
) {
298+
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
299+
}
193300
}
194301
}
195302
) { padding ->
@@ -245,10 +352,13 @@ fun ImageManagerScreen(
245352
modifier = Modifier.fillMaxSize().padding(padding)
246353
) {
247354
items(images, key = { it.file.absolutePath }) { image ->
355+
val isMounted = image.name in mountedNames
248356
ImageCard(
249357
image = image,
250-
isMounted = image.name in mountedNames,
358+
isMounted = isMounted,
251359
isExporting = exportingPath == image.file.absolutePath,
360+
inSelectionMode = inSelectionMode,
361+
isSelected = image.file.absolutePath in selected,
252362
onMount = {
253363
onMount(
254364
DeviceInfo(
@@ -280,7 +390,22 @@ fun ImageManagerScreen(
280390
).show()
281391
}
282392
},
283-
onDelete = { deleteTarget = image }
393+
onDelete = { deleteTarget = image },
394+
onToggleSelect = {
395+
if (!isMounted) {
396+
val path = image.file.absolutePath
397+
selected = if (path in selected) selected - path else selected + path
398+
}
399+
}
400+
)
401+
}
402+
item {
403+
Spacer(Modifier.height(4.dp))
404+
Text(
405+
stringResource(R.string.images_storage_hint),
406+
style = MaterialTheme.typography.bodySmall,
407+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
408+
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
284409
)
285410
}
286411
}
@@ -300,14 +425,18 @@ fun ImageManagerScreen(
300425
}
301426
}
302427

428+
@OptIn(ExperimentalFoundationApi::class)
303429
@Composable
304430
private fun ImageCard(
305431
image: DiskImage,
306432
isMounted: Boolean,
307433
isExporting: Boolean,
434+
inSelectionMode: Boolean,
435+
isSelected: Boolean,
308436
onMount: () -> Unit,
309437
onExport: () -> Unit,
310438
onDelete: () -> Unit,
439+
onToggleSelect: () -> Unit,
311440
modifier: Modifier = Modifier
312441
) {
313442
val glow = rememberInfiniteTransition(label = "card")
@@ -324,17 +453,31 @@ private fun ImageCard(
324453
Card(
325454
modifier = modifier
326455
.fillMaxWidth()
327-
.border(1.dp, tint.copy(alpha = borderAlpha), RoundedCornerShape(12.dp)),
456+
.then(
457+
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp))
458+
else Modifier.border(1.dp, tint.copy(alpha = borderAlpha), RoundedCornerShape(12.dp))
459+
)
460+
.combinedClickable(
461+
onClick = { if (inSelectionMode && !isMounted) onToggleSelect() },
462+
onLongClick = { if (!isMounted) onToggleSelect() }
463+
),
328464
colors = CardDefaults.cardColors(
329465
containerColor = MaterialTheme.colorScheme.surfaceContainer
330466
),
331467
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
332468
) {
333469
Column(modifier = Modifier.padding(16.dp)) {
334470
Row(verticalAlignment = Alignment.CenterVertically) {
335-
val icon = if (image.extension == "iso") Icons.Filled.Album
336-
else Icons.Filled.Storage
337-
Icon(icon, null, modifier = Modifier.size(28.dp), tint = tint)
471+
val icon = when {
472+
isSelected -> Icons.Filled.CheckCircle
473+
image.extension == "iso" -> Icons.Filled.Album
474+
else -> Icons.Filled.Storage
475+
}
476+
Icon(
477+
icon, null,
478+
modifier = Modifier.size(28.dp),
479+
tint = if (isSelected) MaterialTheme.colorScheme.primary else tint
480+
)
338481
Spacer(Modifier.width(12.dp))
339482

340483
Column(modifier = Modifier.weight(1f)) {
@@ -379,44 +522,46 @@ private fun ImageCard(
379522
}
380523
}
381524

382-
Spacer(Modifier.height(12.dp))
383-
384-
Row(
385-
modifier = Modifier.fillMaxWidth(),
386-
horizontalArrangement = Arrangement.spacedBy(8.dp)
387-
) {
388-
OutlinedButton(
389-
onClick = onMount,
390-
enabled = !isMounted,
391-
modifier = Modifier.weight(1f),
392-
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
393-
) { Text(stringResource(R.string.action_mount)) }
525+
if (!inSelectionMode) {
526+
Spacer(Modifier.height(12.dp))
394527

395-
OutlinedButton(
396-
onClick = onExport,
397-
enabled = !isExporting,
398-
modifier = Modifier.weight(1f),
399-
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
528+
Row(
529+
modifier = Modifier.fillMaxWidth(),
530+
horizontalArrangement = Arrangement.spacedBy(8.dp)
400531
) {
401-
if (isExporting) {
402-
CircularProgressIndicator(
403-
Modifier.size(16.dp),
404-
strokeWidth = 2.dp
405-
)
406-
Spacer(Modifier.width(4.dp))
532+
OutlinedButton(
533+
onClick = onMount,
534+
enabled = !isMounted,
535+
modifier = Modifier.weight(1f),
536+
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
537+
) { Text(stringResource(R.string.action_mount)) }
538+
539+
OutlinedButton(
540+
onClick = onExport,
541+
enabled = !isExporting,
542+
modifier = Modifier.weight(1f),
543+
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
544+
) {
545+
if (isExporting) {
546+
CircularProgressIndicator(
547+
Modifier.size(16.dp),
548+
strokeWidth = 2.dp
549+
)
550+
Spacer(Modifier.width(4.dp))
551+
}
552+
Text(stringResource(R.string.action_export))
407553
}
408-
Text(stringResource(R.string.action_export))
409-
}
410554

411-
OutlinedButton(
412-
onClick = onDelete,
413-
enabled = !isMounted,
414-
modifier = Modifier.weight(1f),
415-
colors = ButtonDefaults.outlinedButtonColors(
416-
contentColor = MaterialTheme.colorScheme.error
417-
),
418-
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
419-
) { Text(stringResource(R.string.action_delete)) }
555+
OutlinedButton(
556+
onClick = onDelete,
557+
enabled = !isMounted,
558+
modifier = Modifier.weight(1f),
559+
colors = ButtonDefaults.outlinedButtonColors(
560+
contentColor = MaterialTheme.colorScheme.error
561+
),
562+
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp)
563+
) { Text(stringResource(R.string.action_delete)) }
564+
}
420565
}
421566
}
422567
}

app/src/main/res/values/strings.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@
113113
<string name="images_delete_message">Permanently delete \"%s\"? This cannot be undone.</string>
114114
<string name="images_export_success">Exported to Downloads</string>
115115
<string name="images_export_failed">Export failed</string>
116+
<string name="images_selected_count">%d selected</string>
117+
<string name="images_clear_unmounted">Clear Unmounted</string>
118+
<string name="images_clear_unmounted_title">Clear Unmounted Images?</string>
119+
<string name="images_clear_unmounted_message">Permanently delete %d unmounted images? Mounted images are protected.</string>
120+
<string name="images_batch_delete_title">Delete %d Images?</string>
121+
<string name="images_batch_delete_message">Permanently delete %d selected images? This cannot be undone.</string>
122+
<string name="images_storage_hint">Clear unused images regularly to free storage</string>
123+
<string name="images_no_unmounted">All images are currently mounted</string>
116124

117125
<string name="format_fat32">FAT32</string>
118126
<string name="format_exfat">exFAT</string>

0 commit comments

Comments
 (0)