Fix DensePhotoGrid: date label above each photo that starts a new day

Each photo that is the first of its day in a folder gets a 16dp
'Jul 28' label above its thumbnail. Other photos in the same row get
an invisible 16dp spacer so all thumbnails stay vertically aligned.
Label is per-column, not full-width.
This commit is contained in:
Kyle Bolen
2026-07-28 06:37:21 +00:00
parent fa1a42000e
commit a06c77f153
2 changed files with 44 additions and 110 deletions

View File

View File

@@ -14,7 +14,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap 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
@@ -33,9 +32,10 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
/** /**
* Dense grid: folder header + flat photo rows per folder. * Dense grid: folder headers + flat photo grid per folder.
* Day boundaries shown as a date label overlaid on the top-left corner * Each photo that is the first of its day gets a small date label
* of the first photo in each new day — no separate row, zero wasted space. * ("Jul 28") above its thumbnail only — within its column.
* Other photos in the same row get an invisible spacer so thumbnails stay aligned.
*/ */
@Composable @Composable
fun DensePhotoGrid( fun DensePhotoGrid(
@@ -60,12 +60,11 @@ fun DensePhotoGrid(
val zoneId = ZoneId.systemDefault() val zoneId = ZoneId.systemDefault()
val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") } val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") }
// Build items: FOLDER_HEADER | PHOTO_ROW // Each photo carries a date label if it is the first of its day within its folder
// 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 LabeledPhoto(val photo: PhonePhoto, val dateLabel: String?)
data class PhotoRow(val photos: List<LabeledPhoto>) data class PhotoRow(val photos: List<LabeledPhoto>)
data class Item( data class Item(
val type: GridItemType, val isFolderHeader: Boolean,
val folderSection: FolderSection? = null, val folderSection: FolderSection? = null,
val photoRow: PhotoRow? = null, val photoRow: PhotoRow? = null,
) )
@@ -73,12 +72,11 @@ fun DensePhotoGrid(
val items: List<Item> = buildList { val items: List<Item> = buildList {
folderSections.forEach { section -> folderSections.forEach { section ->
if (section.allPhotos.isEmpty()) return@forEach if (section.allPhotos.isEmpty()) return@forEach
add(Item(GridItemType.FOLDER_HEADER, folderSection = section)) add(Item(isFolderHeader = true, folderSection = section))
val sorted = section.allPhotos.sortedByDescending { it.dateTaken } val sorted = section.allPhotos.sortedByDescending { it.dateTaken }
var lastDate: LocalDate? = null var lastDate: LocalDate? = null
// Tag each photo with a date label if it's the first of its day
val labeled: List<LabeledPhoto> = sorted.map { photo -> val labeled: List<LabeledPhoto> = sorted.map { photo ->
val date = photo.dateTaken.atZone(zoneId).toLocalDate() val date = photo.dateTaken.atZone(zoneId).toLocalDate()
val label = if (date != lastDate) { lastDate = date; dayFmt.format(date) } else null val label = if (date != lastDate) { lastDate = date; dayFmt.format(date) } else null
@@ -86,7 +84,7 @@ fun DensePhotoGrid(
} }
labeled.chunked(columnCount).forEach { rowPhotos -> labeled.chunked(columnCount).forEach { rowPhotos ->
add(Item(GridItemType.PHOTO_ROW, photoRow = PhotoRow(rowPhotos))) add(Item(isFolderHeader = false, photoRow = PhotoRow(rowPhotos)))
} }
} }
} }
@@ -100,51 +98,45 @@ fun DensePhotoGrid(
count = items.size, count = items.size,
key = { idx -> key = { idx ->
val item = items[idx] val item = items[idx]
when (item.type) { if (item.isFolderHeader) "hdr-${item.folderSection!!.folder.name}"
GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}" else "row-${item.photoRow!!.photos.first().photo.devicePath}"
else -> "row-${item.photoRow!!.photos.first().photo.devicePath}"
}
}, },
) { idx -> ) { idx ->
val item = items[idx] val item = items[idx]
when (item.type) { if (item.isFolderHeader) {
GridItemType.FOLDER_HEADER -> { val section = item.folderSection!!
val section = item.folderSection!! val folderSelected = section.allPhotos.count { it in selectedPhotos }
val folderSelected = section.allPhotos.count { it in selectedPhotos } FolderDividerHeader(
FolderDividerHeader( name = section.folder.name,
name = section.folder.name, total = section.allPhotos.size,
total = section.allPhotos.size, selected = folderSelected,
selected = folderSelected, allSelected = folderSelected == section.allPhotos.size,
allSelected = folderSelected == section.allPhotos.size, onSelectAll = { onSelectAllFolder(section.allPhotos) },
onSelectAll = { onSelectAllFolder(section.allPhotos) }, onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
onDeselectAll = { onDeselectAllFolder(section.allPhotos) }, )
) } else {
} val row = item.photoRow!!
else -> { Row(
val row = item.photoRow!! modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
// Check if any photo in this row has a date label — if so, add top padding horizontalArrangement = Arrangement.spacedBy(2.dp),
// to the row so label text has room above the thumbnail ) {
val hasLabel = row.photos.any { it.dateLabel != null } row.photos.forEach { labeled ->
Row( DenseThumb(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp) photo = labeled.photo,
.padding(top = if (hasLabel) 0.dp else 0.dp), isSelected = labeled.photo in selectedPhotos,
horizontalArrangement = Arrangement.spacedBy(2.dp), device = device,
) { adbBin = adbBin,
row.photos.forEach { labeled -> onToggle = { onTogglePhoto(labeled.photo) },
DenseThumb( dateLabel = labeled.dateLabel,
photo = labeled.photo, modifier = Modifier.weight(1f),
isSelected = labeled.photo in selectedPhotos, )
device = device, }
adbBin = adbBin, repeat(columnCount - row.photos.size) {
onToggle = { onTogglePhoto(labeled.photo) }, // Empty spacer — needs the same height as a thumb cell with label
dateLabel = labeled.dateLabel, Spacer(Modifier.weight(1f).aspectRatio(1f).padding(top = 16.dp))
modifier = Modifier.weight(1f),
)
}
repeat(columnCount - row.photos.size) { Spacer(Modifier.weight(1f)) }
} }
Spacer(Modifier.height(2.dp))
} }
Spacer(Modifier.height(2.dp))
} }
} }
} }
@@ -179,14 +171,14 @@ private fun DenseThumb(
} }
Column(modifier = modifier) { Column(modifier = modifier) {
// Date label above this specific column — 16dp tall, always present to keep rows aligned // Date label or invisible spacer — keeps all thumbnails in the row aligned
if (dateLabel != null) { if (dateLabel != null) {
Text( Text(
dateLabel, dateLabel,
fontSize = 10.sp, fontSize = 10.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp, start = 1.dp).height(14.dp), modifier = Modifier.height(16.dp).padding(start = 1.dp),
) )
} else { } else {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -221,62 +213,4 @@ private fun DenseThumb(
} }
} }
} }
}
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)
.clickable(onClick = onToggle)
.then(if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) else Modifier),
) {
// Thumbnail or placeholder
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))
}
}
// Video badge
if (photo.mediaType == MediaType.VIDEO) {
Badge(
containerColor = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp),
) { Text("", style = MaterialTheme.typography.labelSmall) }
}
// Selection checkmark
if (isSelected) {
Icon(Icons.Default.CheckCircle, contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp))
}
}
} }