Adaptive day headers + grid column controls

Day headers:
- Dense days (>=3 photos): full header with date, count, Select/Deselect day
- Sparse days (<3 photos): small date chip overlay on first photo, no
  header row — avoids wasted space when one photo per day for a stretch

Grid column controls:
- +/- buttons in top bar change column count (2-8)
- folderScrollIndex accounts for adaptive headers and column count so
  jump-to-folder stays accurate after column changes
This commit is contained in:
Kyle Bolen
2026-07-28 06:11:43 +00:00
parent 9390b24c7a
commit 2eb19a8a5d

View File

@@ -18,12 +18,15 @@ 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.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bolenpad.photophetch.model.DeviceInfo import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.MediaType import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto import com.bolenpad.photophetch.model.PhonePhoto
@@ -32,6 +35,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.skia.Image as SkiaImage import org.jetbrains.skia.Image as SkiaImage
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable @Composable
fun BrowseScreen(state: AppState) { fun BrowseScreen(state: AppState) {
@@ -41,12 +46,16 @@ fun BrowseScreen(state: AppState) {
val config by state.config.collectAsState() val config by state.config.collectAsState()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
var columnCount by remember { mutableStateOf(4) }
BrowseTopBar( BrowseTopBar(
deviceName = device?.displayName ?: "Browse Photos", deviceName = device?.displayName ?: "Browse Photos",
selectedCount = selectedPhotos.size, selectedCount = selectedPhotos.size,
columnCount = columnCount,
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) },
) )
if (state.shootingInHeic && !config.heicWarningDismissed) { if (state.shootingInHeic && !config.heicWarningDismissed) {
@@ -70,14 +79,14 @@ fun BrowseScreen(state: AppState) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Map folder name → first item index in the lazy list // Map folder name → first item index in the lazy list
val folderScrollIndex = remember(folderSections) { val folderScrollIndex = remember(folderSections, columnCount) {
var idx = 0 var idx = 0
folderSections.associate { (folder, byDay) -> folderSections.associate { (folder, byDay) ->
val startIdx = idx val startIdx = idx
idx += 1 // folder header idx += 1 // folder header
byDay.forEach { (_, dayPhotos) -> byDay.forEach { (_, dayPhotos) ->
idx += 1 // day header if (dayPhotos.size >= 3) idx += 1 // day header only for dense days
idx += ((dayPhotos.size + 3) / 4) // photo rows idx += ((dayPhotos.size + columnCount - 1) / columnCount) // photo rows
} }
folder.name to startIdx folder.name to startIdx
} }
@@ -116,6 +125,7 @@ fun BrowseScreen(state: AppState) {
device = device, device = device,
adbBin = config.adbPath ?: "adb", adbBin = config.adbPath ?: "adb",
listState = listState, listState = listState,
columnCount = columnCount,
onTogglePhoto = { state.togglePhotoSelection(it) }, onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder -> onSelectAllFolder = { folder ->
folder.forEach { p -> folder.forEach { p ->
@@ -283,6 +293,7 @@ private fun UnifiedPhotoScroll(
device: com.bolenpad.photophetch.model.DeviceInfo?, device: com.bolenpad.photophetch.model.DeviceInfo?,
adbBin: String, adbBin: String,
listState: LazyListState, listState: LazyListState,
columnCount: Int,
onTogglePhoto: (PhonePhoto) -> Unit, onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit, onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit, onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
@@ -290,6 +301,7 @@ private fun UnifiedPhotoScroll(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// Column count — adjustable via +/- buttons in the top bar
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@@ -311,10 +323,14 @@ private fun UnifiedPhotoScroll(
) )
} }
// ── Day groups ───────────────────────────────────────────────── // ── Day groups — adaptive headers ──────────────────────────────
// Dense days (≥3 photos): full header with count + Select day button
// Sparse days (<3 photos): small inline date chip on first photo of the day
section.byDay.forEach { (day, dayPhotos) -> section.byDay.forEach { (day, dayPhotos) ->
val daySelected = dayPhotos.count { it in selectedPhotos } val daySelected = dayPhotos.count { it in selectedPhotos }
val isDense = dayPhotos.size >= 3
if (isDense) {
item(key = "day-${section.folder.name}-$day") { item(key = "day-${section.folder.name}-$day") {
DaySubHeader( DaySubHeader(
day = day, day = day,
@@ -323,29 +339,32 @@ private fun UnifiedPhotoScroll(
onSelectDay = { onSelectDay(dayPhotos) }, onSelectDay = { onSelectDay(dayPhotos) },
) )
} }
}
val rows = dayPhotos.chunked(4) val rows = dayPhotos.chunked(columnCount)
items( items(
count = rows.size, count = rows.size,
key = { idx -> "row-${section.folder.name}-$day-$idx" }, key = { idx -> "row-${section.folder.name}-$day-$idx" },
) { rowIdx -> ) { rowIdx ->
val rowPhotos = rows[rowIdx] val rowPhotos = rows[rowIdx]
val isFirstRow = rowIdx == 0
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(3.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
) { ) {
rowPhotos.forEach { photo -> rowPhotos.forEachIndexed { colIdx, photo ->
val showDateChip = !isDense && isFirstRow && colIdx == 0
PhotoThumb( PhotoThumb(
photo = photo, photo = photo,
isSelected = photo in selectedPhotos, isSelected = photo in selectedPhotos,
device = device, device = device,
adbBin = adbBin, adbBin = adbBin,
onToggle = { onTogglePhoto(photo) }, onToggle = { onTogglePhoto(photo) },
dateChip = if (showDateChip) day else null,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
repeat(4 - rowPhotos.size) { Spacer(Modifier.weight(1f)) } repeat(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) }
} }
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
} }
@@ -440,9 +459,11 @@ private fun DaySubHeader(
private fun BrowseTopBar( private fun BrowseTopBar(
deviceName: String, deviceName: String,
selectedCount: Int, selectedCount: Int,
columnCount: Int,
onBack: () -> Unit, onBack: () -> Unit,
onSelectNone: () -> Unit, onSelectNone: () -> Unit,
onImport: () -> Unit, onImport: () -> Unit,
onColumnChange: (Int) -> Unit,
) { ) {
TopAppBar( TopAppBar(
title = { Text(deviceName) }, title = { Text(deviceName) },
@@ -452,6 +473,15 @@ private fun BrowseTopBar(
} }
}, },
actions = { actions = {
// Grid size controls
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
Text("", style = MaterialTheme.typography.titleMedium)
}
Text("$columnCount", style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 2.dp))
IconButton(onClick = { onColumnChange(1) }, enabled = columnCount < 8) {
Text("+", style = MaterialTheme.typography.titleMedium)
}
if (selectedCount > 0) { if (selectedCount > 0) {
TextButton(onClick = onSelectNone) { Text("Clear") } TextButton(onClick = onSelectNone) { Text("Clear") }
} }
@@ -501,6 +531,7 @@ private fun PhotoThumb(
device: DeviceInfo?, device: DeviceInfo?,
adbBin: String, adbBin: String,
onToggle: () -> Unit, onToggle: () -> Unit,
dateChip: LocalDate? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) } var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
@@ -565,6 +596,21 @@ private fun PhotoThumb(
} }
} }
// Sparse-day date chip — bottom left, small pill
if (dateChip != null) {
val fmt = remember { DateTimeFormatter.ofPattern("MMM d") }
Text(
fmt.format(dateChip),
fontSize = 10.sp,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(4.dp)
.background(Color.Black.copy(alpha = 0.5f), shape = MaterialTheme.shapes.extraSmall)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
// Selection indicator top-right // Selection indicator top-right
Icon( Icon(
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,