From 80eee705a7ec390bf1f61bd2aff394a132ddbd7a Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Thu, 30 Jul 2026 05:37:29 +0000 Subject: [PATCH] Verify screen: soft-remove with undo, reactive summary counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soft-remove (X button): - Row dims to 40% opacity with 'Removed — tap ✕ to undo' overlay - X button turns blue to indicate it's now an undo action - File stays on screen, counts update immediately - 'N removed' chip appears in summary header - 'N removed — tap to undo all' chip in filter bar with X to undo all On Done: soft-removed files are deleted from staging at that point. Until Done is clicked, all removals are fully reversible. --- .../bolenpad/photophetch/ui/VerifyScreen.kt | 179 +++++++++++------- 1 file changed, 109 insertions(+), 70 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt index 6466bf9..77669ce 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap @@ -109,10 +110,12 @@ fun VerifyScreen(state: AppState) { Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp)) { - // ── Summary ──────────────────────────────────────────────────────── - val failCount = r.results.count { it.error != null || !it.verified } - val unverifiedCount = r.results.count { it.exifVerified == false } - val okCount = r.results.count { it.verified && it.error == null && it.exifVerified != false } + // ── Summary — counts exclude soft-removed files ─────────────────── + val activeResults = r.results.filter { it.photo.devicePath !in softRemoved } + val failCount = activeResults.count { it.error != null || !it.verified } + val unverifiedCount = activeResults.count { it.exifVerified == false } + val okCount = activeResults.count { it.verified && it.error == null && it.exifVerified != false } + val removedCount = softRemoved.size Card(colors = CardDefaults.cardColors( containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer @@ -131,6 +134,7 @@ fun VerifyScreen(state: AppState) { if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error) if (unverifiedCount > 0) StatusChip("$unverifiedCount ⚠", MaterialTheme.colorScheme.tertiary) StatusChip("$okCount ✓", MaterialTheme.colorScheme.primary) + if (removedCount > 0) StatusChip("$removedCount removed", MaterialTheme.colorScheme.outline) } } } @@ -141,15 +145,25 @@ fun VerifyScreen(state: AppState) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { VerifyFilter.entries.forEach { filter -> val label = when (filter) { - VerifyFilter.ALL -> "All (${r.results.size})" + VerifyFilter.ALL -> "All (${activeResults.size})" VerifyFilter.OK -> "✓ OK ($okCount)" VerifyFilter.UNVERIFIED -> "⚠ Unverified ($unverifiedCount)" VerifyFilter.FAILED -> "✗ Failed ($failCount)" } - FilterChip( - selected = activeFilter == filter, - onClick = { activeFilter = filter }, - label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + FilterChip(selected = activeFilter == filter, onClick = { activeFilter = filter }, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }) + } + if (removedCount > 0) { + FilterChip(selected = false, onClick = { /* show removed inline */ }, + label = { Text("$removedCount removed — tap to undo all", + style = MaterialTheme.typography.labelSmall) }, + trailingIcon = { + IconButton(onClick = { softRemoved = emptySet() }, + modifier = Modifier.size(16.dp)) { + Icon(Icons.Default.Close, contentDescription = "Undo all removals", + modifier = Modifier.size(12.dp)) + } + } ) } } @@ -158,11 +172,11 @@ fun VerifyScreen(state: AppState) { // ── Results list ─────────────────────────────────────────────────── val filteredResults = r.results.filter { cr -> - cr.photo.devicePath !in removedFromImport && when (activeFilter) { - VerifyFilter.ALL -> true - VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false - VerifyFilter.UNVERIFIED -> cr.exifVerified == false - VerifyFilter.FAILED -> cr.error != null || !cr.verified + when (activeFilter) { + VerifyFilter.ALL -> true // show all including soft-removed (they'll be greyed) + VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false && cr.photo.devicePath !in softRemoved + VerifyFilter.UNVERIFIED -> cr.exifVerified == false && cr.photo.devicePath !in softRemoved + VerifyFilter.FAILED -> (cr.error != null || !cr.verified) && cr.photo.devicePath !in softRemoved } } @@ -184,22 +198,26 @@ fun VerifyScreen(state: AppState) { LazyColumn(state = listState, modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { items(filteredResults, key = { it.photo.devicePath }) { cr -> + val isSoftRemoved = cr.photo.devicePath in softRemoved VerifyResultRow( result = cr, - showCheckbox = importConfig.deleteAfterVerify && !deleteDone, + showCheckbox = importConfig.deleteAfterVerify && !deleteDone && !isSoftRemoved, isChecked = cr.photo.devicePath in toDelete, canCheck = cr.verified && cr.error == null, + isSoftRemoved = isSoftRemoved, onToggleCheck = { toDelete = if (cr.photo.devicePath in toDelete) toDelete - cr.photo.devicePath else toDelete + cr.photo.devicePath }, onPreview = { previewResult = it }, - onRemove = { - // Delete from staging and remove from list - removedFromImport = removedFromImport + cr.photo.devicePath - toDelete = toDelete - cr.photo.devicePath - try { cr.destinationPath.toFile().delete() } catch (_: Exception) {} + onToggleRemove = { + if (isSoftRemoved) { + softRemoved = softRemoved - cr.photo.devicePath + } else { + softRemoved = softRemoved + cr.photo.devicePath + toDelete = toDelete - cr.photo.devicePath + } }, ) } @@ -228,7 +246,16 @@ fun VerifyScreen(state: AppState) { } Button( - onClick = { state.navigateTo(Screen.CONNECT); state.selectNone() }, + onClick = { + // Delete soft-removed files from staging before leaving + if (softRemoved.isNotEmpty()) { + r.results.filter { it.photo.devicePath in softRemoved }.forEach { cr -> + try { cr.destinationPath.toFile().delete() } catch (_: Exception) {} + } + } + state.navigateTo(Screen.CONNECT) + state.selectNone() + }, modifier = Modifier.fillMaxWidth(), ) { Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp)) @@ -254,71 +281,83 @@ private fun VerifyResultRow( showCheckbox: Boolean, isChecked: Boolean, canCheck: Boolean, + isSoftRemoved: Boolean, onToggleCheck: () -> Unit, onPreview: (CopyResult) -> Unit, - onRemove: () -> Unit, + onToggleRemove: () -> Unit, ) { val isFailed = result.error != null || !result.verified val exifUnverified = result.exifVerified == false val exifUnavailable = result.exifVerified == null - // Row background: red tint for failures, yellow tint for unverified val rowBg = when { + isSoftRemoved -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) else -> Color.Transparent } - Row( - modifier = Modifier.fillMaxWidth() - .background(rowBg) - .padding(vertical = 4.dp, horizontal = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Thumbnail chip on left — 44dp square - ThumbnailChip( - destinationPath = result.destinationPath.toString(), - onClick = { onPreview(result) }, - ) + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth() + .background(rowBg) + .padding(vertical = 4.dp, horizontal = 4.dp) + .then(if (isSoftRemoved) Modifier.graphicsLayer { alpha = 0.4f } else Modifier), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ThumbnailChip(destinationPath = result.destinationPath.toString(), + onClick = { if (!isSoftRemoved) onPreview(result) }) - // Checkbox - if (showCheckbox && canCheck) { - Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() }, - modifier = Modifier.size(20.dp)) - } else if (showCheckbox) { - Spacer(Modifier.width(20.dp)) + if (showCheckbox && canCheck) { + Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() }, + modifier = Modifier.size(20.dp)) + } else if (showCheckbox) { + Spacer(Modifier.width(20.dp)) + } + + val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle } + val iconTint = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.primary } + Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + val displayName = if (result.convertedFromHeic) + "${result.photo.filename} → ${result.destinationPath.fileName}" + else result.photo.filename + Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, + maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) + Text( + when { + isFailed -> result.error ?: "Failed" + exifUnverified -> "⚠ Make: '${result.exifMake?.ifEmpty { "absent" } ?: "absent"}'" + + if (showCheckbox) " — kept on phone" else "" + exifUnavailable -> "${result.photo.sizeBytes / 1024} KB" + else -> "✓ ${result.exifMake} · ${result.photo.sizeBytes / 1024} KB" + }, + style = MaterialTheme.typography.labelSmall, + color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant }, + ) + } + + IconButton(onClick = onToggleRemove, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, contentDescription = if (isSoftRemoved) "Undo remove" else "Remove from import", + tint = if (isSoftRemoved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp)) + } } - // Status icon - val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle } - val iconTint = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.primary } - Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(16.dp)) - - // File info - Column(modifier = Modifier.weight(1f)) { - val displayName = if (result.convertedFromHeic) - "${result.photo.filename} → ${result.destinationPath.fileName}" - else result.photo.filename - Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, - maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) - Text( - when { - isFailed -> result.error ?: "Failed" - exifUnverified -> "⚠ Make: '${result.exifMake?.ifEmpty { "absent" } ?: "absent"}'" + - if (showCheckbox) " — kept on phone" else "" - exifUnavailable -> "${result.photo.sizeBytes / 1024} KB" - else -> "✓ ${result.exifMake} · ${result.photo.sizeBytes / 1024} KB" - }, - style = MaterialTheme.typography.labelSmall, - color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant }, - ) - } - - // Remove from import button - IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { - Icon(Icons.Default.Close, contentDescription = "Remove from import", - tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp)) + // "Removed" overlay label when soft-removed + if (isSoftRemoved) { + Box(modifier = Modifier.matchParentSize(), contentAlignment = Alignment.Center) { + Surface(shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant) { + Text("Removed — tap ✕ to undo", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium) + } + } } } }