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.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.bolenpad.photophetch.model.PhonePhoto import com.bolenpad.photophetch.model.PhonePhoto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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 import java.time.ZoneId
@Composable @Composable
@@ -25,6 +34,20 @@ fun BrowseScreen(state: AppState) {
val device by state.selectedDevice.collectAsState() val device by state.selectedDevice.collectAsState()
val config by state.config.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()) { Column(modifier = Modifier.fillMaxSize()) {
var columnCount by remember { mutableStateOf(4) } var columnCount by remember { mutableStateOf(4) }
@@ -112,6 +135,7 @@ fun BrowseScreen(state: AppState) {
onDeselectAllFolder = { folder -> onDeselectAllFolder = { folder ->
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) } folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
}, },
onPreviewPhoto = { previewPhoto = it },
modifier = Modifier.weight(1f), 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, onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit, onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit, onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
onPreviewPhoto: ((PhonePhoto) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// In-memory bitmap cache — survives cell recycling during scroll // In-memory bitmap cache — survives cell recycling during scroll
@@ -151,6 +152,7 @@ fun DensePhotoGrid(
thumbnailVersion = thumbnailVersion, thumbnailVersion = thumbnailVersion,
bitmapCache = bitmapCache, bitmapCache = bitmapCache,
onToggle = { onTogglePhoto(labeled.photo) }, onToggle = { onTogglePhoto(labeled.photo) },
onPreview = onPreviewPhoto,
dateLabel = labeled.dateLabel, dateLabel = labeled.dateLabel,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -175,6 +177,7 @@ private fun DenseThumb(
thumbnailVersion: Int, thumbnailVersion: Int,
bitmapCache: MutableMap<String, ImageBitmap>, bitmapCache: MutableMap<String, ImageBitmap>,
onToggle: () -> Unit, onToggle: () -> Unit,
onPreview: ((PhonePhoto) -> Unit)?,
dateLabel: String?, dateLabel: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -246,29 +249,49 @@ private fun DenseThumb(
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp)) tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
} }
} }
// LIVE badge for Live Photo stills
if (photo.isLivePhotoStill) { // Top-right: selection indicator only (no other labels here)
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)
}
}
if (isSelected) { if (isSelected) {
Icon(Icons.Default.CheckCircle, contentDescription = null, Icon(Icons.Default.CheckCircle, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp)) 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 package com.bolenpad.photophetch.ui
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle 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.Error
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
@@ -15,6 +18,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -29,193 +33,200 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage import org.jetbrains.skia.Image as SkiaImage
import java.nio.file.Files 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 @Composable
fun ImportingScreen(state: AppState) { fun ImportingScreen(state: AppState) {
val progress by state.importProgress.collectAsState() val progress by state.importProgress.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(32.dp),
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center) {
) {
Text("Importing…", style = MaterialTheme.typography.headlineSmall) Text("Importing…", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
val p = progress val p = progress
if (p != null && p.total > 0) { if (p != null && p.total > 0) {
val fraction = p.completed.toFloat() / p.total val fraction = p.completed.toFloat() / p.total
LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f)) LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(0.6f))
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text("${p.completed} / ${p.total} files") Text("${p.completed} / ${p.total} files")
if (p.currentFile.isNotBlank()) { if (p.currentFile.isNotBlank())
Text(p.currentFile, style = MaterialTheme.typography.bodySmall, Text(p.currentFile, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) 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 @Composable
fun VerifyScreen(state: AppState) { fun VerifyScreen(state: AppState) {
val result by state.importResult.collectAsState() val result by state.importResult.collectAsState()
val importConfig by state.importConfig.collectAsState() val importConfig by state.importConfig.collectAsState()
var previewResult by remember { mutableStateOf<CopyResult?>(null) } var previewResult by remember { mutableStateOf<CopyResult?>(null) }
var activeFilter by remember { mutableStateOf(VerifyFilter.ALL) }
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 deleteConfirmShown by remember { mutableStateOf(false) }
var deleteDone 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 -> previewResult?.let { pr ->
PreviewDialog(result = pr, onDismiss = { previewResult = null }) ResultPreviewDialog(result = pr, onDismiss = { previewResult = null })
} }
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { if (deleteConfirmShown) {
AlertDialog(
// Summary header onDismissRequest = { deleteConfirmShown = false },
ResultSummaryHeader( title = { Text("Delete ${toDelete.size} files from phone?") },
success = r.successCount, text = {
failure = r.failureCount, val unverifiedCount = r.results.count { it.exifVerified == false && it.photo.devicePath !in toDelete }
exifUnverified = r.results.count { it.exifVerified == false }, Text(buildString {
destination = "${r.job.stagingRoot}/${r.job.folderName}", append("This is permanent.\n")
deleteAfterVerify = importConfig.deleteAfterVerify, 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( // ── Summary ────────────────────────────────────────────────────────
modifier = Modifier.fillMaxWidth(), val failCount = r.results.count { it.error != null || !it.verified }
horizontalArrangement = Arrangement.SpaceBetween, val unverifiedCount = r.results.count { it.exifVerified == false }
verticalAlignment = Alignment.CenterVertically, val okCount = r.results.count { it.verified && it.error == null && it.exifVerified != false }
) {
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))
// Scrollable results with top/bottom shadows Card(colors = CardDefaults.cardColors(
val listState = rememberLazyListState() containerColor = if (failCount == 0) MaterialTheme.colorScheme.primaryContainer
val topShadowAlpha by remember { else MaterialTheme.colorScheme.errorContainer)) {
derivedStateOf { Row(modifier = Modifier.fillMaxWidth().padding(12.dp),
val scrolled = listState.firstVisibleItemScrollOffset + horizontalArrangement = Arrangement.SpaceBetween,
listState.firstVisibleItemIndex * 80 verticalAlignment = Alignment.CenterVertically) {
(scrolled / 60f).coerceIn(0f, 1f) Column {
} Text(if (failCount == 0) "Import complete ✓" else "Import complete with errors",
} fontWeight = FontWeight.SemiBold)
val bottomShadowAlpha by remember { Text("${r.job.stagingRoot}/${r.job.folderName}",
derivedStateOf { style = MaterialTheme.typography.bodySmall,
val info = listState.layoutInfo color = MaterialTheme.colorScheme.onSurfaceVariant)
val lastVisible = info.visibleItemsInfo.lastOrNull() }
val total = info.totalItemsCount Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (lastVisible == null || lastVisible.index >= total - 1) 0f else 1f if (failCount > 0) StatusChip("$failCount failed", MaterialTheme.colorScheme.error)
} if (unverifiedCount > 0) StatusChip("$unverifiedCount", MaterialTheme.colorScheme.tertiary)
} StatusChip("$okCount", MaterialTheme.colorScheme.primary)
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")
} }
} }
} else if (deleteDone) {
Text("✓ Files deleted from phone",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall)
} }
Spacer(Modifier.height(8.dp)) 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( Button(
onClick = { state.navigateTo(Screen.CONNECT); state.selectNone() }, onClick = { state.navigateTo(Screen.CONNECT); state.selectNone() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -228,171 +239,143 @@ fun VerifyScreen(state: AppState) {
} }
@Composable @Composable
private fun ResultSummaryHeader(success: Int, failure: Int, exifUnverified: Int, destination: String, deleteAfterVerify: Boolean) { private fun StatusChip(label: String, color: Color) {
Card(colors = CardDefaults.cardColors( Surface(shape = MaterialTheme.shapes.small,
containerColor = if (failure == 0) MaterialTheme.colorScheme.primaryContainer color = color.copy(alpha = 0.15f),
else MaterialTheme.colorScheme.errorContainer, contentColor = color) {
)) { Text(label, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold)
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)
}
} }
} }
@Composable @Composable
private fun CopyResultRow( private fun VerifyResultRow(
result: CopyResult, result: CopyResult,
showCheckbox: Boolean, showCheckbox: Boolean,
isChecked: Boolean, isChecked: Boolean,
canCheck: Boolean, canCheck: Boolean,
onToggleCheck: () -> Unit, onToggleCheck: () -> Unit,
onPreview: (CopyResult) -> Unit, onPreview: (CopyResult) -> Unit,
onRemove: () -> 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
val iconTint = when { // Row background: red tint for failures, yellow tint for unverified
isFailed -> MaterialTheme.colorScheme.error val rowBg = when {
exifUnverified -> MaterialTheme.colorScheme.tertiary isFailed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.primary exifUnverified -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
} else -> Color.Transparent
val icon = when {
isFailed -> Icons.Default.Error
exifUnverified -> Icons.Default.Warning
else -> Icons.Default.CheckCircle
} }
Row( Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp) modifier = Modifier.fillMaxWidth()
.clickable { onPreview(result) }, .background(rowBg)
.padding(vertical = 4.dp, horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, 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) { if (showCheckbox && canCheck) {
Checkbox( Checkbox(checked = isChecked, onCheckedChange = { onToggleCheck() },
checked = isChecked, modifier = Modifier.size(20.dp))
onCheckedChange = { onToggleCheck() },
modifier = Modifier.size(20.dp),
)
} else if (showCheckbox) { } else if (showCheckbox) {
Spacer(Modifier.width(20.dp)) 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)) { 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}"
else result.photo.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( Text(
when { when {
isFailed -> result.error ?: "Failed" isFailed -> result.error ?: "Failed"
exifUnverified -> "⚠ Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}'" + exifUnverified -> "⚠ Make: '${result.exifMake?.ifEmpty { "absent" } ?: "absent"}'" +
if (showCheckbox) " — kept on phone" else "" if (showCheckbox) " — kept on phone" else ""
exifUnavailable -> "Copied · ${result.photo.sizeBytes / 1024} KB" exifUnavailable -> "${result.photo.sizeBytes / 1024} KB"
else -> " Make: ${result.exifMake} · ${result.photo.sizeBytes / 1024} KB" else -> "${result.exifMake} · ${result.photo.sizeBytes / 1024} KB"
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = when { color = when { isFailed -> MaterialTheme.colorScheme.error; exifUnverified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.onSurfaceVariant },
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 @Composable
private fun DeleteConfirmCard( private fun ThumbnailChip(destinationPath: String, onClick: () -> Unit) {
count: Int, var bitmap by remember(destinationPath) { mutableStateOf<ImageBitmap?>(null) }
exifUnverifiedCount: Int, LaunchedEffect(destinationPath) {
onConfirm: () -> Unit, val bytes = withContext(Dispatchers.IO) {
onCancel: () -> Unit, try { Files.readAllBytes(Paths.get(destinationPath)) } catch (_: Exception) { null }
) { }
Card( if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), }
modifier = Modifier.fillMaxWidth(), 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)) { if (bitmap != null) {
Text("Delete $count files from phone?", fontWeight = FontWeight.SemiBold, Image(bitmap = bitmap!!, contentDescription = null,
color = MaterialTheme.colorScheme.onErrorContainer) contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
Text( } else {
buildString { Text("👁", style = MaterialTheme.typography.labelSmall)
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") }
}
} }
} }
} }
@Composable @Composable
private fun PreviewDialog(result: CopyResult, onDismiss: () -> Unit) { private fun ResultPreviewDialog(result: CopyResult, onDismiss: () -> Unit) {
var bitmap by remember(result.destinationPath) { mutableStateOf<ImageBitmap?>(null) } var bitmap by remember(result.destinationPath) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(result.destinationPath) { LaunchedEffect(result.destinationPath) {
val bytes = withContext(Dispatchers.IO) { val bytes = withContext(Dispatchers.IO) {
try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null } try { Files.readAllBytes(result.destinationPath) } catch (_: Exception) { null }
} }
if (bytes != null) { if (bytes != null) runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
runCatching { bitmap = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() }
}
} }
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.fillMaxWidth(0.9f)) { Card(modifier = Modifier.fillMaxWidth(0.9f)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(result.photo.filename, fontWeight = FontWeight.SemiBold) Text(result.photo.filename, fontWeight = FontWeight.SemiBold)
Text( Text(when (result.exifVerified) {
when (result.exifVerified) { true -> "✓ EXIF Make: ${result.exifMake}"
true -> "✓ EXIF Make: ${result.exifMake}" false -> " Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not a confirmed camera original"
false -> "⚠ Make: '${result.exifMake?.ifEmpty { "(absent)" } ?: "(absent)"}' — not a confirmed camera original" null -> "EXIF not checked (install exiftool)"
null -> "EXIF not checked (install exiftool)" }, style = MaterialTheme.typography.bodySmall,
},
style = MaterialTheme.typography.bodySmall,
color = when (result.exifVerified) { color = when (result.exifVerified) {
true -> MaterialTheme.colorScheme.primary true -> MaterialTheme.colorScheme.primary
false -> MaterialTheme.colorScheme.tertiary false -> MaterialTheme.colorScheme.tertiary
null -> MaterialTheme.colorScheme.onSurfaceVariant null -> MaterialTheme.colorScheme.onSurfaceVariant
}, })
)
HorizontalDivider() HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f), Box(modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
contentAlignment = Alignment.Center) { contentAlignment = Alignment.Center) {
if (bitmap != null) { if (bitmap != null) Image(bitmap = bitmap!!, contentDescription = null,
Image(bitmap = bitmap!!, contentDescription = result.photo.filename, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize())
contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) else CircularProgressIndicator()
} else {
CircularProgressIndicator()
}
} }
Text("${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}", Text("${result.photo.sizeBytes / 1024} KB · ${result.destinationPath}",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,