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),
)
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),
)
}

View File

@@ -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<PhonePhoto>,
folderSections: List<com.bolenpad.photophetch.ui.FolderSection>,
selectedPhotos: Set<PhonePhoto>,
device: DeviceInfo?,
adbBin: String,
columnCount: Int,
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> 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<List<PhonePhoto>> = 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<PhonePhoto>? = null,
)
enum class GridItemType { FOLDER_HEADER, PHOTO_ROW }
// For each row index, compute the date of its first photo
val rowDates: List<LocalDate> = rows.map {
it.first().dateTaken.atZone(zoneId).toLocalDate()
val items: List<GridItem> = 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)) }
}
}
// Day boundary rows: the first row index where a new date appears
// Maps LocalDate → row index
val dayBoundaries: Map<LocalDate, Int> = buildMap {
// Build date scrubber data from photo rows only
val photoRowIndices: List<Pair<LocalDate, Int>> = buildList {
var lastDate: LocalDate? = null
rowDates.forEachIndexed { idx, date ->
items.forEachIndexed { idx, item ->
if (item.type == GridItemType.PHOTO_ROW) {
val date = item.rowPhotos!!.first().dateTaken.atZone(zoneId).toLocalDate()
if (date != lastDate) {
put(date, idx)
add(date to idx)
lastDate = date
}
}
}
// Ordered list of (date, rowIndex) sorted newest first
val scrubberDates: List<Pair<LocalDate, Int>> = dayBoundaries.entries
.sortedByDescending { it.key }
.map { it.key to it.value }
}.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,17 +127,38 @@ 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]
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) },
)
}
GridItemType.PHOTO_ROW -> {
val rowPhotos = item.rowPhotos!!
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
@@ -154,6 +179,8 @@ fun DensePhotoGrid(
}
}
}
}
}
}
/**