From 60d72ce0bde070c901f826413b2800a785270eb2 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 06:27:41 +0000 Subject: [PATCH] Dense view: inline day markers + fix folder jump list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace date scrubber strip with inline day boundary markers: a thin rule + 'Mon, Jul 28' label that scrolls with the photos. No separate panel — markers live in the grid flow. Fix folder jump list in Dense mode: maintain separate denseListState and denseScrollIndex (accounting for day boundary items). activeFolder scroll spy now reads from whichever listState is active. --- .../bolenpad/photophetch/ui/BrowseScreen.kt | 41 ++- .../bolenpad/photophetch/ui/DensePhotoGrid.kt | 247 +++++++----------- .../com/bolenpad/photophetch/ui/GridModels.kt | 3 +- 3 files changed, 124 insertions(+), 167 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index 476af23..e057240 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -80,23 +80,49 @@ fun BrowseScreen(state: AppState) { is PhotoListState.Loaded -> { // Build ordered list of (folder, photos-by-day) for the unified scroll val folderSections = buildFolderSections(pl) - val listState = rememberLazyListState() + val groupedListState = rememberLazyListState() + val denseListState = rememberLazyListState() val scope = rememberCoroutineScope() - // Map folder name → first item index in the lazy list - val folderScrollIndex = remember(folderSections, columnCount) { + val listState = if (viewMode == ViewMode.GROUPED) groupedListState else denseListState + + // Grouped mode: folder scroll index (accounts for day headers) + val groupedScrollIndex = remember(folderSections, columnCount) { var idx = 0 folderSections.associate { (folder, byDay) -> val startIdx = idx idx += 1 // folder header byDay.forEach { (_, dayPhotos) -> - if (dayPhotos.size >= 3) idx += 1 // day header only for dense days - idx += ((dayPhotos.size + columnCount - 1) / columnCount) // photo rows + if (dayPhotos.size >= 3) idx += 1 + idx += ((dayPhotos.size + columnCount - 1) / columnCount) } folder.name to startIdx } } + // Dense mode: folder scroll index (accounts for day boundary markers) + 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 + } + 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 { @@ -109,7 +135,7 @@ fun BrowseScreen(state: AppState) { } Row(modifier = Modifier.fillMaxSize()) { - // Left: folder jump list + // Left: folder jump list — jumps in whichever list is active FolderJumpList( folders = pl.folders, activeFolder = activeFolder, @@ -130,7 +156,7 @@ fun BrowseScreen(state: AppState) { selectedPhotos = selectedPhotos, device = device, adbBin = config.adbPath ?: "adb", - listState = listState, + listState = groupedListState, columnCount = columnCount, onTogglePhoto = { state.togglePhotoSelection(it) }, onSelectAllFolder = { folder -> @@ -159,6 +185,7 @@ fun BrowseScreen(state: AppState) { device = device, adbBin = config.adbPath ?: "adb", columnCount = columnCount, + listState = denseListState, onTogglePhoto = { state.togglePhotoSelection(it) }, onSelectAllFolder = { folder -> folder.forEach { p -> diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index 4115ce3..5f62afc 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -11,7 +11,6 @@ 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 -import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -21,7 +20,6 @@ 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.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bolenpad.photophetch.model.DeviceInfo @@ -29,7 +27,6 @@ import com.bolenpad.photophetch.model.MediaType import com.bolenpad.photophetch.model.PhonePhoto import com.bolenpad.photophetch.util.ThumbnailCache import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.skia.Image as SkiaImage import java.time.LocalDate @@ -37,13 +34,12 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter /** - * Dense grid view with a date scrubber strip on the left edge. + * 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. * - * Photos flow as a flat grid per folder, with a folder divider header - * between each folder. No day-header rows break up the grid within a folder. - * - * Layout: - * [28dp scrubber] [2dp gap] [photo grid fills remaining width] + * Exposes [listState] so the parent can wire up the folder jump list. */ @Composable fun DensePhotoGrid( @@ -52,6 +48,7 @@ fun DensePhotoGrid( device: DeviceInfo?, adbBin: String, columnCount: Int, + listState: LazyListState, onTogglePhoto: (PhonePhoto) -> Unit, onSelectAllFolder: (List) -> Unit, onDeselectAllFolder: (List) -> Unit, @@ -65,167 +62,101 @@ fun DensePhotoGrid( } val zoneId = ZoneId.systemDefault() - val listState = rememberLazyListState() - val scope = rememberCoroutineScope() + val dayFmt = remember { DateTimeFormatter.ofPattern("EEE, MMM d") } - // Build flat item list: folder header + photo rows per section + // Build flat item list per folder section val items: List = buildList { folderSections.forEach { section -> if (section.allPhotos.isEmpty()) return@forEach add(GridItem(GridItemType.FOLDER_HEADER, folderSection = section)) - section.allPhotos - .sortedByDescending { it.dateTaken } - .chunked(columnCount) - .forEach { row -> add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row)) } + + 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)) + } + lastDate = rowDate + } + add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row)) + } } } - // Build date scrubber data from photo rows only - val photoRowIndices: List> = buildList { - var lastDate: LocalDate? = null - items.forEachIndexed { idx, item -> - if (item.type == GridItemType.PHOTO_ROW) { - val date = item.rowPhotos!!.first().dateTaken.atZone(zoneId).toLocalDate() - if (date != lastDate) { - add(date to idx) - lastDate = date - } - } - } - }.sortedByDescending { it.first } - - val monthFmt = remember { DateTimeFormatter.ofPattern("MMM") } - val dayFmt = remember { DateTimeFormatter.ofPattern("d") } - val totalItems = items.size - - Row(modifier = modifier.fillMaxSize()) { - - // ── Date scrubber strip ─────────────────────────────────────────── - Box( - modifier = Modifier - .width(28.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - ) { - if (totalItems > 0) { - ScrubberOverlay( - scrubberDates = photoRowIndices, - totalRows = totalItems, - onJump = { rowIdx -> scope.launch { listState.animateScrollToItem(rowIdx) } }, - monthFmt = monthFmt, - dayFmt = dayFmt, - ) - } - } - - Spacer(Modifier.width(2.dp)) - - // ── Photo grid with folder headers ──────────────────────────────── - LazyColumn( - state = listState, - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = 32.dp), - ) { - items( - count = items.size, - key = { idx -> - val item = items[idx] - when (item.type) { - GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}" - GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}" - } - }, - ) { idx -> + LazyColumn( + state = listState, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp), + ) { + items( + count = items.size, + key = { idx -> val item = items[idx] when (item.type) { - GridItemType.FOLDER_HEADER -> { - val section = item.folderSection!! - val folderSelected = section.allPhotos.count { it in selectedPhotos } - FolderDividerHeader( - name = section.folder.name, - total = section.allPhotos.size, - selected = folderSelected, - allSelected = folderSelected == section.allPhotos.size, - onSelectAll = { onSelectAllFolder(section.allPhotos) }, - onDeselectAll = { onDeselectAllFolder(section.allPhotos) }, - ) - } - GridItemType.PHOTO_ROW -> { - val rowPhotos = item.rowPhotos!! - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - rowPhotos.forEach { photo -> - DenseThumb( - photo = photo, - isSelected = photo in selectedPhotos, - device = device, - adbBin = adbBin, - onToggle = { onTogglePhoto(photo) }, - modifier = Modifier.weight(1f), - ) - } - repeat(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) } - } - Spacer(Modifier.height(2.dp)) - } + 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}" } - } - } - } -} - -/** - * Scrubber overlay: proportionally-positioned date labels. - * Uses a custom layout approach with fillMaxHeight + proportional offset. - */ -@Composable -private fun ScrubberOverlay( - scrubberDates: List>, - totalRows: Int, - onJump: (Int) -> Unit, - monthFmt: DateTimeFormatter, - dayFmt: DateTimeFormatter, -) { - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val totalHeight = maxHeight - - // Group by month — only show month label at first day of each month - var lastMonth: Int? = null - - scrubberDates.forEach { (date, rowIdx) -> - val fraction = rowIdx.toFloat() / totalRows.toFloat() - val yOffset = totalHeight * fraction - - val showMonth = date.monthValue != lastMonth - if (showMonth) lastMonth = date.monthValue - - Column( - modifier = Modifier - .offset(y = yOffset) - .fillMaxWidth() - .clickable { onJump(rowIdx) } - .padding(vertical = 1.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (showMonth) { - Text( - monthFmt.format(date), - fontSize = 8.sp, - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - lineHeight = 9.sp, + }, + ) { idx -> + val item = items[idx] + when (item.type) { + GridItemType.FOLDER_HEADER -> { + val section = item.folderSection!! + val folderSelected = section.allPhotos.count { it in selectedPhotos } + FolderDividerHeader( + name = section.folder.name, + total = section.allPhotos.size, + selected = folderSelected, + allSelected = folderSelected == section.allPhotos.size, + onSelectAll = { onSelectAllFolder(section.allPhotos) }, + onDeselectAll = { onDeselectAllFolder(section.allPhotos) }, ) } - Text( - dayFmt.format(date), - fontSize = 9.sp, - fontWeight = if (showMonth) FontWeight.Bold else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - lineHeight = 10.sp, - ) + 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 -> { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + item.rowPhotos!!.forEach { photo -> + DenseThumb( + photo = photo, + isSelected = photo in selectedPhotos, + device = device, + adbBin = adbBin, + onToggle = { onTogglePhoto(photo) }, + modifier = Modifier.weight(1f), + ) + } + repeat(columnCount - item.rowPhotos.size) { Spacer(Modifier.weight(1f)) } + } + Spacer(Modifier.height(2.dp)) + } } } } @@ -285,7 +216,6 @@ private fun DenseThumb( } } - // Video badge if (photo.mediaType == MediaType.VIDEO) { Badge( containerColor = MaterialTheme.colorScheme.tertiary, @@ -293,7 +223,6 @@ private fun DenseThumb( ) { Text("▶", style = MaterialTheme.typography.labelSmall) } } - // Selection indicator if (isSelected) { Icon( Icons.Default.CheckCircle, diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/GridModels.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/GridModels.kt index 58c049a..913c459 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/GridModels.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/GridModels.kt @@ -11,11 +11,12 @@ data class FolderSection( ) /** Item types used in the DensePhotoGrid flat list */ -enum class GridItemType { FOLDER_HEADER, PHOTO_ROW } +enum class GridItemType { FOLDER_HEADER, DAY_BOUNDARY, PHOTO_ROW } /** A single item in the DensePhotoGrid flat list */ data class GridItem( val type: GridItemType, val folderSection: FolderSection? = null, val rowPhotos: List? = null, + val dayDate: LocalDate? = null, )