From 35984b0b850764207f1bf5bfa6b801e91b29f369 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Thu, 30 Jul 2026 05:16:09 +0000 Subject: [PATCH] Verify screen: per-file checkboxes for delete + preview dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VerifyScreen redesigned as review-before-delete: - All imported files shown with status icons (green/yellow/red) - Checkboxes appear when deleteAfterVerify is on - Pre-checked: EXIF-verified files (safe to delete) - Pre-unchecked: EXIF-unverified files (kept on phone by default) - Failed copies: no checkbox (excluded from deletion entirely) - Uncheck any file to protect it from deletion - 'Delete N files' button → confirmation card - Confirmation shows count of excluded unverified files - Click any row → PreviewDialog with full image + EXIF status AppState: add deleteSpecificFromDevice(devicePaths) to delete exactly the set of paths the user confirmed on the verify screen. --- .../com/bolenpad/photophetch/ui/AppState.kt | 14 +- .../bolenpad/photophetch/ui/VerifyScreen.kt | 287 +++++++++--------- 2 files changed, 156 insertions(+), 145 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index ae22ccb..0d8a5cb 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -426,16 +426,24 @@ class AppState(private val configStore: ConfigStore) { // Post-import deletion // ------------------------------------------------------------------------- fun deleteVerifiedFromDevice() { + deleteSpecificFromDevice( + _importResult.value?.results + ?.filter { it.verified && it.exifVerified != false } + ?.map { it.photo.devicePath } + ?.toSet() ?: emptySet() + ) + } + + fun deleteSpecificFromDevice(devicePaths: Set) { val device = _selectedDevice.value ?: return val result = _importResult.value ?: return scope.launch(Dispatchers.IO) { val connector = connectorFor(device) result.results - .filter { it.verified && it.exifVerified != false } // skip exifVerified=false + .filter { it.photo.devicePath in devicePaths } .forEach { r -> try { connector.deletePhoto(device, r.photo) - // Also delete companion MOV if this was a Live Photo still if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) { val companion = r.photo.copy( devicePath = r.photo.companionDevicePath, @@ -443,7 +451,7 @@ class AppState(private val configStore: ConfigStore) { ) try { connector.deletePhoto(device, companion) } catch (e: DeviceException) { - log.warn("Could not delete companion for ${r.photo.filename}: ${e.message}") + log.warn("Companion delete failed for ${r.photo.filename}: ${e.message}") } } } catch (e: DeviceException) { diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt index 3f0e733..3e6373e 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt @@ -1,10 +1,11 @@ package com.bolenpad.photophetch.ui +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.clickable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Error @@ -30,7 +31,7 @@ import org.jetbrains.skia.Image as SkiaImage import java.nio.file.Files /** - * Shown during active import (IMPORTING screen). + * Progress screen shown during active import. */ @Composable fun ImportingScreen(state: AppState) { @@ -51,11 +52,8 @@ fun ImportingScreen(state: AppState) { Spacer(Modifier.height(12.dp)) Text("${p.completed} / ${p.total} files") if (p.currentFile.isNotBlank()) { - Text( - p.currentFile, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(p.currentFile, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { CircularProgressIndicator() @@ -64,24 +62,38 @@ fun ImportingScreen(state: AppState) { } /** - * Shown after import completes (VERIFY screen). + * Review screen shown after import completes. + * + * Shows all imported files with their copy/EXIF status. + * Files are pre-checked for deletion based on verification: + * - EXIF-verified camera originals → pre-checked + * - EXIF-unverified files → pre-unchecked (never auto-deleted) + * - Failed copies → excluded from deletion entirely + * + * User can uncheck any file to protect it from deletion, then confirm. */ @Composable fun VerifyScreen(state: AppState) { val result by state.importResult.collectAsState() val importConfig by state.importConfig.collectAsState() - var deleteConfirmShown by remember { mutableStateOf(false) } - var deleteDone by remember { mutableStateOf(false) } var previewResult by remember { mutableStateOf(null) } val r = result ?: return + // Pre-populate delete set: only verified copies with confirmed EXIF + val deletableResults = r.results.filter { it.verified && it.error == null } + val initiallyChecked = deletableResults + .filter { it.exifVerified != false } // pre-check verified, pre-uncheck unverified EXIF + .map { it.photo.devicePath } + .toSet() + + var toDelete by remember(r) { mutableStateOf(initiallyChecked) } + var deleteConfirmShown by remember { mutableStateOf(false) } + var deleteDone by remember { mutableStateOf(false) } + // Preview dialog previewResult?.let { pr -> - PreviewDialog( - result = pr, - onDismiss = { previewResult = null }, - ) + PreviewDialog(result = pr, onDismiss = { previewResult = null }) } Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { @@ -90,17 +102,30 @@ fun VerifyScreen(state: AppState) { ResultSummaryHeader( success = r.successCount, failure = r.failureCount, + exifUnverified = r.results.count { it.exifVerified == false }, destination = "${r.job.stagingRoot}/${r.job.folderName}", ) - Spacer(Modifier.height(16.dp)) - Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + if (importConfig.deleteAfterVerify && !deleteDone) { + Text( + "${toDelete.size} selected for deletion", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Spacer(Modifier.height(4.dp)) - HorizontalDivider() - // Scrollable results list — shadows are pinned to the divider edges + // Scrollable results with top/bottom shadows val listState = rememberLazyListState() - val topShadowAlpha by remember { derivedStateOf { val scrolled = listState.firstVisibleItemScrollOffset + @@ -117,95 +142,81 @@ fun VerifyScreen(state: AppState) { } } - // Top edge: shadow drawn below the divider line above the list if (topShadowAlpha > 0f) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(16.dp) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.10f * topShadowAlpha), - Color.Transparent, - ), - ) - ) - } - ) + Box(modifier = Modifier.fillMaxWidth().height(16.dp).drawWithContent { + drawContent() + drawRect(brush = Brush.verticalGradient( + colors = listOf(Color.Black.copy(alpha = 0.08f * topShadowAlpha), Color.Transparent))) + }) } LazyColumn( state = listState, modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = PaddingValues(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { items(r.results, key = { it.photo.devicePath }) { copyResult -> - CopyResultRow(copyResult, onPreview = { previewResult = it }) + val isChecked = copyResult.photo.devicePath in toDelete + val canCheck = copyResult.verified && copyResult.error == null + + CopyResultRow( + result = copyResult, + showCheckbox = importConfig.deleteAfterVerify && !deleteDone, + isChecked = isChecked, + canCheck = canCheck, + onToggleCheck = { + toDelete = if (isChecked) toDelete - copyResult.photo.devicePath + else toDelete + copyResult.photo.devicePath + }, + onPreview = { previewResult = it }, + ) } } - // Bottom edge: shadow drawn above the divider line below the list if (bottomShadowAlpha > 0f) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(16.dp) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Color.Black.copy(alpha = 0.10f * bottomShadowAlpha), - ), - ) - ) - } - ) + Box(modifier = Modifier.fillMaxWidth().height(16.dp).drawWithContent { + drawContent() + drawRect(brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.08f * bottomShadowAlpha)))) + }) } HorizontalDivider() Spacer(Modifier.height(12.dp)) // Actions - if (importConfig.deleteAfterVerify && r.successCount > 0 && !deleteDone) { + if (importConfig.deleteAfterVerify && !deleteDone) { if (deleteConfirmShown) { DeleteConfirmCard( - count = r.successCount, + count = toDelete.size, + exifUnverifiedCount = r.results.count { + it.photo.devicePath !in toDelete && it.exifVerified == false + }, onConfirm = { - state.deleteVerifiedFromDevice() + state.deleteSpecificFromDevice(toDelete) deleteDone = true deleteConfirmShown = false }, onCancel = { deleteConfirmShown = false }, ) - } else { + } else if (toDelete.isNotEmpty()) { OutlinedButton( onClick = { deleteConfirmShown = true }, colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth(), ) { - Text("Delete ${r.successCount} verified files from phone") + Text("Delete ${toDelete.size} files from phone") } } } else if (deleteDone) { - Text( - "✓ ${r.successCount} files deleted from phone", + Text("✓ Files deleted from phone", color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodySmall, - ) + style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.height(8.dp)) Button( - onClick = { - state.navigateTo(Screen.CONNECT) - state.selectNone() - }, + onClick = { state.navigateTo(Screen.CONNECT); state.selectNone() }, modifier = Modifier.fillMaxWidth(), ) { Icon(Icons.Default.Home, contentDescription = null, modifier = Modifier.size(18.dp)) @@ -216,42 +227,40 @@ fun VerifyScreen(state: AppState) { } @Composable -private fun ResultSummaryHeader(success: Int, failure: Int, destination: String) { - Card( - colors = CardDefaults.cardColors( - containerColor = if (failure == 0) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.errorContainer, - ) - ) { +private fun ResultSummaryHeader(success: Int, failure: Int, exifUnverified: Int, destination: String) { + Card(colors = CardDefaults.cardColors( + containerColor = if (failure == 0) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer, + )) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( if (failure == 0) "Import complete ✓" else "Import complete with errors", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Text("$success verified, $failure failed") - Text( - "→ $destination", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) + Text("$success copied, $failure failed" + + if (exifUnverified > 0) ", $exifUnverified EXIF unverified (⚠ not auto-deleted)" else "") + Text("→ $destination", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @Composable -private fun CopyResultRow(result: CopyResult, onPreview: (CopyResult) -> Unit) { - val hasRemoteHash = result.sourceSha256 != "(not available)" +private fun CopyResultRow( + result: CopyResult, + showCheckbox: Boolean, + isChecked: Boolean, + canCheck: Boolean, + onToggleCheck: () -> Unit, + onPreview: (CopyResult) -> Unit, +) { val isFailed = result.error != null || !result.verified val exifUnverified = result.exifVerified == false val exifUnavailable = result.exifVerified == null - // Color: red=failed, yellow=unverified EXIF, green=fully verified val iconTint = when { isFailed -> MaterialTheme.colorScheme.error - exifUnverified -> MaterialTheme.colorScheme.tertiary // yellow-ish + exifUnverified -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.primary } val icon = when { @@ -261,12 +270,24 @@ private fun CopyResultRow(result: CopyResult, onPreview: (CopyResult) -> Unit) { } Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - .clickable { onPreview(result) }, // click to preview + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) + .clickable { onPreview(result) }, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + // Delete checkbox (only shown when deleteAfterVerify is on and import isn't done) + if (showCheckbox && canCheck) { + Checkbox( + checked = isChecked, + onCheckedChange = { onToggleCheck() }, + modifier = Modifier.size(20.dp), + ) + } else if (showCheckbox) { + Spacer(Modifier.width(20.dp)) + } + Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(18.dp)) + Column(modifier = Modifier.weight(1f)) { val displayName = if (result.convertedFromHeic) "${result.photo.filename} → ${result.destinationPath.fileName}" @@ -275,10 +296,9 @@ private fun CopyResultRow(result: CopyResult, onPreview: (CopyResult) -> Unit) { Text( when { isFailed -> result.error ?: "Failed" - exifUnverified -> "⚠ EXIF Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not deleted from phone" - exifUnavailable -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}…" - hasRemoteHash -> "SHA-256 verified · ${result.photo.sizeBytes / 1024} KB" - else -> "Copied · ${result.photo.sizeBytes / 1024} KB · local SHA-256: ${result.destSha256.take(12)}…" + exifUnverified -> "⚠ Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — kept on phone" + exifUnavailable -> "Copied · ${result.photo.sizeBytes / 1024} KB" + else -> "✓ Make: ${result.exifMake} · ${result.photo.sizeBytes / 1024} KB" }, style = MaterialTheme.typography.labelSmall, color = when { @@ -288,34 +308,40 @@ private fun CopyResultRow(result: CopyResult, onPreview: (CopyResult) -> Unit) { }, ) } - // Tap hint Text("👁", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @Composable -private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> Unit) { +private fun DeleteConfirmCard( + count: Int, + exifUnverifiedCount: Int, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Delete $count files from phone?", fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onErrorContainer) Text( - "Delete $count files from phone?", - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - "This is permanent. Only files that passed SHA-256 verification will be deleted.", + buildString { + append("This is permanent and cannot be undone.") + if (exifUnverifiedCount > 0) { + append(" $exifUnverifiedCount unverified file(s) are excluded and will stay on your phone.") + } + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = onConfirm, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - ) { Text("Delete from Phone") } + Button(onClick = onConfirm, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Text("Delete from Phone") + } OutlinedButton(onClick = onCancel) { Text("Cancel") } } } @@ -326,31 +352,24 @@ private fun DeleteConfirmCard(count: Int, onConfirm: () -> Unit, onCancel: () -> private fun PreviewDialog(result: CopyResult, onDismiss: () -> Unit) { var bitmap by remember(result.destinationPath) { mutableStateOf(null) } - // Load from destination file (already on disk after import) LaunchedEffect(result.destinationPath) { val bytes = withContext(Dispatchers.IO) { try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null } } if (bytes != null) { - runCatching { - // Use sips to get a reasonable preview size if file is large - bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() - } + runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } } } Dialog(onDismissRequest = onDismiss) { Card(modifier = Modifier.fillMaxWidth(0.9f)) { - Column(modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp)) { - - // Filename + EXIF status + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(result.photo.filename, fontWeight = FontWeight.SemiBold) Text( when (result.exifVerified) { true -> "✓ EXIF Make: ${result.exifMake}" - false -> "⚠ EXIF Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not a confirmed camera original" - null -> "EXIF not checked (exiftool not installed)" + false -> "⚠ Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not a confirmed camera original" + null -> "EXIF not checked (install exiftool)" }, style = MaterialTheme.typography.bodySmall, color = when (result.exifVerified) { @@ -359,36 +378,20 @@ private fun PreviewDialog(result: CopyResult, onDismiss: () -> Unit) { null -> MaterialTheme.colorScheme.onSurfaceVariant }, ) - HorizontalDivider() - - // Preview image - Box( - modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f), - contentAlignment = Alignment.Center, - ) { + Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f), + contentAlignment = Alignment.Center) { if (bitmap != null) { - androidx.compose.foundation.Image( - bitmap = bitmap!!, - contentDescription = result.photo.filename, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize(), - ) + Image(bitmap = bitmap!!, contentDescription = result.photo.filename, + contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) } else { CircularProgressIndicator() } } - - // File info - Text( - "${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}", + Text("${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { - Text("Close") - } + color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Close") } } } }