Fix scroll spy + date overlay on first photo of each day
Scroll spy: use key(viewMode) around derivedStateOf so activeFolder
recomputes against the correct listState when switching modes.
Dense date markers: remove separate DAY_BOUNDARY row item entirely.
Date label ('Jul 28') now overlaid on the top-left corner of the first
photo in each new day — zero wasted vertical space.
This commit is contained in:
@@ -100,31 +100,22 @@ fun BrowseScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dense mode: folder scroll index (accounts for day boundary markers)
|
// Dense mode: folder scroll index (photo rows only, no day boundary items)
|
||||||
val denseScrollIndex = remember(folderSections, columnCount) {
|
val denseScrollIndex = remember(folderSections, columnCount) {
|
||||||
var idx = 0
|
var idx = 0
|
||||||
folderSections.associate { folderSection ->
|
folderSections.associate { folderSection ->
|
||||||
val startIdx = idx
|
val startIdx = idx
|
||||||
idx += 1 // folder header
|
idx += 1 // folder header
|
||||||
val sorted = folderSection.allPhotos.sortedByDescending { it.dateTaken }
|
idx += ((folderSection.allPhotos.size + columnCount - 1) / columnCount) // photo rows
|
||||||
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
|
folderSection.folder.name to startIdx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex
|
val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex
|
||||||
|
|
||||||
// Scroll spy: which folder is currently at the top of the visible area
|
// Scroll spy — keyed on viewMode so it recomposes when switching
|
||||||
val activeFolder by remember {
|
val activeFolder by key(viewMode) {
|
||||||
|
remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val firstVisible = listState.firstVisibleItemIndex
|
val firstVisible = listState.firstVisibleItemIndex
|
||||||
folderScrollIndex.entries
|
folderScrollIndex.entries
|
||||||
@@ -133,6 +124,7 @@ fun BrowseScreen(state: AppState) {
|
|||||||
?: folderSections.firstOrNull()?.folder?.name
|
?: folderSections.firstOrNull()?.folder?.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Left: folder jump list — jumps in whichever list is active
|
// Left: folder jump list — jumps in whichever list is active
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
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
|
||||||
@@ -34,12 +33,9 @@ import java.time.ZoneId
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dense grid view: photos flow as a tight grid per folder.
|
* Dense grid: folder header + flat photo rows per folder.
|
||||||
* Folder headers separate sections. Day boundaries are marked with a
|
* Day boundaries shown as a date label overlaid on the top-left corner
|
||||||
* thin rule + date label that scrolls inline with the photos — no
|
* of the first photo in each new day — no separate row, zero wasted space.
|
||||||
* separate scrubber strip.
|
|
||||||
*
|
|
||||||
* Exposes [listState] so the parent can wire up the folder jump list.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DensePhotoGrid(
|
fun DensePhotoGrid(
|
||||||
@@ -62,27 +58,35 @@ fun DensePhotoGrid(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val zoneId = ZoneId.systemDefault()
|
val zoneId = ZoneId.systemDefault()
|
||||||
val dayFmt = remember { DateTimeFormatter.ofPattern("EEE, MMM d") }
|
val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") }
|
||||||
|
|
||||||
// Build flat item list per folder section
|
// Build items: FOLDER_HEADER | PHOTO_ROW (with optional dateLabel on first photo)
|
||||||
val items: List<GridItem> = buildList {
|
// dateLabel is set on the first photo of each new day within a folder
|
||||||
|
data class PhotoRow(
|
||||||
|
val photos: List<PhonePhoto>,
|
||||||
|
val dateLabel: String?, // non-null on first row of a new day
|
||||||
|
)
|
||||||
|
data class Item(
|
||||||
|
val type: GridItemType,
|
||||||
|
val folderSection: FolderSection? = null,
|
||||||
|
val photoRow: PhotoRow? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val items: List<Item> = 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(Item(GridItemType.FOLDER_HEADER, folderSection = section))
|
||||||
|
|
||||||
val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
|
val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
|
||||||
var lastDate: LocalDate? = null
|
var lastDate: LocalDate? = null
|
||||||
|
|
||||||
sorted.chunked(columnCount).forEach { row ->
|
sorted.chunked(columnCount).forEach { rowPhotos ->
|
||||||
val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate()
|
val rowDate = rowPhotos.first().dateTaken.atZone(zoneId).toLocalDate()
|
||||||
// Emit a day boundary marker when the date changes
|
val label = if (rowDate != lastDate) {
|
||||||
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
|
lastDate = rowDate
|
||||||
}
|
dayFmt.format(rowDate)
|
||||||
add(GridItem(GridItemType.PHOTO_ROW, rowPhotos = row))
|
} else null
|
||||||
|
add(Item(GridItemType.PHOTO_ROW, photoRow = PhotoRow(rowPhotos, label)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +102,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}"
|
else -> "row-${item.photoRow!!.photos.first().devicePath}"
|
||||||
GridItemType.PHOTO_ROW -> "row-${item.rowPhotos!!.first().devicePath}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { idx ->
|
) { idx ->
|
||||||
@@ -117,43 +120,25 @@ fun DensePhotoGrid(
|
|||||||
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GridItemType.DAY_BOUNDARY -> {
|
else -> {
|
||||||
// Thin rule with a small date label — scrolls with the grid
|
val row = item.photoRow!!
|
||||||
Row(
|
|
||||||
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
item.rowPhotos!!.forEach { photo ->
|
row.photos.forEachIndexed { colIdx, photo ->
|
||||||
DenseThumb(
|
DenseThumb(
|
||||||
photo = photo,
|
photo = photo,
|
||||||
isSelected = photo in selectedPhotos,
|
isSelected = photo in selectedPhotos,
|
||||||
device = device,
|
device = device,
|
||||||
adbBin = adbBin,
|
adbBin = adbBin,
|
||||||
onToggle = { onTogglePhoto(photo) },
|
onToggle = { onTogglePhoto(photo) },
|
||||||
|
// Show date label only on first photo of the row that starts a new day
|
||||||
|
dateLabel = if (colIdx == 0) row.dateLabel else null,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repeat(columnCount - item.rowPhotos.size) { Spacer(Modifier.weight(1f)) }
|
repeat(columnCount - row.photos.size) { Spacer(Modifier.weight(1f)) }
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
}
|
}
|
||||||
@@ -169,6 +154,7 @@ private fun DenseThumb(
|
|||||||
device: DeviceInfo?,
|
device: DeviceInfo?,
|
||||||
adbBin: String,
|
adbBin: String,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
|
dateLabel: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
|
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
@@ -195,6 +181,7 @@ private fun DenseThumb(
|
|||||||
.clickable(onClick = onToggle)
|
.clickable(onClick = onToggle)
|
||||||
.then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier),
|
.then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier),
|
||||||
) {
|
) {
|
||||||
|
// Thumbnail or placeholder
|
||||||
if (thumbnail != null) {
|
if (thumbnail != null) {
|
||||||
Image(
|
Image(
|
||||||
bitmap = thumbnail!!,
|
bitmap = thumbnail!!,
|
||||||
@@ -207,29 +194,38 @@ private fun DenseThumb(
|
|||||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
|
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(Icons.Default.Image, contentDescription = null,
|
||||||
Icons.Default.Image,
|
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.outline,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Date label overlaid on top-left — only on first photo of a new day
|
||||||
|
if (dateLabel != null) {
|
||||||
|
Text(
|
||||||
|
dateLabel,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.background(Color.Black.copy(alpha = 0.55f))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video badge
|
||||||
if (photo.mediaType == MediaType.VIDEO) {
|
if (photo.mediaType == MediaType.VIDEO) {
|
||||||
Badge(
|
Badge(
|
||||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(2.dp),
|
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp),
|
||||||
) { Text("▶", style = MaterialTheme.typography.labelSmall) }
|
) { Text("▶", style = MaterialTheme.typography.labelSmall) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selection checkmark
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Icon(
|
Icon(Icons.Default.CheckCircle, contentDescription = null,
|
||||||
Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp),
|
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user