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:
@@ -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,15 +98,12 @@ 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(
|
||||||
@@ -119,15 +114,10 @@ fun DensePhotoGrid(
|
|||||||
onSelectAll = { onSelectAllFolder(section.allPhotos) },
|
onSelectAll = { onSelectAllFolder(section.allPhotos) },
|
||||||
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
else -> {
|
|
||||||
val row = item.photoRow!!
|
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(
|
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),
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
row.photos.forEach { labeled ->
|
row.photos.forEach { labeled ->
|
||||||
@@ -141,13 +131,15 @@ fun DensePhotoGrid(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
repeat(columnCount - row.photos.size) { Spacer(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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user