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.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
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)) {
|
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.dp)) {
|
||||||
|
|
||||||
// ── Summary ────────────────────────────────────────────────────────
|
// ── Summary — counts exclude soft-removed files ───────────────────
|
||||||
val failCount = r.results.count { it.error != null || !it.verified }
|
val activeResults = r.results.filter { it.photo.devicePath !in softRemoved }
|
||||||
val unverifiedCount = r.results.count { it.exifVerified == false }
|
val failCount = activeResults.count { it.error != null || !it.verified }
|
||||||
val okCount = r.results.count { it.verified && it.error == null && it.exifVerified != false }
|
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(
|
Card(colors = CardDefaults.cardColors(
|
||||||
containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
|
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 (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error)
|
||||||
if (unverifiedCount > 0) StatusChip("$unverifiedCount ⚠", MaterialTheme.colorScheme.tertiary)
|
if (unverifiedCount > 0) StatusChip("$unverifiedCount ⚠", MaterialTheme.colorScheme.tertiary)
|
||||||
StatusChip("$okCount ✓", MaterialTheme.colorScheme.primary)
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
VerifyFilter.entries.forEach { filter ->
|
VerifyFilter.entries.forEach { filter ->
|
||||||
val label = when (filter) {
|
val label = when (filter) {
|
||||||
VerifyFilter.ALL -> "All (${r.results.size})"
|
VerifyFilter.ALL -> "All (${activeResults.size})"
|
||||||
VerifyFilter.OK -> "✓ OK ($okCount)"
|
VerifyFilter.OK -> "✓ OK ($okCount)"
|
||||||
VerifyFilter.UNVERIFIED -> "⚠ Unverified ($unverifiedCount)"
|
VerifyFilter.UNVERIFIED -> "⚠ Unverified ($unverifiedCount)"
|
||||||
VerifyFilter.FAILED -> "✗ Failed ($failCount)"
|
VerifyFilter.FAILED -> "✗ Failed ($failCount)"
|
||||||
}
|
}
|
||||||
FilterChip(
|
FilterChip(selected = activeFilter == filter, onClick = { activeFilter = filter },
|
||||||
selected = activeFilter == filter,
|
label = { Text(label, style = MaterialTheme.typography.labelSmall) })
|
||||||
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 ───────────────────────────────────────────────────
|
// ── Results list ───────────────────────────────────────────────────
|
||||||
val filteredResults = r.results.filter { cr ->
|
val filteredResults = r.results.filter { cr ->
|
||||||
cr.photo.devicePath !in removedFromImport && when (activeFilter) {
|
when (activeFilter) {
|
||||||
VerifyFilter.ALL -> true
|
VerifyFilter.ALL -> true // show all including soft-removed (they'll be greyed)
|
||||||
VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false
|
VerifyFilter.OK -> cr.verified && cr.error == null && cr.exifVerified != false && cr.photo.devicePath !in softRemoved
|
||||||
VerifyFilter.UNVERIFIED -> cr.exifVerified == false
|
VerifyFilter.UNVERIFIED -> cr.exifVerified == false && cr.photo.devicePath !in softRemoved
|
||||||
VerifyFilter.FAILED -> cr.error != null || !cr.verified
|
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),
|
LazyColumn(state = listState, modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
items(filteredResults, key = { it.photo.devicePath }) { cr ->
|
items(filteredResults, key = { it.photo.devicePath }) { cr ->
|
||||||
|
val isSoftRemoved = cr.photo.devicePath in softRemoved
|
||||||
VerifyResultRow(
|
VerifyResultRow(
|
||||||
result = cr,
|
result = cr,
|
||||||
showCheckbox = importConfig.deleteAfterVerify && !deleteDone,
|
showCheckbox = importConfig.deleteAfterVerify && !deleteDone && !isSoftRemoved,
|
||||||
isChecked = cr.photo.devicePath in toDelete,
|
isChecked = cr.photo.devicePath in toDelete,
|
||||||
canCheck = cr.verified && cr.error == null,
|
canCheck = cr.verified && cr.error == null,
|
||||||
|
isSoftRemoved = isSoftRemoved,
|
||||||
onToggleCheck = {
|
onToggleCheck = {
|
||||||
toDelete = if (cr.photo.devicePath in toDelete)
|
toDelete = if (cr.photo.devicePath in toDelete)
|
||||||
toDelete - cr.photo.devicePath
|
toDelete - cr.photo.devicePath
|
||||||
else toDelete + cr.photo.devicePath
|
else toDelete + cr.photo.devicePath
|
||||||
},
|
},
|
||||||
onPreview = { previewResult = it },
|
onPreview = { previewResult = it },
|
||||||
onRemove = {
|
onToggleRemove = {
|
||||||
// Delete from staging and remove from list
|
if (isSoftRemoved) {
|
||||||
removedFromImport = removedFromImport + cr.photo.devicePath
|
softRemoved = softRemoved - cr.photo.devicePath
|
||||||
|
} else {
|
||||||
|
softRemoved = softRemoved + cr.photo.devicePath
|
||||||
toDelete = toDelete - cr.photo.devicePath
|
toDelete = toDelete - cr.photo.devicePath
|
||||||
try { cr.destinationPath.toFile().delete() } catch (_: Exception) {}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -228,7 +246,16 @@ fun VerifyScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button(
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
@@ -254,35 +281,34 @@ private fun VerifyResultRow(
|
|||||||
showCheckbox: Boolean,
|
showCheckbox: Boolean,
|
||||||
isChecked: Boolean,
|
isChecked: Boolean,
|
||||||
canCheck: Boolean,
|
canCheck: Boolean,
|
||||||
|
isSoftRemoved: Boolean,
|
||||||
onToggleCheck: () -> Unit,
|
onToggleCheck: () -> Unit,
|
||||||
onPreview: (CopyResult) -> Unit,
|
onPreview: (CopyResult) -> Unit,
|
||||||
onRemove: () -> Unit,
|
onToggleRemove: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val isFailed = result.error != null || !result.verified
|
val isFailed = result.error != null || !result.verified
|
||||||
val exifUnverified = result.exifVerified == false
|
val exifUnverified = result.exifVerified == false
|
||||||
val exifUnavailable = result.exifVerified == null
|
val exifUnavailable = result.exifVerified == null
|
||||||
|
|
||||||
// Row background: red tint for failures, yellow tint for unverified
|
|
||||||
val rowBg = when {
|
val rowBg = when {
|
||||||
|
isSoftRemoved -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||||
isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
.background(rowBg)
|
.background(rowBg)
|
||||||
.padding(vertical = 4.dp, horizontal = 4.dp),
|
.padding(vertical = 4.dp, horizontal = 4.dp)
|
||||||
|
.then(if (isSoftRemoved) Modifier.graphicsLayer { alpha = 0.4f } else Modifier),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
// Thumbnail chip on left — 44dp square
|
ThumbnailChip(destinationPath = result.destinationPath.toString(),
|
||||||
ThumbnailChip(
|
onClick = { if (!isSoftRemoved) onPreview(result) })
|
||||||
destinationPath = result.destinationPath.toString(),
|
|
||||||
onClick = { onPreview(result) },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Checkbox
|
|
||||||
if (showCheckbox && canCheck) {
|
if (showCheckbox && canCheck) {
|
||||||
Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
|
Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
|
||||||
modifier = Modifier.size(20.dp))
|
modifier = Modifier.size(20.dp))
|
||||||
@@ -290,12 +316,10 @@ private fun VerifyResultRow(
|
|||||||
Spacer(Modifier.width(20.dp))
|
Spacer(Modifier.width(20.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status icon
|
|
||||||
val icon = when { isFailed -> Icons.Default.Error; exifUnverified -> Icons.Default.Warning; else -> Icons.Default.CheckCircle }
|
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 }
|
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))
|
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(16.dp))
|
||||||
|
|
||||||
// File info
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
val displayName = if (result.convertedFromHeic)
|
val displayName = if (result.convertedFromHeic)
|
||||||
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
"${result.photo.filename} → ${result.destinationPath.fileName}"
|
||||||
@@ -315,10 +339,25 @@ private fun VerifyResultRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from import button
|
IconButton(onClick = onToggleRemove, modifier = Modifier.size(28.dp)) {
|
||||||
IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
|
Icon(Icons.Default.Close, contentDescription = if (isSoftRemoved) "Undo remove" else "Remove from import",
|
||||||
Icon(Icons.Default.Close, contentDescription = "Remove from import",
|
tint = if (isSoftRemoved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
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