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:
@@ -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 ->
|
||||||
|
|||||||
@@ -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,65 +62,34 @@ 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 }
|
|
||||||
.chunked(columnCount)
|
|
||||||
.forEach { row -> add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build date scrubber data from photo rows only
|
val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
|
||||||
val photoRowIndices: List<Pair<LocalDate, Int>> = buildList {
|
|
||||||
var lastDate: LocalDate? = null
|
var lastDate: LocalDate? = null
|
||||||
items.forEachIndexed { idx, item ->
|
|
||||||
if (item.type == GridItemType.PHOTO_ROW) {
|
sorted.chunked(columnCount).forEach { row ->
|
||||||
val date = item.rowPhotos!!.first().dateTaken.atZone(zoneId).toLocalDate()
|
val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate()
|
||||||
if (date != lastDate) {
|
// Emit a day boundary marker when the date changes
|
||||||
add(date to idx)
|
if (rowDate != lastDate) {
|
||||||
lastDate = date
|
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(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 32.dp),
|
contentPadding = PaddingValues(bottom = 32.dp),
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
@@ -132,6 +98,7 @@ fun DensePhotoGrid(
|
|||||||
val item = items[idx]
|
val item = items[idx]
|
||||||
when (item.type) {
|
when (item.type) {
|
||||||
GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}"
|
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}"
|
GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -150,13 +117,33 @@ fun DensePhotoGrid(
|
|||||||
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GridItemType.PHOTO_ROW -> {
|
GridItemType.DAY_BOUNDARY -> {
|
||||||
val rowPhotos = item.rowPhotos!!
|
// Thin rule with a small date label — scrolls with the grid
|
||||||
Row(
|
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),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
rowPhotos.forEach { photo ->
|
item.rowPhotos!!.forEach { photo ->
|
||||||
DenseThumb(
|
DenseThumb(
|
||||||
photo = photo,
|
photo = photo,
|
||||||
isSelected = photo in selectedPhotos,
|
isSelected = photo in selectedPhotos,
|
||||||
@@ -166,7 +153,7 @@ fun DensePhotoGrid(
|
|||||||
modifier = Modifier.weight(1f),
|
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))
|
Spacer(Modifier.height(2.dp))
|
||||||
}
|
}
|
||||||
@@ -174,62 +161,6 @@ fun DensePhotoGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
@Composable
|
||||||
private fun DenseThumb(
|
private fun DenseThumb(
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user