Fix scroll spy + date overlay on first photo of each day

Scroll spy: use key(viewMode) around derivedStateOf so activeFolder
recomputes against the correct listState when switching modes.

Dense date markers: remove separate DAY_BOUNDARY row item entirely.
Date label ('Jul 28') now overlaid on the top-left corner of the first
photo in each new day — zero wasted vertical space.
This commit is contained in:
Kyle Bolen
2026-07-28 06:31:33 +00:00
parent 60d72ce0bd
commit a6b489f133
2 changed files with 66 additions and 78 deletions

View File

@@ -100,37 +100,29 @@ fun BrowseScreen(state: AppState) {
} }
} }
// Dense mode: folder scroll index (accounts for day boundary markers) // Dense mode: folder scroll index (photo rows only, no day boundary items)
val denseScrollIndex = remember(folderSections, columnCount) { val denseScrollIndex = remember(folderSections, columnCount) {
var idx = 0 var idx = 0
folderSections.associate { folderSection -> folderSections.associate { folderSection ->
val startIdx = idx val startIdx = idx
idx += 1 // folder header idx += 1 // folder header
val sorted = folderSection.allPhotos.sortedByDescending { it.dateTaken } idx += ((folderSection.allPhotos.size + columnCount - 1) / columnCount) // photo rows
var lastDate: java.time.LocalDate? = null
val zoneId = java.time.ZoneId.systemDefault()
sorted.chunked(columnCount).forEach { row ->
val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate()
if (rowDate != lastDate) {
if (lastDate != null) idx += 1 // day boundary marker
lastDate = rowDate
}
idx += 1 // photo row
}
folderSection.folder.name to startIdx folderSection.folder.name to startIdx
} }
} }
val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex
// Scroll spy: which folder is currently at the top of the visible area // Scroll spy — keyed on viewMode so it recomposes when switching
val activeFolder by remember { val activeFolder by key(viewMode) {
derivedStateOf { remember {
val firstVisible = listState.firstVisibleItemIndex derivedStateOf {
folderScrollIndex.entries val firstVisible = listState.firstVisibleItemIndex
.filter { it.value <= firstVisible } folderScrollIndex.entries
.maxByOrNull { it.value }?.key .filter { it.value <= firstVisible }
?: folderSections.firstOrNull()?.folder?.name .maxByOrNull { it.value }?.key
?: folderSections.firstOrNull()?.folder?.name
}
} }
} }

View File

