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.ui.Alignment
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.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
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
@@ -32,6 +35,8 @@ 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.format.DateTimeFormatter
@Composable
fun BrowseScreen(state: AppState) {
@@ -41,12 +46,16 @@ fun BrowseScreen(state: AppState) {
val config by state.config.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
var columnCount by remember { mutableStateOf(4) }
BrowseTopBar(
deviceName = device?.displayName ?: "Browse Photos",
selectedCount = selectedPhotos.size,
columnCount = columnCount,
onBack = { state.navigateTo(Screen.CONNECT) },
onSelectNone = { state.selectNone() },
onImport = { state.prepareImport() },
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
)
if (state.shootingInHeic && !config.heicWarningDismissed) {
@@ -70,14 +79,14 @@ fun BrowseScreen(state: AppState) {
val scope = rememberCoroutineScope()
// Map folder name → first item index in the lazy list
val folderScrollIndex = remember(folderSections) {
val folderScrollIndex = remember(folderSections, columnCount) {
var idx = 0
folderSections.associate { (folder, byDay) ->
val startIdx = idx
idx += 1 // folder header
byDay.forEach { (_, dayPhotos) ->
idx += 1 // day header
idx += ((dayPhotos.size + 3) / 4) // photo rows
if (dayPhotos.size >= 3) idx += 1 // day header only for dense days
idx += ((dayPhotos.size + columnCount - 1) / columnCount) // photo rows
}
folder.name to startIdx
}
@@ -116,6 +125,7 @@ fun BrowseScreen(state: AppState) {
device = device,
adbBin = config.adbPath ?: "adb",
listState = listState,
columnCount = columnCount,
onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder ->
folder.forEach { p ->
@@ -283,6 +293,7 @@ private fun UnifiedPhotoScroll(
device: com.bolenpad.photophetch.model.DeviceInfo?,
adbBin: String,
listState: LazyListState,
columnCount: Int,
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
@@ -290,6 +301,7 @@ private fun UnifiedPhotoScroll(
modifier: Modifier = Modifier,
) {
// Column count — adjustable via +/- buttons in the top bar
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
@@ -311,41 +323,48 @@ 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) ->
val daySelected = dayPhotos.count { it in selectedPhotos }
val isDense = dayPhotos.size >= 3
item(key = "day-${section.folder.name}-$day") {
DaySubHeader(
day = day,
total = dayPhotos.size,
selected = daySelected,
onSelectDay = { onSelectDay(dayPhotos) },
)
if (isDense) {
item(key = "day-${section.folder.name}-$day") {
DaySubHeader(
day = day,
total = dayPhotos.size,
selected = daySelected,
onSelectDay = { onSelectDay(dayPhotos) },
)
}
}
val rows = dayPhotos.chunked(4)
val rows = dayPhotos.chunked(columnCount)
items(
count = rows.size,
key = { idx -> "row-${section.folder.name}-$day-$idx" },
) { rowIdx ->
val rowPhotos = rows[rowIdx]
val isFirstRow = rowIdx == 0
Row(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
rowPhotos.forEach { photo ->
rowPhotos.forEachIndexed { colIdx, photo ->
val showDateChip = !isDense && isFirstRow && colIdx == 0
PhotoThumb(
photo = photo,
isSelected = photo in selectedPhotos,
device = device,
adbBin = adbBin,
onToggle = { onTogglePhoto(photo) },
dateChip = if (showDateChip) day else null,
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))
}
@@ -440,9 +459,11 @@ private fun DaySubHeader(
private fun BrowseTopBar(
deviceName: String,
selectedCount: Int,
columnCount: Int,
onBack: () -> Unit,
onSelectNone: () -> Unit,
onImport: () -> Unit,
onColumnChange: (Int) -> Unit,
) {
TopAppBar(
title = { Text(deviceName) },
@@ -452,6 +473,15 @@ private fun BrowseTopBar(
}
},
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) {
TextButton(onClick = onSelectNone) { Text("Clear") }
}
@@ -501,6 +531,7 @@ private fun PhotoThumb(
device: DeviceInfo?,
adbBin: String,
onToggle: () -> Unit,
dateChip: LocalDate? = null,
modifier: Modifier = Modifier,
) {
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
Icon(
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,