Browse + Verify screen improvements

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)
This commit is contained in:
Kyle Bolen
2026-07-30 05:30:43 +00:00
parent f90baa92d4
commit 6863bad7a0
3 changed files with 398 additions and 266 deletions

View File

@@ -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<PhonePhoto?>(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<ImageBitmap?>(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") }
}
}
}
}

View File

@@ -51,6 +51,7 @@ fun DensePhotoGrid(
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> 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<String, ImageBitmap>,
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)
}
}
}
}
}

View File

@@ -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<CopyResult?>(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<String>()) }
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<ImageBitmap?>(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<ImageBitmap?>(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,