From fa1a42000e8ee853a0e9ee6c8ec83a63aae9d623 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 06:36:33 +0000 Subject: [PATCH] Dense view: date label above each specific photo that starts a new day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .attach_pid1471 | 0 .../bolenpad/photophetch/ui/BrowseScreen.kt | 14 +- .../bolenpad/photophetch/ui/DensePhotoGrid.kt | 126 ++++++++++++------ 3 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 .attach_pid1471 diff --git a/.attach_pid1471 b/.attach_pid1471 new file mode 100644 index 0000000..e69de29 diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index de9c589..cc29fd0 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -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 } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt index 60393f2..abe0216 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -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) + // 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) data class Item( val type: GridItemType, val folderSection: FolderSection? = null, val photoRow: PhotoRow? = null, - val dateLabel: String? = null, ) val items: List = 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 = 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(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(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)