Verify screen: soft-remove with undo, reactive summary counts

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.
This commit is contained in:
Kyle Bolen
2026-07-30 05:37:29 +00:00
parent 0139d47d5f
commit 80eee705a7

View File

@@ -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)
}
}
}
}
}