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