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:
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user