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:
@@ -38,6 +38,8 @@ import org.jetbrains.skia.Image as SkiaImage
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
enum class ViewMode { GROUPED, DENSE }
|
||||
|
||||
@Composable
|
||||
fun BrowseScreen(state: AppState) {
|
||||
val photoList by state.photoList.collectAsState()
|
||||
@@ -47,15 +49,18 @@ fun BrowseScreen(state: AppState) {
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
var columnCount by remember { mutableStateOf(4) }
|
||||
var viewMode by remember { mutableStateOf(ViewMode.GROUPED) }
|
||||
|
||||
BrowseTopBar(
|
||||
deviceName = device?.displayName ?: "Browse Photos",
|
||||
selectedCount = selectedPhotos.size,
|
||||
columnCount = columnCount,
|
||||
viewMode = viewMode,
|
||||
onBack = { state.navigateTo(Screen.CONNECT) },
|
||||
onSelectNone = { state.selectNone() },
|
||||
onImport = { state.prepareImport() },
|
||||
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
|
||||
onToggleView = { viewMode = if (viewMode == ViewMode.GROUPED) ViewMode.DENSE else ViewMode.GROUPED },
|
||||
)
|
||||
|
||||
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
||||
@@ -118,35 +123,46 @@ fun BrowseScreen(state: AppState) {
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Right: unified continuous scroll
|
||||
UnifiedPhotoScroll(
|
||||
folderSections = folderSections,
|
||||
selectedPhotos = selectedPhotos,
|
||||
device = device,
|
||||
adbBin = config.adbPath ?: "adb",
|
||||
listState = listState,
|
||||
columnCount = columnCount,
|
||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||
onSelectAllFolder = { folder ->
|
||||
folder.forEach { p ->
|
||||
if (p !in selectedPhotos) state.togglePhotoSelection(p)
|
||||
}
|
||||
},
|
||||
onDeselectAllFolder = { folder ->
|
||||
folder.forEach { p ->
|
||||
if (p in selectedPhotos) state.togglePhotoSelection(p)
|
||||
}
|
||||
},
|
||||
onSelectDay = { dayPhotos ->
|
||||
val allSelected = dayPhotos.all { it in selectedPhotos }
|
||||
dayPhotos.forEach { p ->
|
||||
val inSet = p in selectedPhotos
|
||||
if (allSelected && inSet) state.togglePhotoSelection(p)
|
||||
else if (!allSelected && !inSet) state.togglePhotoSelection(p)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Right: swap between grouped and dense view
|
||||
when (viewMode) {
|
||||
ViewMode.GROUPED -> UnifiedPhotoScroll(
|
||||
folderSections = folderSections,
|
||||
selectedPhotos = selectedPhotos,
|
||||
device = device,
|
||||
adbBin = config.adbPath ?: "adb",
|
||||
listState = listState,
|
||||
columnCount = columnCount,
|
||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||
onSelectAllFolder = { folder ->
|
||||
folder.forEach { p ->
|
||||
if (p !in selectedPhotos) state.togglePhotoSelection(p)
|
||||
}
|
||||
},
|
||||
onDeselectAllFolder = { folder ->
|
||||
folder.forEach { p ->
|
||||
if (p in selectedPhotos) state.togglePhotoSelection(p)
|
||||
}
|
||||
},
|
||||
onSelectDay = { dayPhotos ->
|
||||
val allSelected = dayPhotos.all { it in selectedPhotos }
|
||||
dayPhotos.forEach { p ->
|
||||
val inSet = p in selectedPhotos
|
||||
if (allSelected && inSet) state.togglePhotoSelection(p)
|
||||
else if (!allSelected && !inSet) state.togglePhotoSelection(p)
|
||||
}
|
||||
},
|
||||
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 -> {
|
||||
@@ -460,10 +476,12 @@ private fun BrowseTopBar(
|
||||
deviceName: String,
|
||||
selectedCount: Int,
|
||||
columnCount: Int,
|
||||
viewMode: ViewMode,
|
||||
onBack: () -> Unit,
|
||||
onSelectNone: () -> Unit,
|
||||
onImport: () -> Unit,
|
||||
onColumnChange: (Int) -> Unit,
|
||||
onToggleView: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(deviceName) },
|
||||
@@ -473,6 +491,14 @@ private fun BrowseTopBar(
|
||||
}
|
||||
},
|
||||
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
|
||||
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
|
||||
Text("−", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
286
src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt
Normal file
286
src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user