From f3e93c2bd09612c4b5bb18944486021684395afe Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 06:23:16 +0000 Subject: [PATCH] Dense mode: add folder headers between folder sections DensePhotoGrid now takes folderSections (same as Grouped) and renders a FolderDividerHeader with Select All / Deselect All between each folder's photos. The flat grid flows within each folder section. Date scrubber indices account for the header items. --- .../bolenpad/photophetch/ui/BrowseScreen.kt | 12 +- .../bolenpad/photophetch/ui/DensePhotoGrid.kt | 135 +++++++++++------- 2 files changed, 92 insertions(+), 55 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index a131b3c..bc0c742 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -154,12 +154,22 @@ fun BrowseScreen(state: AppState) { modifier = Modifier.weight(1f), ) ViewMode.DENSE -> DensePhotoGrid( - photos = pl.all.sortedByDescending { it.dateTaken }, + folderSections = folderSections, selectedPhotos = selectedPhotos, device = device, adbBin = config.adbPath ?: "adb", columnCount = columnCount, onTogglePhoto = { state.togglePhotoSelection(it) }, + onSelectAllFolder = { folder -> + folder.forEach { p -> + if (p !in selectedPhotos) state.togglePhotoSelection(p) + } + }, + onDeselectAllFolder = { folder -> + folder.forEach { p -> + if (p in selectedPhotos) state.togglePhotoSelection(p) + } + }, modifier = Modifier.weight(1f), ) } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index c1032b4..d8397ab 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -39,24 +39,25 @@ import java.time.format.DateTimeFormatter /** * Dense grid view with a date scrubber strip on the left edge. * - * Photos flow as a flat grid with no day-header rows breaking it up. - * A narrow scrubber column shows date labels at day boundaries. - * Clicking a date label on the scrubber jumps to that day in the grid. + * 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: - * [24dp scrubber] [3dp gap] [photo grid fills remaining width] + * [28dp scrubber] [2dp gap] [photo grid fills remaining width] */ @Composable fun DensePhotoGrid( - photos: List, + folderSections: List, selectedPhotos: Set, device: DeviceInfo?, adbBin: String, columnCount: Int, onTogglePhoto: (PhonePhoto) -> Unit, + onSelectAllFolder: (List) -> Unit, + onDeselectAllFolder: (List) -> Unit, modifier: Modifier = Modifier, ) { - if (photos.isEmpty()) { + if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) { Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No photos", color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -67,53 +68,56 @@ fun DensePhotoGrid( val listState = rememberLazyListState() val scope = rememberCoroutineScope() - // Chunk into rows - val rows: List> = photos.chunked(columnCount) + // Build flat item list: folder header + photo rows per section + data class GridItem( + val type: GridItemType, + val folderSection: com.bolenpad.photophetch.ui.FolderSection? = null, + val rowPhotos: List? = null, + ) + enum class GridItemType { FOLDER_HEADER, PHOTO_ROW } - // For each row index, compute the date of its first photo - val rowDates: List = rows.map { - it.first().dateTaken.atZone(zoneId).toLocalDate() - } - - // Day boundary rows: the first row index where a new date appears - // Maps LocalDate → row index - val dayBoundaries: Map = buildMap { - var lastDate: LocalDate? = null - rowDates.forEachIndexed { idx, date -> - if (date != lastDate) { - put(date, idx) - lastDate = date - } + 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)) } } } - // Ordered list of (date, rowIndex) sorted newest first - val scrubberDates: List> = dayBoundaries.entries - .sortedByDescending { it.key } - .map { it.key to it.value } + // 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 ─────────────────────────────────────────── - // We overlay date labels proportionally over the full height. - // Using a Box with proportional placement rather than a LazyColumn - // so labels stay anchored to their visual position even when scrolling. - Box( modifier = Modifier .width(28.dp) .fillMaxHeight() .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ) { - // Render scrubber labels using scroll-proportional positioning - val totalRows = rows.size - if (totalRows > 0) { + if (totalItems > 0) { ScrubberOverlay( - scrubberDates = scrubberDates, - totalRows = totalRows, + scrubberDates = photoRowIndices, + totalRows = totalItems, onJump = { rowIdx -> scope.launch { listState.animateScrollToItem(rowIdx) } }, monthFmt = monthFmt, dayFmt = dayFmt, @@ -123,34 +127,57 @@ fun DensePhotoGrid( Spacer(Modifier.width(2.dp)) - // ── Photo grid ──────────────────────────────────────────────────── + // ── Photo grid with folder headers ──────────────────────────────── LazyColumn( state = listState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(bottom = 32.dp), ) { items( - count = rows.size, - key = { idx -> "dense-row-${rows[idx].first().devicePath}" }, - ) { rowIdx -> - val rowPhotos = rows[rowIdx] - 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), + 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 -> + 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) }, ) } - repeat(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) } + 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)) + } } - Spacer(Modifier.height(2.dp)) } } }