Dense view: inline day markers + fix folder jump list

Replace date scrubber strip with inline day boundary markers:
a thin rule + 'Mon, Jul 28' label that scrolls with the photos.
No separate panel — markers live in the grid flow.

Fix folder jump list in Dense mode: maintain separate denseListState
and denseScrollIndex (accounting for day boundary items). activeFolder
scroll spy now reads from whichever listState is active.
This commit is contained in:
Kyle Bolen
2026-07-28 06:27:41 +00:00
parent 6b624651fd
commit 60d72ce0bd
3 changed files with 124 additions and 167 deletions

View File

@@ -80,23 +80,49 @@ fun BrowseScreen(state: AppState) {
is PhotoListState.Loaded -> {
// Build ordered list of (folder, photos-by-day) for the unified scroll
val folderSections = buildFolderSections(pl)
val listState = rememberLazyListState()
val groupedListState = rememberLazyListState()
val denseListState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Map folder name → first item index in the lazy list
val folderScrollIndex = remember(folderSections, columnCount) {
val listState = if (viewMode == ViewMode.GROUPED) groupedListState else denseListState
// Grouped mode: folder scroll index (accounts for day headers)
val groupedScrollIndex = remember(folderSections, columnCount) {
var idx = 0
folderSections.associate { (folder, byDay) ->
val startIdx = idx
idx += 1 // folder header
byDay.forEach { (_, dayPhotos) ->
if (dayPhotos.size >= 3) idx += 1 // day header only for dense days
idx += ((dayPhotos.size + columnCount - 1) / columnCount) // photo rows
if (dayPhotos.size >= 3) idx += 1
idx += ((dayPhotos.size + columnCount - 1) / columnCount)
}
folder.name to startIdx
}
}
// Dense mode: folder scroll index (accounts for day boundary markers)
val denseScrollIndex = remember(folderSections, columnCount) {
var idx = 0
folderSections.associate { folderSection ->
val startIdx = idx
idx += 1 // folder header
val sorted = folderSection.allPhotos.sortedByDescending { it.dateTaken }
var lastDate: java.time.LocalDate? = null
val zoneId = java.time.ZoneId.systemDefault()
sorted.chunked(columnCount).forEach { row ->
val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate()
if (rowDate != lastDate) {
if (lastDate != null) idx += 1 // day boundary marker
lastDate = rowDate
}
idx += 1 // photo row
}
folderSection.folder.name to startIdx
}
}
val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex
// Scroll spy: which folder is currently at the top of the visible area
val activeFolder by remember {
derivedStateOf {
@@ -109,7 +135,7 @@ fun BrowseScreen(state: AppState) {
}
Row(modifier = Modifier.fillMaxSize()) {
// Left: folder jump list
// Left: folder jump list — jumps in whichever list is active
FolderJumpList(
folders = pl.folders,
activeFolder = activeFolder,
@@ -130,7 +156,7 @@ fun BrowseScreen(state: AppState) {
selectedPhotos = selectedPhotos,
device = device,
adbBin = config.adbPath ?: "adb",
listState = listState,
listState = groupedListState,
columnCount = columnCount,
onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder ->
@@ -159,6 +185,7 @@ fun BrowseScreen(state: AppState) {
device = device,
adbBin = config.adbPath ?: "adb",
columnCount = columnCount,
listState = denseListState,
onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder ->
folder.forEach { p ->

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -21,7 +20,6 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bolenpad.photophetch.model.DeviceInfo
@@ -29,7 +27,6 @@ import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import com.bolenpad.photophetch.util.ThumbnailCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.time.LocalDate
@@ -37,13 +34,12 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Dense grid view with a date scrubber strip on the left edge.
* Dense grid view: photos flow as a tight grid per folder.
* Folder headers separate sections. Day boundaries are marked with a
* thin rule + date label that scrolls inline with the photos — no
* separate scrubber strip.
*
* 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:
* [28dp scrubber] [2dp gap] [photo grid fills remaining width]
* Exposes [listState] so the parent can wire up the folder jump list.
*/
@Composable
fun DensePhotoGrid(
@@ -52,6 +48,7 @@ fun DensePhotoGrid(
device: DeviceInfo?,
adbBin: String,
columnCount: Int,
listState: LazyListState,
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
@@ -65,65 +62,34 @@ fun DensePhotoGrid(
}
val zoneId = ZoneId.systemDefault()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val dayFmt = remember { DateTimeFormatter.ofPattern("EEE, MMM d") }
// Build flat item list: folder header + photo rows per section
// Build flat item list per folder section
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)) }
}
}
// Build date scrubber data from photo rows only
val photoRowIndices: List<Pair<LocalDate, Int>> = buildList {
val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
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
sorted.chunked(columnCount).forEach { row ->
val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate()
// Emit a day boundary marker when the date changes
if (rowDate != lastDate) {
if (lastDate != null) { // don't emit before first row — folder header serves that
add(GridItem(GridItemType.DAY_BOUNDARY, dayDate = rowDate))
}
lastDate = rowDate
}
add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row))
}
}
}
}.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 ───────────────────────────────────────────
Box(
modifier = Modifier
.width(28.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
if (totalItems > 0) {
ScrubberOverlay(
scrubberDates = photoRowIndices,
totalRows = totalItems,
onJump = { rowIdx -> scope.launch { listState.animateScrollToItem(rowIdx) } },
monthFmt = monthFmt,
dayFmt = dayFmt,
)
}
}
Spacer(Modifier.width(2.dp))
// ── Photo grid with folder headers ────────────────────────────────
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 32.dp),
) {
items(
@@ -132,6 +98,7 @@ fun DensePhotoGrid(
val item = items[idx]
when (item.type) {
GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}"
GridItemType.DAY_BOUNDARY -> "day-${item.folderSection?.folder?.name}-${item.dayDate}"
GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}"
}
},
@@ -150,13 +117,33 @@ fun DensePhotoGrid(
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
)
}
GridItemType.PHOTO_ROW -> {
val rowPhotos = item.rowPhotos!!
GridItemType.DAY_BOUNDARY -> {
// Thin rule with a small date label — scrolls with the grid
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
HorizontalDivider(modifier = Modifier.width(16.dp), thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant)
Text(
dayFmt.format(item.dayDate!!),
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
HorizontalDivider(modifier = Modifier.weight(1f), thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant)
}
}
GridItemType.PHOTO_ROW -> {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
rowPhotos.forEach { photo ->
item.rowPhotos!!.forEach { photo ->
DenseThumb(
photo = photo,
isSelected = photo in selectedPhotos,
@@ -166,69 +153,13 @@ fun DensePhotoGrid(
modifier = Modifier.weight(1f),
)
}
repeat(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) }
repeat(columnCount - item.rowPhotos.size) { Spacer(Modifier.weight(1f)) }
}
Spacer(Modifier.height(2.dp))
}
}
}
}
}
}
/**
* Scrubber overlay: proportionally-positioned date labels.
* Uses a custom layout approach with fillMaxHeight + proportional offset.
*/
@Composable
private fun ScrubberOverlay(
scrubberDates: List<Pair<LocalDate, Int>>,
totalRows: Int,
onJump: (Int) -> Unit,
monthFmt: DateTimeFormatter,
dayFmt: DateTimeFormatter,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val totalHeight = maxHeight
// Group by month — only show month label at first day of each month
var lastMonth: Int? = null
scrubberDates.forEach { (date, rowIdx) ->
val fraction = rowIdx.toFloat() / totalRows.toFloat()
val yOffset = totalHeight * fraction
val showMonth = date.monthValue != lastMonth
if (showMonth) lastMonth = date.monthValue
Column(
modifier = Modifier
.offset(y = yOffset)
.fillMaxWidth()
.clickable { onJump(rowIdx) }
.padding(vertical = 1.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (showMonth) {
Text(
monthFmt.format(date),
fontSize = 8.sp,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
lineHeight = 9.sp,
)
}
Text(
dayFmt.format(date),
fontSize = 9.sp,
fontWeight = if (showMonth) FontWeight.Bold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 10.sp,
)
}
}
}
}
@Composable
@@ -285,7 +216,6 @@ private fun DenseThumb(
}
}
// Video badge
if (photo.mediaType == MediaType.VIDEO) {
Badge(
containerColor = MaterialTheme.colorScheme.tertiary,
@@ -293,7 +223,6 @@ private fun DenseThumb(
) { Text("", style = MaterialTheme.typography.labelSmall) }
}
// Selection indicator
if (isSelected) {
Icon(
Icons.Default.CheckCircle,

View File

@@ -11,11 +11,12 @@ data class FolderSection(
)
/** Item types used in the DensePhotoGrid flat list */
enum class GridItemType { FOLDER_HEADER, PHOTO_ROW }
enum class GridItemType { FOLDER_HEADER, DAY_BOUNDARY, PHOTO_ROW }
/** A single item in the DensePhotoGrid flat list */
data class GridItem(
val type: GridItemType,
val folderSection: FolderSection? = null,
val rowPhotos: List<PhonePhoto>? = null,
val dayDate: LocalDate? = null,
)