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.
This commit is contained in:
Kyle Bolen
2026-07-28 06:23:16 +00:00
parent 69a5f451d5
commit f3e93c2bd0
2 changed files with 92 additions and 55 deletions

View File

@@ -154,12 +154,22 @@ fun BrowseScreen(state: AppState) {
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
ViewMode.DENSE -> DensePhotoGrid( ViewMode.DENSE -> DensePhotoGrid(
photos = pl.all.sortedByDescending { it.dateTaken }, folderSections = folderSections,
selectedPhotos = selectedPhotos, selectedPhotos = selectedPhotos,
device = device, device = device,
adbBin = config.adbPath ?: "adb", adbBin = config.adbPath ?: "adb",
columnCount = columnCount, columnCount = columnCount,
onTogglePhoto = { state.togglePhotoSelection(it) }, 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), modifier = Modifier.weight(1f),
) )
} }

View File

@@ -39,24 +39,25 @@ import java.time.format.DateTimeFormatter
/** /**
* Dense grid view with a date scrubber strip on the left edge. * 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. * Photos flow as a flat grid per folder, with a folder divider header
* A narrow scrubber column shows date labels at day boundaries. * between each folder. No day-header rows break up the grid within a folder.
* Clicking a date label on the scrubber jumps to that day in the grid.
* *
* Layout: * Layout:
* [24dp scrubber] [3dp gap] [photo grid fills remaining width] * [28dp scrubber] [2dp gap] [photo grid fills remaining width]
*/ */
@Composable @Composable
fun DensePhotoGrid( fun DensePhotoGrid(
photos: List<PhonePhoto>, folderSections: List<com.bolenpad.photophetch.ui.FolderSection>,
selectedPhotos: Set<PhonePhoto>, selectedPhotos: Set<PhonePhoto>,
device: DeviceInfo?, device: DeviceInfo?,
adbBin: String, adbBin: String,
columnCount: Int, columnCount: Int,
onTogglePhoto: (PhonePhoto) -> Unit, onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (photos.isEmpty()) { if (folderSections.isEmpty() || folderSections.all { it.allPhotos.isEmpty() }) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No photos", color = MaterialTheme.colorScheme.onSurfaceVariant) Text("No photos", color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
@@ -67,53 +68,56 @@ fun DensePhotoGrid(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Chunk into rows // Build flat item list: folder header + photo rows per section
val rows: List<List<PhonePhoto>> = photos.chunked(columnCount) data class GridItem(
val type: GridItemType,
val folderSection: com.bolenpad.photophetch.ui.FolderSection? = null,
val rowPhotos: List<PhonePhoto>? = null,
)
enum class GridItemType { FOLDER_HEADER, PHOTO_ROW }
// For each row index, compute the date of its first photo val items: List<GridItem> = buildList {
val rowDates: List<LocalDate> = rows.map { folderSections.forEach { section ->
it.first().dateTaken.atZone(zoneId).toLocalDate() if (section.allPhotos.isEmpty()) return@forEach
} add(GridItem(GridItemType.FOLDER_HEADER, folderSection = section))
section.allPhotos
// Day boundary rows: the first row index where a new date appears .sortedByDescending { it.dateTaken }
// Maps LocalDate → row index .chunked(columnCount)
val dayBoundaries: Map<LocalDate, Int> = buildMap { .forEach { row -> add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row)) }
var lastDate: LocalDate? = null
rowDates.forEachIndexed { idx, date ->
if (date != lastDate) {
put(date, idx)
lastDate = date
}
} }
} }
// Ordered list of (date, rowIndex) sorted newest first // Build date scrubber data from photo rows only
val scrubberDates: List<Pair<LocalDate, Int>> = dayBoundaries.entries val photoRowIndices: List<Pair<LocalDate, Int>> = buildList {
.sortedByDescending { it.key } var lastDate: LocalDate? = null
.map { it.key to it.value } 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 monthFmt = remember { DateTimeFormatter.ofPattern("MMM") }
val dayFmt = remember { DateTimeFormatter.ofPattern("d") } val dayFmt = remember { DateTimeFormatter.ofPattern("d") }
val totalItems = items.size
Row(modifier = modifier.fillMaxSize()) { Row(modifier = modifier.fillMaxSize()) {
// ── Date scrubber strip ─────────────────────────────────────────── // ── 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( Box(
modifier = Modifier modifier = Modifier
.width(28.dp) .width(28.dp)
.fillMaxHeight() .fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) { ) {
// Render scrubber labels using scroll-proportional positioning if (totalItems > 0) {
val totalRows = rows.size
if (totalRows > 0) {
ScrubberOverlay( ScrubberOverlay(
scrubberDates = scrubberDates, scrubberDates = photoRowIndices,
totalRows = totalRows, totalRows = totalItems,
onJump = { rowIdx -> scope.launch { listState.animateScrollToItem(rowIdx) } }, onJump = { rowIdx -> scope.launch { listState.animateScrollToItem(rowIdx) } },
monthFmt = monthFmt, monthFmt = monthFmt,
dayFmt = dayFmt, dayFmt = dayFmt,
@@ -123,34 +127,57 @@ fun DensePhotoGrid(
Spacer(Modifier.width(2.dp)) Spacer(Modifier.width(2.dp))
// ── Photo grid ──────────────────────────────────────────────────── // ── Photo grid with folder headers ────────────────────────────────
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = 32.dp), contentPadding = PaddingValues(bottom = 32.dp),
) { ) {
items( items(
count = rows.size, count = items.size,
key = { idx -> "dense-row-${rows[idx].first().devicePath}" }, key = { idx ->
) { rowIdx -> val item = items[idx]
val rowPhotos = rows[rowIdx] when (item.type) {
Row( GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}"
modifier = Modifier.fillMaxWidth(), GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}"
horizontalArrangement = Arrangement.spacedBy(2.dp), }
) { },
rowPhotos.forEach { photo -> ) { idx ->
DenseThumb( val item = items[idx]
photo = photo, when (item.type) {
isSelected = photo in selectedPhotos, GridItemType.FOLDER_HEADER -> {
device = device, val section = item.folderSection!!
adbBin = adbBin, val folderSelected = section.allPhotos.count { it in selectedPhotos }
onToggle = { onTogglePhoto(photo) }, FolderDividerHeader(
modifier = Modifier.weight(1f), 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))
} }
} }
} }