Dense view: date label above each specific photo that starts a new day

Each photo that is the first of its day gets a 14dp date label
('Jul 28') above its thumbnail only — within its column, not spanning
the row. Other photos in the same row get an invisible spacer so all
thumbnails stay aligned. Result: date markers appear above individual
photos exactly where the day changes.
This commit is contained in:
Kyle Bolen
2026-07-28 06:36:33 +00:00
parent 361eab792b
commit fa1a42000e
3 changed files with 88 additions and 52 deletions

0
.attach_pid1471 Normal file
View File

View File

@@ -100,23 +100,13 @@ fun BrowseScreen(state: AppState) {
}
}
// Dense mode: folder scroll index (folder header + date labels + photo rows)
// Dense mode: folder scroll index (folder header + photo rows only)
val denseScrollIndex = remember(folderSections, columnCount) {
var idx = 0
val zoneId = java.time.ZoneId.systemDefault()
folderSections.associate { folderSection ->
val startIdx = idx
idx += 1 // folder header
val sorted = folderSection.allPhotos.sortedByDescending { it.dateTaken }
var lastDate: java.time.LocalDate? = null
sorted.chunked(columnCount).forEach { row ->
val rowDate = row.first().dateTaken.atZone(zoneId).toLocalDate()
if (rowDate != lastDate) {
idx += 1 // date label row
lastDate = rowDate
}
idx += 1 // photo row
}
idx += ((folderSection.allPhotos.size + columnCount - 1) / columnCount)
folderSection.folder.name to startIdx
}
}

View File

@@ -60,13 +60,14 @@ fun DensePhotoGrid(
val zoneId = ZoneId.systemDefault()
val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") }
// Build items: FOLDER_HEADER | DATE_LABEL | PHOTO_ROW
data class PhotoRow(val photos: List<PhonePhoto>)
// Build items: FOLDER_HEADER | PHOTO_ROW
// Each photo carries its own dateLabel if it's the first of a new day
data class LabeledPhoto(val photo: PhonePhoto, val dateLabel: String?)
data class PhotoRow(val photos: List<LabeledPhoto>)
data class Item(
val type: GridItemType,
val folderSection: FolderSection? = null,
val photoRow: PhotoRow? = null,
val dateLabel: String? = null,
)
val items: List<Item> = buildList {
@@ -77,13 +78,14 @@ fun DensePhotoGrid(
val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
var lastDate: LocalDate? = null
sorted.chunked(columnCount).forEach { rowPhotos ->
val rowDate = rowPhotos.first().dateTaken.atZone(zoneId).toLocalDate()
if (rowDate != lastDate) {
lastDate = rowDate
// Slim date label above every day's first row (including first row of folder)
add(Item(GridItemType.DAY_BOUNDARY, dateLabel = dayFmt.format(rowDate)))
}
// Tag each photo with a date label if it's the first of its day
val labeled: List<LabeledPhoto> = sorted.map { photo ->
val date = photo.dateTaken.atZone(zoneId).toLocalDate()
val label = if (date != lastDate) { lastDate = date; dayFmt.format(date) } else null
LabeledPhoto(photo, label)
}
labeled.chunked(columnCount).forEach { rowPhotos ->
add(Item(GridItemType.PHOTO_ROW, photoRow = PhotoRow(rowPhotos)))
}
}
@@ -100,8 +102,7 @@ fun DensePhotoGrid(
val item = items[idx]
when (item.type) {
GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}"
GridItemType.DAY_BOUNDARY -> "date-${item.dateLabel}-${idx}"
else -> "row-${item.photoRow!!.photos.first().devicePath}"
else -> "row-${item.photoRow!!.photos.first().photo.devicePath}"
}
},
) { idx ->
@@ -119,42 +120,24 @@ fun DensePhotoGrid(
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
)
}
GridItemType.DAY_BOUNDARY -> {
// Slim full-width date label — 18dp tall, no wasted space
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(top = 4.dp, bottom = 1.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
item.dateLabel!!,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
}
else -> {
val row = item.photoRow!!
// Check if any photo in this row has a date label — if so, add top padding
// to the row so label text has room above the thumbnail
val hasLabel = row.photos.any { it.dateLabel != null }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
.padding(top = if (hasLabel) 0.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
row.photos.forEach { photo ->
row.photos.forEach { labeled ->
DenseThumb(
photo = photo,
isSelected = photo in selectedPhotos,
photo = labeled.photo,
isSelected = labeled.photo in selectedPhotos,
device = device,
adbBin = adbBin,
onToggle = { onTogglePhoto(photo) },
onToggle = { onTogglePhoto(labeled.photo) },
dateLabel = labeled.dateLabel,
modifier = Modifier.weight(1f),
)
}
@@ -174,6 +157,7 @@ private fun DenseThumb(
device: DeviceInfo?,
adbBin: String,
onToggle: () -> Unit,
dateLabel: String?,
modifier: Modifier = Modifier,
) {
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
@@ -194,6 +178,68 @@ private fun DenseThumb(
}
}
Column(modifier = modifier) {
// Date label above this specific column — 16dp tall, always present to keep rows aligned
if (dateLabel != null) {
Text(
dateLabel,
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp, start = 1.dp).height(14.dp),
)
} else {
Spacer(Modifier.height(16.dp))
}
Box(
modifier = Modifier
.aspectRatio(1f)
.clickable(onClick = onToggle)
.then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier),
) {
if (thumbnail != null) {
Image(bitmap = thumbnail!!, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center) {
Icon(Icons.Default.Image, contentDescription = null,
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
}
}
if (photo.mediaType == MediaType.VIDEO) {
Badge(containerColor = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) {
Text("", style = MaterialTheme.typography.labelSmall)
}
}
if (isSelected) {
Icon(Icons.Default.CheckCircle, contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp))
}
}
}
}
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
var thumbFailed by remember(photo.devicePath) { mutableStateOf(false) }
LaunchedEffect(photo.devicePath) {
if (device?.adbSerial != null && !thumbFailed && thumbnail == null) {
val bytes = withContext(Dispatchers.IO) {
ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename)
}
if (bytes != null) {
runCatching {
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
}.onFailure { thumbFailed = true }
} else {
thumbFailed = true
}
}
}
Box(
modifier = modifier
.aspectRatio(1f)