VerifyScreen: top + bottom scroll shadows on results list

Top shadow: fades in as you scroll down from the top (content hidden above).
Bottom shadow: present when last item is not yet visible (content below).
Both use drawWithContent overlay at 8% black opacity, 24dp strip.
This commit is contained in:
Kyle Bolen
2026-07-28 05:36:55 +00:00
parent 87241ba1d4
commit 7aa7e4dae1

View File

@@ -3,6 +3,7 @@ package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
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.Error import androidx.compose.material.icons.filled.Error
@@ -11,12 +12,15 @@ 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.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bolenpad.photophetch.model.CopyResult import com.bolenpad.photophetch.model.CopyResult
/** /**
* Shown during active import (IMPORTING screen) and after import completes (VERIFY screen). * Shown during active import (IMPORTING screen).
*/ */
@Composable @Composable
fun ImportingScreen(state: AppState) { fun ImportingScreen(state: AppState) {
@@ -49,6 +53,9 @@ fun ImportingScreen(state: AppState) {
} }
} }
/**
* Shown after import completes (VERIFY screen).
*/
@Composable @Composable
fun VerifyScreen(state: AppState) { fun VerifyScreen(state: AppState) {
val result by state.importResult.collectAsState() val result by state.importResult.collectAsState()
@@ -59,6 +66,7 @@ fun VerifyScreen(state: AppState) {
val r = result ?: return val r = result ?: return
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) { Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
// Summary header // Summary header
ResultSummaryHeader( ResultSummaryHeader(
success = r.successCount, success = r.successCount,
@@ -67,18 +75,72 @@ fun VerifyScreen(state: AppState) {
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// File-by-file results
Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Text("Results", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Scrollable results list with top + bottom shadows
val listState = rememberLazyListState()
val topShadowAlpha by remember {
derivedStateOf {
val scrolled = listState.firstVisibleItemScrollOffset +
listState.firstVisibleItemIndex * 80 // approximate
(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 // snap off at bottom is fine here since it's item-based not pixel
}
}
Box(
modifier = Modifier
.weight(1f)
.drawWithContent {
drawContent()
// Top shadow — appears when scrolled down
if (topShadowAlpha > 0f) {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.08f * topShadowAlpha),
Color.Transparent,
),
startY = 0f,
endY = 24.dp.toPx(),
)
)
}
// Bottom shadow — fades as last item approaches view
if (bottomShadowAlpha > 0f) {
drawRect(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.08f * bottomShadowAlpha),
),
startY = size.height - 24.dp.toPx(),
endY = size.height,
)
)
}
}
) {
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(r.results, key = { it.photo.devicePath }) { copyResult -> items(r.results, key = { it.photo.devicePath }) { copyResult ->
CopyResultRow(copyResult) CopyResultRow(copyResult)
} }
} }
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
HorizontalDivider() HorizontalDivider()
@@ -165,20 +227,17 @@ private fun CopyResultRow(result: CopyResult) {
if (result.verified && result.error == null) Icons.Default.CheckCircle else Icons.Default.Error, if (result.verified && result.error == null) Icons.Default.CheckCircle else Icons.Default.Error,
contentDescription = null, contentDescription = null,
tint = if (result.verified && result.error == null) tint = if (result.verified && result.error == null)
MaterialTheme.colorScheme.primary MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
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 { else result.photo.filename
result.photo.filename
}
Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) Text(displayName, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
if (result.error != null) { if (result.error != null) {
Text(result.error, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error) Text(result.error, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error)
} else { } else {
Text( Text(
"${result.photo.sizeBytes / 1024} KB • SHA-256: ${result.destSha256.take(12)}", "${result.photo.sizeBytes / 1024} KB • SHA-256: ${result.destSha256.take(12)}",