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:
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user