@@ -7,7 +7,6 @@ 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
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
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.Image import androidx.compose.material.icons.filled.Image
@@ -34,12 +33,9 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
/** /**
* Dense grid view: photos flow as a tight grid per folder. * Dense grid: folder header + flat photo rows per folder.
* Folder headers separate sections. Day boundaries are marked with a * Day boundaries shown as a date label overlaid on the top-left corner
* thin rule + date label that scrolls inline with the photos — no * of the first photo in each new day — no separate row, zero wasted space.
* separate scrubber strip.
*
* Exposes [listState] so the parent can wire up the folder jump list.
*/ */
@Composable @Composable
fun DensePhotoGrid( fun DensePhotoGrid(
@@ -62,27 +58,35 @@ fun DensePhotoGrid(
} }
val zoneId = ZoneId.systemDefault() val zoneId = ZoneId.systemDefault()
val dayFmt = remember { DateTimeFormatter.ofPattern("EEE, MMM d") } val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") }
// Build flat item list per folder section // Build items: FOLDER_HEADER | PHOTO_ROW (with optional dateLabel on first photo)
val items: List<GridItem> = buildList { // dateLabel is set on the first photo of each new day within a folder
data class PhotoRow(
val photos: List<PhonePhoto>,
val dateLabel: String?, // non-null on first row of a new day
)
data class Item(
val type: GridItemType,
val folderSection: FolderSection? = null,
val photoRow: PhotoRow? = null,
)
val items: List<Item> = buildList {
folderSections.forEach { section -> folderSections.forEach { section ->
if (section.allPhotos.isEmpty()) return@forEach if (section.allPhotos.isEmpty()) return@forEach
add(GridItem(GridItemType.FOLDER_HEADER, folderSection = section)) add(Item(GridItemType.FOLDER_HEADER, folderSection = section))
val sorted = section.allPhotos.sortedByDescending { it.dateTaken } val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
var lastDate: LocalDate? = null var lastDate: LocalDate? = null
sorted.chunked(columnCount).forEach { row -> sorted.chunked(columnCount).forEach { rowPhotos ->
val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate() val rowDate = rowPhotos.first().dateTaken.atZone(zoneId).toLocalDate()
// Emit a day boundary marker when the date changes val label = if (rowDate != lastDate) {
if (rowDate != lastDate) {
if (lastDate != null) { // don't emit before first row — folder header serves that
add(GridItem(GridItemType.DAY_BOUNDARY, dayDate = rowDate))
}
lastDate = rowDate lastDate = rowDate
} dayFmt.format(rowDate)
add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row)) } else null
add(Item(GridItemType.PHOTO_ROW, photoRow = PhotoRow(rowPhotos, label)))
} }
} }
} }
@@ -98,8 +102,7 @@ fun DensePhotoGrid(
val item = items[idx] val item = items[idx]
when (item.type) { when (item.type) {
GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}" GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}"
GridItemType.DAY_BOUNDARY -> "day-${item.folderSection?.folder?.name}-${item.dayDate}" else -> "row-${item.photoRow!!.photos.first().devicePath}"
GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}"
} }
}, },
) { idx -> ) { idx ->
@@ -117,43 +120,25 @@ fun DensePhotoGrid(
onDeselectAll = { onDeselectAllFolder(section.allPhotos) }, onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
) )
} }
GridItemType.DAY_BOUNDARY -> { else -> {
// Thin rule with a small date label — scrolls with the grid val row = item.photoRow!!
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
HorizontalDivider(modifier = Modifier.width(16.dp), thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant)
Text(
dayFmt.format(item.dayDate!!),
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
HorizontalDivider(modifier = Modifier.weight(1f), thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant)
}
}
GridItemType.PHOTO_ROW -> {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
item.rowPhotos!!.forEach { photo -> row.photos.forEachIndexed { colIdx, photo ->
DenseThumb( DenseThumb(
photo = photo, photo = photo,
isSelected = photo in selectedPhotos, isSelected = photo in selectedPhotos,
device = device, device = device,
adbBin = adbBin, adbBin = adbBin,
onToggle = { onTogglePhoto(photo) }, onToggle = { onTogglePhoto(photo) },
// Show date label only on first photo of the row that starts a new day
dateLabel = if (colIdx == 0) row.dateLabel else null,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
repeat(columnCount - item.rowPhotos.size) { Spacer(Modifier.weight(1f)) } repeat(columnCount - row.photos.size) { Spacer(Modifier.weight(1f)) }
} }
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
} }
@@ -169,6 +154,7 @@ private fun DenseThumb(
device: DeviceInfo?, device: DeviceInfo?,
adbBin: String, adbBin: String,
onToggle: () -> Unit, onToggle: () -> Unit,
dateLabel: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) } var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
@@ -195,6 +181,7 @@ private fun DenseThumb(
.clickable(onClick = onToggle) .clickable(onClick = onToggle)
.then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier), .then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier),
) { ) {
// Thumbnail or placeholder
if (thumbnail != null) { if (thumbnail != null) {
Image( Image(
bitmap = thumbnail!!, bitmap = thumbnail!!,
@@ -207,29 +194,38 @@ private fun DenseThumb(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant), Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(Icons.Default.Image, contentDescription = null,
Icons.Default.Image, tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
contentDescription = null,
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(20.dp),
)
} }
} }
// Date label overlaid on top-left — only on first photo of a new day
if (dateLabel != null) {
Text(
dateLabel,
fontSize = 9.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier
.align(Alignment.TopStart)
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 4.dp, vertical = 2.dp),
)
}
// Video badge
if (photo.mediaType == MediaType.VIDEO) { if (photo.mediaType == MediaType.VIDEO) {
Badge( Badge(
containerColor = MaterialTheme.colorScheme.tertiary, containerColor = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.align(Alignment.TopStart).padding(2.dp), modifier = Modifier.align(Alignment.BottomStart).padding(2.dp),
) { Text("", style = MaterialTheme.typography.labelSmall) } ) { Text("", style = MaterialTheme.typography.labelSmall) }
} }
// Selection checkmark
if (isSelected) { if (isSelected) {
Icon( Icon(Icons.Default.CheckCircle, contentDescription = null,
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))
)
} }
} }
} }