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:
0
.attach_pid1471
Normal file
0
.attach_pid1471
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user