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 -> { is PhotoListState.Loaded -> {
// Build ordered list of (folder, photos-by-day) for the unified scroll // Build ordered list of (folder, photos-by-day) for the unified scroll
val folderSections = buildFolderSections(pl) val folderSections = buildFolderSections(pl)
val listState = rememberLazyListState() val groupedListState = rememberLazyListState()
val denseListState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Map folder name → first item index in the lazy list val listState = if (viewMode == ViewMode.GROUPED) groupedListState else denseListState
val folderScrollIndex = remember(folderSections, columnCount) {
// Grouped mode: folder scroll index (accounts for day headers)
val groupedScrollIndex = remember(folderSections, columnCount) {
var idx = 0 var idx = 0
folderSections.associate { (folder, byDay) -> folderSections.associate { (folder, byDay) ->
val startIdx = idx val startIdx = idx
idx += 1 // folder header idx += 1 // folder header
byDay.forEach { (_, dayPhotos) -> byDay.forEach { (_, dayPhotos) ->
if (dayPhotos.size >= 3) idx += 1 // day header only for dense days if (dayPhotos.size >= 3) idx += 1
idx += ((dayPhotos.size + columnCount - 1) / columnCount) // photo rows idx += ((dayPhotos.size + columnCount - 1) / columnCount)
} }
folder.name to startIdx 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 // Scroll spy: which folder is currently at the top of the visible area
val activeFolder by remember { val activeFolder by remember {
derivedStateOf { derivedStateOf {
@@ -109,7 +135,7 @@ fun BrowseScreen(state: AppState) {
} }
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// Left: folder jump list // Left: folder jump list — jumps in whichever list is active
FolderJumpList( FolderJumpList(
folders = pl.folders, folders = pl.folders,
activeFolder = activeFolder, activeFolder = activeFolder,
@@ -130,7 +156,7 @@ fun BrowseScreen(state: AppState) {
selectedPhotos = selectedPhotos, selectedPhotos = selectedPhotos,
device = device, device = device,
adbBin = config.adbPath ?: "adb", adbBin = config.adbPath ?: "adb",
listState = listState, listState = groupedListState,
columnCount = columnCount, columnCount = columnCount,
onTogglePhoto = { state.togglePhotoSelection(it) }, onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder -> onSelectAllFolder = { folder ->
@@ -159,6 +185,7 @@ fun BrowseScreen(state: AppState) {
device = device, device = device,
adbBin = config.adbPath ?: "adb", adbBin = config.adbPath ?: "adb",
columnCount = columnCount, columnCount = columnCount,
listState = denseListState,
onTogglePhoto = { state.togglePhotoSelection(it) }, onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder -> onSelectAllFolder = { folder ->
folder.forEach { p -> 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.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.bolenpad.photophetch.model.DeviceInfo 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.model.PhonePhoto
import com.bolenpad.photophetch.util.ThumbnailCache import com.bolenpad.photophetch.util.ThumbnailCache
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage import org.jetbrains.skia.Image as SkiaImage
import java.time.LocalDate import java.time.LocalDate
@@ -37,13 +34,12 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter 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 * Exposes [listState] so the parent can wire up the folder jump list.
* between each folder. No day-header rows break up the grid within a folder.
*
* Layout:
* [28dp scrubber] [2dp gap] [photo grid fills remaining width]
*/ */
@Composable @Composable
fun DensePhotoGrid( fun DensePhotoGrid(
@@ -52,6 +48,7 @@ fun DensePhotoGrid(
device: DeviceInfo?, device: DeviceInfo?,
adbBin: String, adbBin: String,
columnCount: Int, columnCount: Int,
listState: LazyListState,
onTogglePhoto: (PhonePhoto) -> Unit, onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit, onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit, onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
@@ -65,167 +62,101 @@ fun DensePhotoGrid(
} }
val zoneId = ZoneId.systemDefault() val zoneId = ZoneId.systemDefault()
val listState = rememberLazyListState() val dayFmt = remember { DateTimeFormatter.ofPattern("EEE, MMM d") }
val scope = rememberCoroutineScope()
// Build flat item list: folder header + photo rows per section // Build flat item list per folder section
val items: List<GridItem> = buildList { val items: List<GridItem> = buildList {
folderSections.forEach { section -> folderSections.forEach { section ->
if (section.allPhotos.isEmpty()) return@forEach if (section.allPhotos.isEmpty()) return@forEach
add(GridItem(GridItemType.FOLDER_HEADER, folderSection = section)) add(GridItem(GridItemType.FOLDER_HEADER, folderSection = section))
section.allPhotos
.sortedByDescending { it.dateTaken } val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
.chunked(columnCount) var lastDate: LocalDate? = null
.forEach { row -> add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row)) }
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))
}
} }
} }
// Build date scrubber data from photo rows only LazyColumn(
val photoRowIndices: List<Pair<LocalDate, Int>> = buildList { state = listState,
var lastDate: LocalDate? = null modifier = modifier.fillMaxSize(),
items.forEachIndexed { idx, item -> contentPadding = PaddingValues(bottom = 32.dp),
if (item.type == GridItemType.PHOTO_ROW) { ) {
val date = item.rowPhotos!!.first().dateTaken.atZone(zoneId).toLocalDate() items(
if (date != lastDate) { count = items.size,
add(date to idx) key = { idx ->
lastDate = date
}
}
}
}.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),
contentPadding = PaddingValues(bottom = 32.dp),
) {
items(
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] val item = items[idx]
when (item.type) { when (item.type) {
GridItemType.FOLDER_HEADER -> { GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}"
val section = item.folderSection!! GridItemType.DAY_BOUNDARY -> "day-${item.folderSection?.folder?.name}-${item.dayDate}"
val folderSelected = section.allPhotos.count { it in selectedPhotos } GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}"
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),
) {
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))
}
} }
} },
} ) { idx ->
} val item = items[idx]
} when (item.type) {
GridItemType.FOLDER_HEADER -> {
/** val section = item.folderSection!!
* Scrubber overlay: proportionally-positioned date labels. val folderSelected = section.allPhotos.count { it in selectedPhotos }
* Uses a custom layout approach with fillMaxHeight + proportional offset. FolderDividerHeader(
*/ name = section.folder.name,
@Composable total = section.allPhotos.size,
private fun ScrubberOverlay( selected = folderSelected,
scrubberDates: List<Pair<LocalDate, Int>>, allSelected = folderSelected == section.allPhotos.size,
totalRows: Int, onSelectAll = { onSelectAllFolder(section.allPhotos) },
onJump: (Int) -> Unit, onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
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( GridItemType.DAY_BOUNDARY -> {
dayFmt.format(date), // Thin rule with a small date label — scrolls with the grid
fontSize = 9.sp, Row(
fontWeight = if (showMonth) FontWeight.Bold else FontWeight.Normal, modifier = Modifier
color = MaterialTheme.colorScheme.onSurfaceVariant, .fillMaxWidth()
textAlign = TextAlign.Center, .padding(horizontal = 8.dp, vertical = 4.dp),
lineHeight = 10.sp, 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),
) {
item.rowPhotos!!.forEach { photo ->
DenseThumb(
photo = photo,
isSelected = photo in selectedPhotos,
device = device,
adbBin = adbBin,
onToggle = { onTogglePhoto(photo) },
modifier = Modifier.weight(1f),
)
}
repeat(columnCount - item.rowPhotos.size) { Spacer(Modifier.weight(1f)) }
}
Spacer(Modifier.height(2.dp))
}
} }
} }
} }
@@ -285,7 +216,6 @@ private fun DenseThumb(
} }
} }
// Video badge
if (photo.mediaType == MediaType.VIDEO) { if (photo.mediaType == MediaType.VIDEO) {
Badge( Badge(
containerColor = MaterialTheme.colorScheme.tertiary, containerColor = MaterialTheme.colorScheme.tertiary,
@@ -293,7 +223,6 @@ private fun DenseThumb(
) { Text("", style = MaterialTheme.typography.labelSmall) } ) { Text("", style = MaterialTheme.typography.labelSmall) }
} }
// Selection indicator
if (isSelected) { if (isSelected) {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.CheckCircle,

View File

@@ -11,11 +11,12 @@ data class FolderSection(
) )
/** Item types used in the DensePhotoGrid flat list */ /** 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 */ /** A single item in the DensePhotoGrid flat list */
data class GridItem( data class GridItem(
val type: GridItemType, val type: GridItemType,
val folderSection: FolderSection? = null, val folderSection: FolderSection? = null,
val rowPhotos: List<PhonePhoto>? = null, val rowPhotos: List<PhonePhoto>? = null,
val dayDate: LocalDate? = null,
) )