From a6b489f133d7f8eacfb07cceadee40c11d7cf618 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 06:31:33 +0000 Subject: [PATCH] Fix scroll spy + date overlay on first photo of each day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../bolenpad/photophetch/ui/BrowseScreen.kt | 32 ++--- .../bolenpad/photophetch/ui/DensePhotoGrid.kt | 112 +++++++++--------- 2 files changed, 66 insertions(+), 78 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index e057240..4d17891 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -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) { var idx = 0 folderSections.associate { folderSection -> val startIdx = idx idx += 1 // folder header - val sorted = folderSection.allPhotos.sortedByDescending { it.dateTaken } - 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 - } + idx += ((folderSection.allPhotos.size + columnCount - 1) / columnCount) // photo rows folderSection.folder.name to startIdx } } val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex - // Scroll spy: which folder is currently at the top of the visible area - val activeFolder by remember { - derivedStateOf { - val firstVisible = listState.firstVisibleItemIndex - folderScrollIndex.entries - .filter { it.value <= firstVisible } - .maxByOrNull { it.value }?.key - ?: folderSections.firstOrNull()?.folder?.name + // Scroll spy — keyed on viewMode so it recomposes when switching + val activeFolder by key(viewMode) { + remember { + derivedStateOf { + val firstVisible = listState.firstVisibleItemIndex + folderScrollIndex.entries + .filter { it.value <= firstVisible } + .maxByOrNull { it.value }?.key + ?: folderSections.firstOrNull()?.folder?.name + } } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index 5f62afc..97bf218 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -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.Image @@ -34,12 +33,9 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter /** - * Dense grid view: photos flow as a tight grid per folder. - * Folder headers separate sections. Day boundaries are marked with a - * thin rule + date label that scrolls inline with the photos — no - * separate scrubber strip. - * - * Exposes [listState] so the parent can wire up the folder jump list. + * Dense grid: folder header + flat photo rows per folder. + * Day boundaries shown as a date label overlaid on the top-left corner + * of the first photo in each new day — no separate row, zero wasted space. */ @Composable fun DensePhotoGrid( @@ -62,27 +58,35 @@ fun DensePhotoGrid( } 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 - val items: List = buildList { + // Build items: FOLDER_HEADER | PHOTO_ROW (with optional dateLabel on first photo) + // dateLabel is set on the first photo of each new day within a folder + data class PhotoRow( + val photos: List, + 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 = buildList { folderSections.forEach { section -> 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 } var lastDate: LocalDate? = null - sorted.chunked(columnCount).forEach { row -> - val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate() - // Emit a day boundary marker when the date changes - if (rowDate != lastDate) { - if (lastDate != null) { // don't emit before first row — folder header serves that - add(GridItem(GridItemType.DAY_BOUNDARY, dayDate = rowDate)) - } + sorted.chunked(columnCount).forEach { rowPhotos -> + val rowDate = rowPhotos.first().dateTaken.atZone(zoneId).toLocalDate() + val label = if (rowDate != lastDate) { lastDate = rowDate - } - add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row)) + dayFmt.format(rowDate) + } else null + add(Item(GridItemType.PHOTO_ROW, photoRow = PhotoRow(rowPhotos, label))) } } } @@ -98,8 +102,7 @@ fun DensePhotoGrid( val item = items[idx] when (item.type) { GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}" - GridItemType.DAY_BOUNDARY -> "day-${item.folderSection?.folder?.name}-${item.dayDate}" - GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}" + else -> "row-${item.photoRow!!.photos.first().devicePath}" } }, ) { idx -> @@ -117,43 +120,25 @@ fun DensePhotoGrid( onDeselectAll = { onDeselectAllFolder(section.allPhotos) }, ) } - GridItemType.DAY_BOUNDARY -> { - // Thin rule with a small date label — scrolls with the grid - 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 -> { + else -> { + val row = item.photoRow!! Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), ) { - item.rowPhotos!!.forEach { photo -> + row.photos.forEachIndexed { colIdx, photo -> DenseThumb( photo = photo, isSelected = photo in selectedPhotos, device = device, adbBin = adbBin, 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), ) } - repeat(columnCount - item.rowPhotos.size) { Spacer(Modifier.weight(1f)) } + repeat(columnCount - row.photos.size) { Spacer(Modifier.weight(1f)) } } Spacer(Modifier.height(2.dp)) } @@ -169,6 +154,7 @@ private fun DenseThumb( device: DeviceInfo?, adbBin: String, onToggle: () -> Unit, + dateLabel: String?, modifier: Modifier = Modifier, ) { var thumbnail by remember(photo.devicePath) { mutableStateOf(null) } @@ -195,6 +181,7 @@ private fun DenseThumb( .clickable(onClick = onToggle) .then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier), ) { + // Thumbnail or placeholder if (thumbnail != null) { Image( bitmap = thumbnail!!, @@ -207,29 +194,38 @@ private fun DenseThumb( Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { - Icon( - Icons.Default.Image, - contentDescription = null, - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(20.dp), - ) + Icon(Icons.Default.Image, 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) { Badge( 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) } } + // Selection checkmark if (isSelected) { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, + Icon(Icons.Default.CheckCircle, contentDescription = null, 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)) } } }