Remove grouped view mode — dense grid is the only view

Deleted: ViewMode enum, UnifiedPhotoScroll, DaySubHeader, PhotoThumb
(grouped-mode cell), view toggle button, groupedListState, groupedScrollIndex.

BrowseScreen is now ~100 lines simpler. DensePhotoGrid is the single
grid implementation. FolderJumpList and FolderDividerHeader remain.
This commit is contained in:
Kyle Bolen
2026-07-28 06:41:59 +00:00
parent c84d3426a7
commit 617ce03cf9
2 changed files with 68 additions and 429 deletions

View File

@@ -1,44 +1,22 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Warning
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.text.style.TextOverflow
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
import com.bolenpad.photophetch.util.ThumbnailCache
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
enum class ViewMode { GROUPED, DENSE }
import java.time.ZoneId
@Composable
fun BrowseScreen(state: AppState) {
@@ -49,18 +27,15 @@ fun BrowseScreen(state: AppState) {
Column(modifier = Modifier.fillMaxSize()) {
var columnCount by remember { mutableStateOf(4) }
var viewMode by remember { mutableStateOf(ViewMode.GROUPED) }
BrowseTopBar(
deviceName = device?.displayName ?: "Browse Photos",
selectedCount = selectedPhotos.size,
columnCount = columnCount,
viewMode = viewMode,
onBack = { state.navigateTo(Screen.CONNECT) },
onSelectNone = { state.selectNone() },
onImport = { state.prepareImport() },
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
onToggleView = { viewMode = if (viewMode == ViewMode.GROUPED) ViewMode.DENSE else ViewMode.GROUPED },
)
if (state.shootingInHeic && !config.heicWarningDismissed) {
@@ -78,44 +53,22 @@ fun BrowseScreen(state: AppState) {
}
}
is PhotoListState.Loaded -> {
// Build ordered list of (folder, photos-by-day) for the unified scroll
val folderSections = buildFolderSections(pl)
val groupedListState = rememberLazyListState()
val denseListState = rememberLazyListState()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val listState = if (viewMode == ViewMode.GROUPED) groupedListState else denseListState
// Grouped mode: folder scroll index (accounts for day headers)
val groupedScrollIndex = remember(folderSections, columnCount) {
// Folder scroll index for jump list
val folderScrollIndex = remember(folderSections, columnCount) {
var idx = 0
folderSections.associate { (folder, byDay) ->
folderSections.associate { section ->
val startIdx = idx
idx += 1 // folder header
byDay.forEach { (_, dayPhotos) ->
if (dayPhotos.size >= 3) idx += 1
idx += ((dayPhotos.size + columnCount - 1) / columnCount)
}
folder.name to startIdx
idx += ((section.allPhotos.size + columnCount - 1) / columnCount)
section.folder.name to startIdx
}
}
// Dense mode: folder scroll index (folder header + photo rows only)
val denseScrollIndex = remember(folderSections, columnCount) {
var idx = 0
folderSections.associate { folderSection ->
val startIdx = idx
idx += 1 // folder header
idx += ((folderSection.allPhotos.size + columnCount - 1) / columnCount)
folderSection.folder.name to startIdx
}
}
val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex
// Scroll spy — keyed on viewMode so it recomposes when switching
val activeFolder by key(viewMode) {
remember {
val activeFolder by remember(folderScrollIndex) {
derivedStateOf {
val firstVisible = listState.firstVisibleItemIndex
folderScrollIndex.entries
@@ -124,16 +77,14 @@ fun BrowseScreen(state: AppState) {
?: folderSections.firstOrNull()?.folder?.name
}
}
}
Row(modifier = Modifier.fillMaxSize()) {
// Left: folder jump list — jumps in whichever list is active
FolderJumpList(
folders = pl.folders,
activeFolder = activeFolder,
selectedPhotos = selectedPhotos,
onJump = { folderName ->
val idx = folderScrollIndex[folderName] ?: return@FolderJumpList
onJump = { name ->
val idx = folderScrollIndex[name] ?: return@FolderJumpList
scope.launch { listState.animateScrollToItem(idx) }
},
modifier = Modifier.width(190.dp).fillMaxHeight(),
@@ -141,59 +92,24 @@ fun BrowseScreen(state: AppState) {
VerticalDivider()
// Right: swap between grouped and dense view
when (viewMode) {
ViewMode.GROUPED -> UnifiedPhotoScroll(
folderSections = folderSections,
selectedPhotos = selectedPhotos,
device = device,
adbBin = config.adbPath ?: "adb",
listState = groupedListState,
columnCount = columnCount,
onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder ->
folder.forEach { p ->
if (p !in selectedPhotos) state.togglePhotoSelection(p)
}
},
onDeselectAllFolder = { folder ->
folder.forEach { p ->
if (p in selectedPhotos) state.togglePhotoSelection(p)
}
},
onSelectDay = { dayPhotos ->
val allSelected = dayPhotos.all { it in selectedPhotos }
dayPhotos.forEach { p ->
val inSet = p in selectedPhotos
if (allSelected && inSet) state.togglePhotoSelection(p)
else if (!allSelected && !inSet) state.togglePhotoSelection(p)
}
},
modifier = Modifier.weight(1f),
)
ViewMode.DENSE -> DensePhotoGrid(
DensePhotoGrid(
folderSections = folderSections,
selectedPhotos = selectedPhotos,
device = device,
adbBin = config.adbPath ?: "adb",
columnCount = columnCount,
listState = denseListState,
listState = listState,
onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectAllFolder = { folder ->
folder.forEach { p ->
if (p !in selectedPhotos) state.togglePhotoSelection(p)
}
folder.forEach { p -> if (p !in selectedPhotos) state.togglePhotoSelection(p) }
},
onDeselectAllFolder = { folder ->
folder.forEach { p ->
if (p in selectedPhotos) state.togglePhotoSelection(p)
}
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
},
modifier = Modifier.weight(1f),
)
}
}
}
is PhotoListState.Error -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@@ -208,10 +124,10 @@ fun BrowseScreen(state: AppState) {
}
}
// ─── Data helpers ─────────────────────────────────────────────────────────────
// ─── Data helpers ─────────────────────────────────────────────────────────────
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
val zoneId = java.time.ZoneId.systemDefault()
val zoneId = ZoneId.systemDefault()
return pl.folders.map { folder ->
val photos = pl.all
.filter { it.sourceFolder == folder.name }
@@ -225,7 +141,7 @@ private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection>
}
}
// ─── Left jump list ───────────────────────────────────────────────────────────
// ─── Folder jump list ─────────────────────────────────────────────────────────
@Composable
private fun FolderJumpList(
@@ -244,38 +160,24 @@ private fun FolderJumpList(
if (cameraRoll.isNotEmpty()) {
item {
Text(
"CAMERA",
style = MaterialTheme.typography.labelSmall,
Text("CAMERA", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp),
)
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp))
}
items(cameraRoll.size) { i ->
JumpRow(
folder = cameraRoll[i],
isActive = cameraRoll[i].name == activeFolder,
selectedCount = selectedPhotos.count { it.sourceFolder == cameraRoll[i].name },
onClick = { onJump(cameraRoll[i].name) },
)
JumpRow(cameraRoll[i], cameraRoll[i].name == activeFolder,
selectedPhotos.count { it.sourceFolder == cameraRoll[i].name }) { onJump(cameraRoll[i].name) }
}
}
if (other.isNotEmpty()) {
item {
Text(
"OTHER",
style = MaterialTheme.typography.labelSmall,
Text("OTHER", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 2.dp),
)
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 2.dp))
}
items(other.size) { i ->
JumpRow(
folder = other[i],
isActive = other[i].name == activeFolder,
selectedCount = selectedPhotos.count { it.sourceFolder == other[i].name },
onClick = { onJump(other[i].name) },
)
JumpRow(other[i], other[i].name == activeFolder,
selectedPhotos.count { it.sourceFolder == other[i].name }) { onJump(other[i].name) }
}
}
}
@@ -283,37 +185,20 @@ private fun FolderJumpList(
}
@Composable
private fun JumpRow(
folder: FolderInfo,
isActive: Boolean,
selectedCount: Int,
onClick: () -> Unit,
) {
private fun JumpRow(folder: FolderInfo, isActive: Boolean, selectedCount: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(
if (isActive) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface,
)
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)
.background(if (isActive) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
folder.name,
style = MaterialTheme.typography.bodySmall,
Text(folder.name, style = MaterialTheme.typography.bodySmall,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
Text(
"${folder.photoCount}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${folder.photoCount}", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (selectedCount > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
@@ -323,94 +208,7 @@ private fun JumpRow(
}
}
// ─── Right unified scroll ─────────────────────────────────────────────────────
@Composable
private fun UnifiedPhotoScroll(
folderSections: List<FolderSection>,
selectedPhotos: Set<PhonePhoto>,
device: com.bolenpad.photophetch.model.DeviceInfo?,
adbBin: String,
listState: LazyListState,
columnCount: Int,
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
onSelectDay: (List<PhonePhoto>) -> Unit,
modifier: Modifier = Modifier,
) {
// Column count — adjustable via +/- buttons in the top bar
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 32.dp),
) {
folderSections.forEach { section ->
val folderSelected = section.allPhotos.count { it in selectedPhotos }
val allFolderSelected = folderSelected == section.allPhotos.size
// ── Folder divider header ──────────────────────────────────────
item(key = "folder-${section.folder.name}") {
FolderDividerHeader(
name = section.folder.name,
total = section.allPhotos.size,
selected = folderSelected,
allSelected = allFolderSelected,
onSelectAll = { onSelectAllFolder(section.allPhotos) },
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
)
}
// ── 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
if (isDense) {
item(key = "day-${section.folder.name}-$day") {
DaySubHeader(
day = day,
total = dayPhotos.size,
selected = daySelected,
onSelectDay = { onSelectDay(dayPhotos) },
)
}
}
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),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
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(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) }
}
Spacer(Modifier.height(3.dp))
}
}
}
}
}
// ─── Folder divider header ────────────────────────────────────────────────────
@Composable
internal fun FolderDividerHeader(
@@ -440,55 +238,19 @@ internal fun FolderDividerHeader(
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(
onClick = onSelectAll,
enabled = !allSelected,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) { Text("Select all", style = MaterialTheme.typography.labelMedium) }
TextButton(onClick = onSelectAll, enabled = !allSelected,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
Text("Select all", style = MaterialTheme.typography.labelMedium)
}
if (selected > 0) {
TextButton(
onClick = onDeselectAll,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) { Text("Deselect all", style = MaterialTheme.typography.labelMedium) }
TextButton(onClick = onDeselectAll,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
Text("Deselect all", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
@Composable
private fun DaySubHeader(
day: java.time.LocalDate,
total: Int,
selected: Int,
onSelectDay: () -> Unit,
) {
val fmt = remember { java.time.format.DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy") }
Row(
modifier = Modifier.fillMaxWidth()
.padding(start = 12.dp, end = 8.dp, top = 10.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(fmt.format(day), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text(
if (selected > 0) "$selected / $total selected" else "$total photos",
style = MaterialTheme.typography.labelSmall,
color = if (selected > 0) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(
onClick = onSelectDay,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) {
Text(
if (selected == total) "Deselect day" else "Select day",
style = MaterialTheme.typography.labelMedium,
)
}
}
}
// ─── Top bar ──────────────────────────────────────────────────────────────────
@@ -499,12 +261,10 @@ private fun BrowseTopBar(
deviceName: String,
selectedCount: Int,
columnCount: Int,
viewMode: ViewMode,
onBack: () -> Unit,
onSelectNone: () -> Unit,
onImport: () -> Unit,
onColumnChange: (Int) -> Unit,
onToggleView: () -> Unit,
) {
TopAppBar(
title = { Text(deviceName) },
@@ -514,15 +274,6 @@ private fun BrowseTopBar(
}
},
actions = {
// View mode toggle
FilterChip(
selected = viewMode == ViewMode.DENSE,
onClick = onToggleView,
label = { Text(if (viewMode == ViewMode.DENSE) "Dense" else "Grouped",
style = MaterialTheme.typography.labelMedium) },
modifier = Modifier.padding(end = 4.dp),
)
// Grid size controls
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
Text("", style = MaterialTheme.typography.titleMedium)
}
@@ -551,121 +302,17 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodySmall)
Text(
"Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
Text("Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
color = MaterialTheme.colorScheme.onTertiaryContainer)
}
TextButton(onClick = onDismiss) { Text("Dismiss") }
}
}
}
// ─── Photo thumbnail cell ─────────────────────────────────────────────────────
@Composable
private fun PhotoThumb(
photo: PhonePhoto,
isSelected: Boolean,
device: DeviceInfo?,
adbBin: String,
onToggle: () -> Unit,
dateChip: LocalDate? = null,
modifier: Modifier = Modifier,
) {
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
var thumbFailed by remember(photo.devicePath) { mutableStateOf(false) }
LaunchedEffect(photo.devicePath) {
if (device?.adbSerial != null && !thumbFailed && thumbnail == null) {
val bytes = withContext(Dispatchers.IO) {
ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename)
}
if (bytes != null) {
runCatching {
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
}.onFailure { thumbFailed = true }
} else {
thumbFailed = true
}
}
}
Box(
modifier = modifier
.aspectRatio(1f)
.clickable(onClick = onToggle)
.then(if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.primary) else Modifier),
) {
if (thumbnail != null) {
Image(
bitmap = thumbnail!!,
contentDescription = photo.filename,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Image,
contentDescription = null,
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(24.dp),
)
}
}
// Badges top-left
Row(
modifier = Modifier.align(Alignment.TopStart).padding(3.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
if (photo.isHeic) {
Badge(containerColor = MaterialTheme.colorScheme.tertiaryContainer) {
Text("HEIC", style = MaterialTheme.typography.labelSmall)
}
}
if (photo.mediaType == MediaType.VIDEO) {
Badge(containerColor = MaterialTheme.colorScheme.tertiary) {
Text("", style = MaterialTheme.typography.labelSmall)
}
}
}
// 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,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.8f),
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(22.dp),
)
}
}

View File

@@ -3,20 +3,12 @@ package com.bolenpad.photophetch.ui
import com.bolenpad.photophetch.model.PhonePhoto
import java.time.LocalDate
/** A folder's photos grouped by day, used by both Grouped and Dense grid views */
/** A folder's photos grouped by day */
data class FolderSection(
val folder: FolderInfo,
val byDay: List<Pair<LocalDate, List<PhonePhoto>>>,
val allPhotos: List<PhonePhoto>,
)
/** Item types used in the DensePhotoGrid flat list */
enum class GridItemType { FOLDER_HEADER, DAY_BOUNDARY, PHOTO_ROW }
/** A single item in the DensePhotoGrid flat list */
data class GridItem(
val type: GridItemType,
val folderSection: FolderSection? = null,
val rowPhotos: List<PhonePhoto>? = null,
val dayDate: LocalDate? = null,
)
/** Item types used in DensePhotoGrid flat list */
enum class GridItemType { FOLDER_HEADER, PHOTO_ROW }