From 6863bad7a0fd08e76ab81617b4954ddb4686988c Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Thu, 30 Jul 2026 05:30:43 +0000 Subject: [PATCH] Browse + Verify screen improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DenseThumb: - LIVE badge moved to bottom-left (no overlap with checkbox) - Video size badge at bottom-left - Checkbox stays top-right only - + button at bottom-right opens PhotoPreviewDialog PhotoPreviewDialog (BrowseScreen): - Shows cached thumbnail in a dialog - For Live Photos: also pulls and opens MOV via macOS 'open' VerifyScreen redesign: - Filter bar: All / OK / Unverified / Failed - Thumbnail chip (44dp) on left of each row — click to preview - Errors/warnings get tinted row background (red/yellow) - X button on each row to remove from import (deletes from staging) - Status chips in summary header - Checkbox-based delete still works when deleteAfterVerify=on - Delete confirmation as AlertDialog (cleaner than inline card) --- .../bolenpad/photophetch/ui/BrowseScreen.kt | 126 +++++ .../bolenpad/photophetch/ui/DensePhotoGrid.kt | 59 ++- .../bolenpad/photophetch/ui/VerifyScreen.kt | 479 +++++++++--------- 3 files changed, 398 insertions(+), 266 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index 9f49d87..e322c79 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -11,11 +11,20 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.bolenpad.photophetch.model.PhonePhoto +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage +import java.nio.file.Path +import java.nio.file.Paths import java.time.ZoneId @Composable @@ -25,6 +34,20 @@ fun BrowseScreen(state: AppState) { val device by state.selectedDevice.collectAsState() val config by state.config.collectAsState() + var previewPhoto by remember { mutableStateOf(null) } + + // Preview dialog + previewPhoto?.let { photo -> + PhotoPreviewDialog( + photo = photo, + cacheKey = device?.let { state.connectorFor(it).cacheKey(it) } ?: "unknown", + pullFn = { devicePath, localPath -> + device?.let { d -> state.connectorFor(d).pullToFile(d, devicePath, localPath) } ?: false + }, + onDismiss = { previewPhoto = null }, + ) + } + Column(modifier = Modifier.fillMaxSize()) { var columnCount by remember { mutableStateOf(4) } @@ -112,6 +135,7 @@ fun BrowseScreen(state: AppState) { onDeselectAllFolder = { folder -> folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) } }, + onPreviewPhoto = { previewPhoto = it }, modifier = Modifier.weight(1f), ) } @@ -323,3 +347,105 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) { } } } + +// ─── Photo preview dialog ───────────────────────────────────────────────────── + +@Composable +private fun PhotoPreviewDialog( + photo: PhonePhoto, + cacheKey: String, + pullFn: suspend (devicePath: String, localDestPath: Path) -> Boolean, + onDismiss: () -> Unit, +) { + var bitmap by remember(photo.devicePath) { mutableStateOf(null) } + val cacheRoot = Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache") + val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_")) + val hash = (cacheKey + photo.devicePath).hashCode().toString(16) + val cachePath = cacheDir.resolve("$hash.jpg") + + LaunchedEffect(photo.devicePath) { + val bytes = withContext(Dispatchers.IO) { + // Use cached thumbnail if available + if (cachePath.toFile().exists() && cachePath.toFile().length() > 0) { + java.nio.file.Files.readAllBytes(cachePath) + } else { + // Pull fresh for preview + val tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-preview") + tmpDir.toFile().mkdirs() + val tmpFile = tmpDir.resolve("preview-${photo.filename}") + val ok = pullFn(photo.devicePath, tmpFile) + if (ok && tmpFile.toFile().exists()) java.nio.file.Files.readAllBytes(tmpFile) else null + } + } + if (bytes != null) { + runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } + } + + // For Live Photos: also open the companion MOV in system viewer + if (photo.isLivePhotoStill && photo.companionDevicePath != null) { + val tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-preview") + val movFilename = photo.companionDevicePath.substringAfterLast("/") + val tmpMov = tmpDir.resolve(movFilename) + val ok = withContext(Dispatchers.IO) { pullFn(photo.companionDevicePath, tmpMov) } + if (ok && tmpMov.toFile().exists()) { + withContext(Dispatchers.IO) { + ProcessBuilder("open", tmpMov.toString()).start() + } + } + } + } + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.fillMaxWidth(0.85f)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth()) { + Column { + Text(photo.filename, fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodyMedium) + val date = photo.dateTaken.atZone(ZoneId.systemDefault()) + Text( + "${date.toLocalDate()} ${date.toLocalTime().toString().take(5)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (photo.isLivePhotoStill) { + Badge(containerColor = MaterialTheme.colorScheme.secondary) { + Text("LIVE", style = MaterialTheme.typography.labelSmall) + } + } + } + + Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f), + contentAlignment = Alignment.Center) { + if (bitmap != null) { + androidx.compose.foundation.Image( + bitmap = bitmap!!, + contentDescription = photo.filename, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + if (photo.isLivePhotoStill) { + Spacer(Modifier.height(8.dp)) + Text("Loading Live Photo + opening video…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + + Text("${photo.sizeBytes / 1024} KB · ${photo.sourceFolder}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + + Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { Text("Close") } + } + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index 357c057..100428a 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -51,6 +51,7 @@ fun DensePhotoGrid( onTogglePhoto: (PhonePhoto) -> Unit, onSelectAllFolder: (List) -> Unit, onDeselectAllFolder: (List) -> Unit, + onPreviewPhoto: ((PhonePhoto) -> Unit)? = null, modifier: Modifier = Modifier, ) { // In-memory bitmap cache — survives cell recycling during scroll @@ -151,6 +152,7 @@ fun DensePhotoGrid( thumbnailVersion = thumbnailVersion, bitmapCache = bitmapCache, onToggle = { onTogglePhoto(labeled.photo) }, + onPreview = onPreviewPhoto, dateLabel = labeled.dateLabel, modifier = Modifier.weight(1f), ) @@ -175,6 +177,7 @@ private fun DenseThumb( thumbnailVersion: Int, bitmapCache: MutableMap, onToggle: () -> Unit, + onPreview: ((PhonePhoto) -> Unit)?, dateLabel: String?, modifier: Modifier = Modifier, ) { @@ -246,29 +249,49 @@ private fun DenseThumb( tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp)) } } - // LIVE badge for Live Photo stills - if (photo.isLivePhotoStill) { - Badge( - containerColor = MaterialTheme.colorScheme.secondary, - modifier = Modifier.align(Alignment.TopStart).padding(2.dp), - ) { Text("LIVE", style = MaterialTheme.typography.labelSmall) } - } - if (photo.mediaType == MediaType.VIDEO) { - val sizeLabel = if (photo.sizeBytes > 1024 * 1024) - "▶ ${photo.sizeBytes / (1024 * 1024)}MB" - else if (photo.sizeBytes > 0) - "▶ ${photo.sizeBytes / 1024}KB" - else "▶" - Badge(containerColor = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) { - Text(sizeLabel, style = MaterialTheme.typography.labelSmall) - } - } + + // Top-right: selection indicator only (no other labels here) if (isSelected) { Icon(Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp)) } + + // Bottom-left: LIVE badge or video size badge + if (photo.isLivePhotoStill) { + Badge( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.85f), + modifier = Modifier.align(Alignment.BottomStart).padding(2.dp), + ) { Text("LIVE", style = MaterialTheme.typography.labelSmall) } + } else if (photo.mediaType == MediaType.VIDEO) { + val sizeLabel = if (photo.sizeBytes > 1024 * 1024) + "▶ ${photo.sizeBytes / (1024 * 1024)}MB" + else if (photo.sizeBytes > 0) "▶ ${photo.sizeBytes / 1024}KB" + else "▶" + Badge(containerColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.85f), + modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) { + Text(sizeLabel, style = MaterialTheme.typography.labelSmall) + } + } + + // Bottom-right: + preview button + if (onPreview != null) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(2.dp) + .size(20.dp) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), + shape = MaterialTheme.shapes.extraSmall, + ) + .clickable { onPreview(photo) }, + contentAlignment = Alignment.Center, + ) { + Text("+", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface) + } + } } } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt index 346c6ea..6466bf9 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/VerifyScreen.kt @@ -1,6 +1,8 @@ package com.bolenpad.photophetch.ui import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -8,6 +10,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Warning @@ -15,6 +18,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* 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.Brush import androidx.compose.ui.graphics.Color @@ -29,193 +33,200 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.skia.Image as SkiaImage import java.nio.file.Files +import java.nio.file.Paths + +private enum class VerifyFilter { ALL, OK, UNVERIFIED, FAILED } -/** - * Progress screen shown during active import. - */ @Composable fun ImportingScreen(state: AppState) { val progress by state.importProgress.collectAsState() - - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), + Column(modifier = Modifier.fillMaxSize().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { + verticalArrangement = Arrangement.Center) { Text("Importing…", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(32.dp)) - val p = progress if (p != null && p.total > 0) { val fraction = p.completed.toFloat() / p.total LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f)) Spacer(Modifier.height(12.dp)) Text("${p.completed} / ${p.total} files") - if (p.currentFile.isNotBlank()) { + if (p.currentFile.isNotBlank()) Text(p.currentFile, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - CircularProgressIndicator() - } + } else CircularProgressIndicator() } } -/** - * 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 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 activeFilter by remember { mutableStateOf(VerifyFilter.ALL) } var deleteConfirmShown by remember { mutableStateOf(false) } var deleteDone by remember { mutableStateOf(false) } - // Preview dialog + val r = result ?: return + + // Build initial delete set: verified + EXIF-confirmed only + val deletableResults = r.results.filter { it.verified && it.error == null } + val initiallyChecked = deletableResults + .filter { it.exifVerified != false } + .map { it.photo.devicePath }.toSet() + var toDelete by remember(r) { mutableStateOf(initiallyChecked) } + + // "Removed from import" set — user explicitly removed these from staging + var removedFromImport by remember { mutableStateOf(setOf()) } + previewResult?.let { pr -> - PreviewDialog(result = pr, onDismiss = { previewResult = null }) + ResultPreviewDialog(result = pr, onDismiss = { previewResult = null }) } - Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { - - // Summary header - ResultSummaryHeader( - success = r.successCount, - failure = r.failureCount, - exifUnverified = r.results.count { it.exifVerified == false }, - destination = "${r.job.stagingRoot}/${r.job.folderName}", - deleteAfterVerify = importConfig.deleteAfterVerify, + if (deleteConfirmShown) { + AlertDialog( + onDismissRequest = { deleteConfirmShown = false }, + title = { Text("Delete ${toDelete.size} files from phone?") }, + text = { + val unverifiedCount = r.results.count { it.exifVerified == false && it.photo.devicePath !in toDelete } + Text(buildString { + append("This is permanent.\n") + if (unverifiedCount > 0) append("$unverifiedCount unverified file(s) are excluded and stay on your phone.") + }) + }, + confirmButton = { + Button(onClick = { + state.deleteSpecificFromDevice(toDelete) + deleteDone = true + deleteConfirmShown = false + }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { + Text("Delete from Phone") + } + }, + dismissButton = { OutlinedButton(onClick = { deleteConfirmShown = false }) { Text("Cancel") } }, ) + } - Spacer(Modifier.height(12.dp)) + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 16.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)) + // ── 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 } - // Scrollable results with top/bottom shadows - val listState = rememberLazyListState() - val topShadowAlpha by remember { - derivedStateOf { - val scrolled = listState.firstVisibleItemScrollOffset + - listState.firstVisibleItemIndex * 80 - (scrolled / 60f).coerceIn(0f, 1f) - } - } - val bottomShadowAlpha by remember { - derivedStateOf { - val info = listState.layoutInfo - val lastVisible = info.visibleItemsInfo.lastOrNull() - val total = info.totalItemsCount - if (lastVisible == null || lastVisible.index >= total - 1) 0f else 1f - } - } - - if (topShadowAlpha > 0f) { - 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(2.dp), - ) { - items(r.results, key = { it.photo.devicePath }) { copyResult -> - 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 }, - ) - } - } - - 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.08f * bottomShadowAlpha)))) - }) - } - - HorizontalDivider() - Spacer(Modifier.height(12.dp)) - - // Actions - if (importConfig.deleteAfterVerify && !deleteDone) { - if (deleteConfirmShown) { - DeleteConfirmCard( - count = toDelete.size, - exifUnverifiedCount = r.results.count { - it.photo.devicePath !in toDelete && it.exifVerified == false - }, - onConfirm = { - state.deleteSpecificFromDevice(toDelete) - deleteDone = true - deleteConfirmShown = false - }, - onCancel = { deleteConfirmShown = false }, - ) - } else if (toDelete.isNotEmpty()) { - OutlinedButton( - onClick = { deleteConfirmShown = true }, - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - modifier = Modifier.fillMaxWidth(), - ) { - Text("Delete ${toDelete.size} files from phone") + Card(colors = CardDefaults.cardColors( + containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer)) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + Column { + Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors", + fontWeight = FontWeight.SemiBold) + Text("→ ${r.job.stagingRoot}/${r.job.folderName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error) + if (unverifiedCount > 0) StatusChip("$unverifiedCount ⚠", MaterialTheme.colorScheme.tertiary) + StatusChip("$okCount ✓", MaterialTheme.colorScheme.primary) } } - } else if (deleteDone) { - Text("✓ Files deleted from phone", - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.height(8.dp)) + + // ── Filter bar ───────────────────────────────────────────────────── + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + VerifyFilter.entries.forEach { filter -> + val label = when (filter) { + VerifyFilter.ALL -> "All (${r.results.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) }, + ) + } + } + + Spacer(Modifier.height(4.dp)) + + // ── 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 + } + } + + val listState = rememberLazyListState() + val topAlpha by remember { derivedStateOf { + ((listState.firstVisibleItemScrollOffset + listState.firstVisibleItemIndex * 80) / 60f).coerceIn(0f, 1f) + }} + val bottomAlpha by remember { derivedStateOf { + val info = listState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + if (last == null || last.index >= info.totalItemsCount - 1) 0f else 1f + }} + + if (topAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent { + drawContent() + drawRect(brush = Brush.verticalGradient(listOf(Color.Black.copy(0.08f * topAlpha), Color.Transparent))) + }) + + LazyColumn(state = listState, modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp)) { + items(filteredResults, key = { it.photo.devicePath }) { cr -> + VerifyResultRow( + result = cr, + showCheckbox = importConfig.deleteAfterVerify && !deleteDone, + isChecked = cr.photo.devicePath in toDelete, + canCheck = cr.verified && cr.error == null, + 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) {} + }, + ) + } + } + + if (bottomAlpha > 0f) Box(Modifier.fillMaxWidth().height(16.dp).drawWithContent { + drawContent() + drawRect(brush = Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(0.08f * bottomAlpha)))) + }) + + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + // ── Actions ──────────────────────────────────────────────────────── + if (importConfig.deleteAfterVerify && !deleteDone && toDelete.isNotEmpty()) { + OutlinedButton( + onClick = { deleteConfirmShown = true }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { Text("Delete ${toDelete.size} verified files from phone") } + Spacer(Modifier.height(4.dp)) + } else if (deleteDone) { + Text("✓ Files deleted from phone", color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(4.dp)) + } + Button( onClick = { state.navigateTo(Screen.CONNECT); state.selectNone() }, modifier = Modifier.fillMaxWidth(), @@ -228,171 +239,143 @@ fun VerifyScreen(state: AppState) { } @Composable -private fun ResultSummaryHeader(success: Int, failure: Int, exifUnverified: Int, destination: String, deleteAfterVerify: Boolean) { - 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(buildString { - append("$success copied") - if (failure > 0) append(", $failure failed") - if (exifUnverified > 0 && deleteAfterVerify) append(", $exifUnverified unverified (⚠ pre-unchecked)") - else if (exifUnverified > 0) append(", $exifUnverified unverified EXIF") - }) - Text("→ $destination", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } +private fun StatusChip(label: String, color: Color) { + Surface(shape = MaterialTheme.shapes.small, + color = color.copy(alpha = 0.15f), + contentColor = color) { + Text(label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold) } } @Composable -private fun CopyResultRow( +private fun VerifyResultRow( result: CopyResult, showCheckbox: Boolean, isChecked: Boolean, canCheck: Boolean, onToggleCheck: () -> Unit, onPreview: (CopyResult) -> Unit, + onRemove: () -> Unit, ) { val isFailed = result.error != null || !result.verified val exifUnverified = result.exifVerified == false val exifUnavailable = result.exifVerified == null - val iconTint = when { - isFailed -> MaterialTheme.colorScheme.error - exifUnverified -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.primary - } - val icon = when { - isFailed -> Icons.Default.Error - exifUnverified -> Icons.Default.Warning - else -> Icons.Default.CheckCircle + // Row background: red tint for failures, yellow tint for unverified + val rowBg = when { + isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + else -> Color.Transparent } Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) - .clickable { onPreview(result) }, + modifier = Modifier.fillMaxWidth() + .background(rowBg) + .padding(vertical = 4.dp, horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Delete checkbox (only shown when deleteAfterVerify is on and import isn't done) + // Thumbnail chip on left — 44dp square + ThumbnailChip( + destinationPath = result.destinationPath.toString(), + onClick = { onPreview(result) }, + ) + + // Checkbox if (showCheckbox && canCheck) { - Checkbox( - checked = isChecked, - onCheckedChange = { onToggleCheck() }, - modifier = Modifier.size(20.dp), - ) + 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)) + // 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) + 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)"}'" + + exifUnverified -> "⚠ Make: '${result.exifMake?.ifEmpty { "absent" } ?: "absent"}'" + if (showCheckbox) " — kept on phone" else "" - exifUnavailable -> "Copied · ${result.photo.sizeBytes / 1024} KB" - else -> "✓ Make: ${result.exifMake} · ${result.photo.sizeBytes / 1024} KB" + 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 - }, + color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant }, ) } - Text("👁", style = MaterialTheme.typography.labelSmall, - color = 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)) + } } } @Composable -private fun DeleteConfirmCard( - count: Int, - exifUnverifiedCount: Int, - onConfirm: () -> Unit, - onCancel: () -> Unit, -) { - Card( - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), - modifier = Modifier.fillMaxWidth(), +private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) { + var bitmap by remember(destinationPath) { mutableStateOf(null) } + LaunchedEffect(destinationPath) { + val bytes = withContext(Dispatchers.IO) { + try { Files.readAllBytes(Paths.get(destinationPath)) } catch (_: Exception) { null } + } + if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } + } + Box( + modifier = Modifier.size(44.dp).clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, ) { - 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( - 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") - } - OutlinedButton(onClick = onCancel) { Text("Cancel") } - } + if (bitmap != null) { + Image(bitmap = bitmap!!, contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else { + Text("👁", style = MaterialTheme.typography.labelSmall) } } } @Composable -private fun PreviewDialog(result: CopyResult, onDismiss: () -> Unit) { +private fun ResultPreviewDialog(result: CopyResult, onDismiss: () -> Unit) { var bitmap by remember(result.destinationPath) { mutableStateOf(null) } - LaunchedEffect(result.destinationPath) { val bytes = withContext(Dispatchers.IO) { try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null } } - if (bytes != null) { - runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } - } + if (bytes != null) 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)) { Text(result.photo.filename, fontWeight = FontWeight.SemiBold) - Text( - when (result.exifVerified) { - true -> "✓ EXIF Make: ${result.exifMake}" - false -> "⚠ Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not a confirmed camera original" - null -> "EXIF not checked (install exiftool)" - }, - style = MaterialTheme.typography.bodySmall, + Text(when (result.exifVerified) { + true -> "✓ EXIF Make: ${result.exifMake}" + 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) { true -> MaterialTheme.colorScheme.primary false -> MaterialTheme.colorScheme.tertiary null -> MaterialTheme.colorScheme.onSurfaceVariant - }, - ) + }) HorizontalDivider() Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f), contentAlignment = Alignment.Center) { - if (bitmap != null) { - Image(bitmap = bitmap!!, contentDescription = result.photo.filename, - contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) - } else { - CircularProgressIndicator() - } + if (bitmap != null) Image(bitmap = bitmap!!, contentDescription = null, + contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) + else CircularProgressIndicator() } Text("${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}", style = MaterialTheme.typography.labelSmall,