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.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)
|
||||||
|
|||||||
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