Add Dense view mode with date scrubber (toggle in top bar)

DensePhotoGrid: flat grid with no day-header rows. A 28dp scrubber
strip on the left shows proportionally-positioned date labels (month
+ day). Clicking a date label jumps to that row via animateScrollToItem.

BrowseTopBar: 'Grouped/Dense' FilterChip toggles between views.
Dense mode passes all photos sorted newest-first; grouped mode keeps
the existing folder+day structure.

Both modes share the same column count and selection state.
This commit is contained in:
Kyle Bolen
2026-07-28 06:20:46 +00:00
parent 2eb19a8a5d
commit 69a5f451d5
2 changed files with 341 additions and 29 deletions

View File

@@ -38,6 +38,8 @@ import org.jetbrains.skia.Image as SkiaImage
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
enum class ViewMode { GROUPED, DENSE }
@Composable @Composable
fun BrowseScreen(state: AppState) { fun BrowseScreen(state: AppState) {
val photoList by state.photoList.collectAsState() val photoList by state.photoList.collectAsState()
@@ -47,15 +49,18 @@ fun BrowseScreen(state: AppState) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
var columnCount by remember { mutableStateOf(4) } var columnCount by remember { mutableStateOf(4) }
var viewMode by remember { mutableStateOf(ViewMode.GROUPED) }
BrowseTopBar( BrowseTopBar(
deviceName = device?.displayName ?: "Browse Photos", deviceName = device?.displayName ?: "Browse Photos",
selectedCount = selectedPhotos.size, selectedCount = selectedPhotos.size,
columnCount = columnCount, columnCount = columnCount,
viewMode = viewMode,
onBack = { state.navigateTo(Screen.CONNECT) }, onBack = { state.navigateTo(Screen.CONNECT) },
onSelectNone = { state.selectNone() }, onSelectNone = { state.selectNone() },
onImport = { state.prepareImport() }, onImport = { state.prepareImport() },
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) }, onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
onToggleView = { viewMode = if (viewMode == ViewMode.GROUPED) ViewMode.DENSE else ViewMode.GROUPED },
) )
if (state.shootingInHeic && !config.heicWarningDismissed) { if (state.shootingInHeic && !config.heicWarningDismissed) {
@@ -118,8 +123,9 @@ fun BrowseScreen(state: AppState) {
VerticalDivider() VerticalDivider()
// Right: unified continuous scroll // Right: swap between grouped and dense view
UnifiedPhotoScroll( when (viewMode) {
ViewMode.GROUPED -> UnifiedPhotoScroll(
folderSections = folderSections, folderSections = folderSections,
selectedPhotos = selectedPhotos, selectedPhotos = selectedPhotos,
device = device, device = device,
@@ -147,6 +153,16 @@ fun BrowseScreen(state: AppState) {
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
ViewMode.DENSE -> DensePhotoGrid(
photos = pl.all.sortedByDescending { it.dateTaken },
selectedPhotos = selectedPhotos,
device = device,
adbBin = config.adbPath ?: "adb",
columnCount = columnCount,
onTogglePhoto = { state.togglePhotoSelection(it) },
modifier = Modifier.weight(1f),
)
}
} }
} }
is PhotoListState.Error -> { is PhotoListState.Error -> {
@@ -460,10 +476,12 @@ private fun BrowseTopBar(
deviceName: String, deviceName: String,
selectedCount: Int, selectedCount: Int,
columnCount: Int, columnCount: Int,
viewMode: ViewMode,
onBack: () -> Unit, onBack: () -> Unit,
onSelectNone: () -> Unit, onSelectNone: () -> Unit,
onImport: () -> Unit, onImport: () -> Unit,
onColumnChange: (Int) -> Unit, onColumnChange: (Int) -> Unit,
onToggleView: () -> Unit,
) { ) {
TopAppBar( TopAppBar(
title = { Text(deviceName) }, title = { Text(deviceName) },
@@ -473,6 +491,14 @@ private fun BrowseTopBar(
} }
}, },
actions = { actions = {
// View mode toggle
FilterChip(
selected = viewMode == ViewMode.DENSE,
onClick = onToggleView,
label = { Text(if (viewMode == ViewMode.DENSE) "Dense" else "Grouped",
style = MaterialTheme.typography.labelMedium) },
modifier = Modifier.padding(end = 4.dp),
)
// Grid size controls // Grid size controls
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) { IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
Text("", style = MaterialTheme.typography.titleMedium) Text("", style = MaterialTheme.typography.titleMedium)

View File

@@ -0,0 +1,286 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
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
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import com.bolenpad.photophetch.util.ThumbnailCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Dense grid view with a date scrubber strip on the left edge.
*
* Photos flow as a flat grid with no day-header rows breaking it up.
* A narrow scrubber column shows date labels at day boundaries.
* Clicking a date label on the scrubber jumps to that day in the grid.
*
* Layout:
* [24dp scrubber] [3dp gap] [photo grid fills remaining width]
*/
@Composable
fun DensePhotoGrid(
photos: List<PhonePhoto>,
selectedPhotos: Set<PhonePhoto>,
device: DeviceInfo?,
adbBin: String,
columnCount: Int,
onTogglePhoto: (PhonePhoto) -> Unit,
modifier: Modifier = Modifier,
) {
if (photos.isEmpty()) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No photos", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
return
}
val zoneId = ZoneId.systemDefault()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Chunk into rows
val rows: List<List<PhonePhoto>> = photos.chunked(columnCount)
// For each row index, compute the date of its first photo
val rowDates: List<LocalDate> = rows.map {
it.first().dateTaken.atZone(zoneId).toLocalDate()
}
// Day boundary rows: the first row index where a new date appears
// Maps LocalDate → row index
val dayBoundaries: Map<LocalDate, Int> = buildMap {
var lastDate: LocalDate? = null
rowDates.forEachIndexed { idx, date ->
if (date != lastDate) {
put(date, idx)
lastDate = date
}
}
}
// Ordered list of (date, rowIndex) sorted newest first
val scrubberDates: List<Pair<LocalDate, Int>> = dayBoundaries.entries
.sortedByDescending { it.key }
.map { it.key to it.value }
val monthFmt = remember { DateTimeFormatter.ofPattern("MMM") }
val dayFmt = remember { DateTimeFormatter.ofPattern("d") }
Row(modifier = modifier.fillMaxSize()) {
// ── Date scrubber strip ───────────────────────────────────────────
// We overlay date labels proportionally over the full height.
// Using a Box with proportional placement rather than a LazyColumn
// so labels stay anchored to their visual position even when scrolling.
Box(
modifier = Modifier
.width(28.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
// Render scrubber labels using scroll-proportional positioning
val totalRows = rows.size
if (totalRows > 0) {
ScrubberOverlay(
scrubberDates = scrubberDates,
totalRows = totalRows,
onJump = { rowIdx -> scope.launch { listState.animateScrollToItem(rowIdx) } },
monthFmt = monthFmt,
dayFmt = dayFmt,
)
}
}
Spacer(Modifier.width(2.dp))
// ── Photo grid ────────────────────────────────────────────────────
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(bottom = 32.dp),
) {
items(
count = rows.size,
key = { idx -> "dense-row-${rows[idx].first().devicePath}" },
) { rowIdx ->
val rowPhotos = rows[rowIdx]
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
rowPhotos.forEach { photo ->
DenseThumb(
photo = photo,
isSelected = photo in selectedPhotos,
device = device,
adbBin = adbBin,
onToggle = { onTogglePhoto(photo) },
modifier = Modifier.weight(1f),
)
}
repeat(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) }
}
Spacer(Modifier.height(2.dp))
}
}
}
}
/**
* Scrubber overlay: proportionally-positioned date labels.
* Uses a custom layout approach with fillMaxHeight + proportional offset.
*/
@Composable
private fun ScrubberOverlay(
scrubberDates: List<Pair<LocalDate, Int>>,
totalRows: Int,
onJump: (Int) -> Unit,
monthFmt: DateTimeFormatter,
dayFmt: DateTimeFormatter,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val totalHeight = maxHeight
// Group by month — only show month label at first day of each month
var lastMonth: Int? = null
scrubberDates.forEach { (date, rowIdx) ->
val fraction = rowIdx.toFloat() / totalRows.toFloat()
val yOffset = totalHeight * fraction
val showMonth = date.monthValue != lastMonth
if (showMonth) lastMonth = date.monthValue
Column(
modifier = Modifier
.offset(y = yOffset)
.fillMaxWidth()
.clickable { onJump(rowIdx) }
.padding(vertical = 1.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (showMonth) {
Text(
monthFmt.format(date),
fontSize = 8.sp,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
lineHeight = 9.sp,
)
}
Text(
dayFmt.format(date),
fontSize = 9.sp,
fontWeight = if (showMonth) FontWeight.Bold else FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 10.sp,
)
}
}
}
}
@Composable
private fun DenseThumb(
photo: PhonePhoto,
isSelected: Boolean,
device: DeviceInfo?,
adbBin: String,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
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),
) {
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.TopStart).padding(2.dp),
) { Text("", style = MaterialTheme.typography.labelSmall) }
}
// Selection indicator
if (isSelected) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp),
)
}
}
}