From a06c77f15354ac762f2a098086dd969b5cc914a0 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 06:37:21 +0000 Subject: [PATCH] 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. --- .attach_pid1471 | 0 .../bolenpad/photophetch/ui/DensePhotoGrid.kt | 154 +++++------------- 2 files changed, 44 insertions(+), 110 deletions(-) delete mode 100644 .attach_pid1471 diff --git a/.attach_pid1471 b/.attach_pid1471 deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index abe0216..c6b07b9 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.layout.ContentScale @@ -33,9 +32,10 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter /** - * Dense grid: folder header + flat photo rows per folder. - * Day boundaries shown as a date label overlaid on the top-left corner - * of the first photo in each new day — no separate row, zero wasted space. + * Dense grid: folder headers + flat photo grid per folder. + * Each photo that is the first of its day gets a small date label + * ("Jul 28") above its thumbnail only — within its column. + * Other photos in the same row get an invisible spacer so thumbnails stay aligned. */ @Composable fun DensePhotoGrid( @@ -60,12 +60,11 @@ fun DensePhotoGrid( val zoneId = ZoneId.systemDefault() val dayFmt = remember { DateTimeFormatter.ofPattern("MMM d") } - // Build items: FOLDER_HEADER | PHOTO_ROW - // Each photo carries its own dateLabel if it's the first of a new day + // Each photo carries a date label if it is the first of its day within its folder data class LabeledPhoto(val photo: PhonePhoto, val dateLabel: String?) data class PhotoRow(val photos: List) data class Item( - val type: GridItemType, + val isFolderHeader: Boolean, val folderSection: FolderSection? = null, val photoRow: PhotoRow? = null, ) @@ -73,12 +72,11 @@ fun DensePhotoGrid( val items: List = buildList { folderSections.forEach { section -> 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 } var lastDate: LocalDate? = null - // Tag each photo with a date label if it's the first of its day val labeled: List = sorted.map { photo -> val date = photo.dateTaken.atZone(zoneId).toLocalDate() val label = if (date != lastDate) { lastDate = date; dayFmt.format(date) } else null @@ -86,7 +84,7 @@ fun DensePhotoGrid( } 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, key = { idx -> val item = items[idx] - when (item.type) { - GridItemType.FOLDER_HEADER -> "hdr-${item.folderSection!!.folder.name}" - else -> "row-${item.photoRow!!.photos.first().photo.devicePath}" - } + if (item.isFolderHeader) "hdr-${item.folderSection!!.folder.name}" + else "row-${item.photoRow!!.photos.first().photo.devicePath}" }, ) { idx -> val item = items[idx] - when (item.type) { - GridItemType.FOLDER_HEADER -> { - val section = item.folderSection!! - val folderSelected = section.allPhotos.count { it in selectedPhotos } - FolderDividerHeader( - name = section.folder.name, - total = section.allPhotos.size, - selected = folderSelected, - allSelected = folderSelected == section.allPhotos.size, - onSelectAll = { onSelectAllFolder(section.allPhotos) }, - onDeselectAll = { onDeselectAllFolder(section.allPhotos) }, - ) - } - 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) - .padding(top = if (hasLabel) 0.dp else 0.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - row.photos.forEach { labeled -> - DenseThumb( - photo = labeled.photo, - isSelected = labeled.photo in selectedPhotos, - device = device, - adbBin = adbBin, - onToggle = { onTogglePhoto(labeled.photo) }, - dateLabel = labeled.dateLabel, - modifier = Modifier.weight(1f), - ) - } - repeat(columnCount - row.photos.size) { Spacer(Modifier.weight(1f)) } + if (item.isFolderHeader) { + val section = item.folderSection!! + val folderSelected = section.allPhotos.count { it in selectedPhotos } + FolderDividerHeader( + name = section.folder.name, + total = section.allPhotos.size, + selected = folderSelected, + allSelected = folderSelected == section.allPhotos.size, + onSelectAll = { onSelectAllFolder(section.allPhotos) }, + onDeselectAll = { onDeselectAllFolder(section.allPhotos) }, + ) + } else { + val row = item.photoRow!! + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + row.photos.forEach { labeled -> + DenseThumb( + photo = labeled.photo, + isSelected = labeled.photo in selectedPhotos, + device = device, + adbBin = adbBin, + onToggle = { onTogglePhoto(labeled.photo) }, + dateLabel = labeled.dateLabel, + modifier = Modifier.weight(1f), + ) + } + repeat(columnCount - row.photos.size) { + // Empty spacer — needs the same height as a thumb cell with label + Spacer(Modifier.weight(1f).aspectRatio(1f).padding(top = 16.dp)) } - Spacer(Modifier.height(2.dp)) } + Spacer(Modifier.height(2.dp)) } } } @@ -179,14 +171,14 @@ private fun DenseThumb( } 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) { Text( dateLabel, fontSize = 10.sp, fontWeight = FontWeight.Medium, 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 { Spacer(Modifier.height(16.dp)) @@ -221,62 +213,4 @@ private fun DenseThumb( } } } -} - var thumbnail by remember(photo.devicePath) { mutableStateOf(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)) - } - } }