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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user