From 2eb19a8a5d59d23757fdf43030976b89338d9eff Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 06:11:43 +0000 Subject: [PATCH] Adaptive day headers + grid column controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../bolenpad/photophetch/ui/BrowseScreen.kt | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index b78ae17..b96b2e9 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -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) -> Unit, onDeselectAllFolder: (List) -> 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(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,