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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user