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