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 package com.bolenpad.photophetch.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.material.icons.filled.Warning
import androidx.compose.material3.* 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.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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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.MediaType
import com.bolenpad.photophetch.model.PhonePhoto import com.bolenpad.photophetch.model.PhonePhoto
import com.bolenpad.photophetch.util.ThumbnailCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import java.time.ZoneId
import org.jetbrains.skia.Image as SkiaImage
import java.time.LocalDate
import java.time.format.DateTimeFormatter
enum class ViewMode { GROUPED, DENSE }
@Composable @Composable
fun BrowseScreen(state: AppState) { fun BrowseScreen(state: AppState) {
@@ -49,18 +27,15 @@ fun BrowseScreen(state: AppState) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
var columnCount by remember { mutableStateOf(4) } var columnCount by remember { mutableStateOf(4) }
var viewMode by remember { mutableStateOf(ViewMode.GROUPED) }
BrowseTopBar( BrowseTopBar(
deviceName = device?.displayName ?: "Browse Photos", deviceName = device?.displayName ?: "Browse Photos",
selectedCount = selectedPhotos.size, selectedCount = selectedPhotos.size,
columnCount = columnCount, columnCount = columnCount,
viewMode = viewMode,
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) }, onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
onToggleView = { viewMode = if (viewMode == ViewMode.GROUPED) ViewMode.DENSE else ViewMode.GROUPED },
) )
if (state.shootingInHeic && !config.heicWarningDismissed) { if (state.shootingInHeic && !config.heicWarningDismissed) {
@@ -78,62 +53,38 @@ fun BrowseScreen(state: AppState) {
} }
} }
is PhotoListState.Loaded -> { is PhotoListState.Loaded -> {
// Build ordered list of (folder, photos-by-day) for the unified scroll
val folderSections = buildFolderSections(pl) val folderSections = buildFolderSections(pl)
val groupedListState = rememberLazyListState() val listState = rememberLazyListState()
val denseListState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = if (viewMode == ViewMode.GROUPED) groupedListState else denseListState // Folder scroll index for jump list
val folderScrollIndex = remember(folderSections, columnCount) {
// Grouped mode: folder scroll index (accounts for day headers)
val groupedScrollIndex = remember(folderSections, columnCount) {
var idx = 0 var idx = 0
folderSections.associate { (folder, byDay) -> folderSections.associate { section ->
val startIdx = idx val startIdx = idx
idx += 1 // folder header idx += 1 // folder header
byDay.forEach { (_, dayPhotos) -> idx += ((section.allPhotos.size + columnCount - 1) / columnCount)
if (dayPhotos.size >= 3) idx += 1 section.folder.name to startIdx
idx += ((dayPhotos.size + columnCount - 1) / columnCount)
}
folder.name to startIdx
} }
} }
// Dense mode: folder scroll index (folder header + photo rows only) val activeFolder by remember(folderScrollIndex) {
val denseScrollIndex = remember(folderSections, columnCount) { derivedStateOf {
var idx = 0 val firstVisible = listState.firstVisibleItemIndex
folderSections.associate { folderSection -> folderScrollIndex.entries
val startIdx = idx .filter { it.value <= firstVisible }
idx += 1 // folder header .maxByOrNull { it.value }?.key
idx += ((folderSection.allPhotos.size + columnCount - 1) / columnCount) ?: folderSections.firstOrNull()?.folder?.name
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 {
derivedStateOf {
val firstVisible = listState.firstVisibleItemIndex
folderScrollIndex.entries
.filter { it.value <= firstVisible }
.maxByOrNull { it.value }?.key
?: folderSections.firstOrNull()?.folder?.name
}
} }
} }
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
// Left: folder jump list — jumps in whichever list is active
FolderJumpList( FolderJumpList(
folders = pl.folders, folders = pl.folders,
activeFolder = activeFolder, activeFolder = activeFolder,
selectedPhotos = selectedPhotos, selectedPhotos = selectedPhotos,
onJump = { folderName -> onJump = { name ->
val idx = folderScrollIndex[folderName] ?: return@FolderJumpList val idx = folderScrollIndex[name] ?: return@FolderJumpList
scope.launch { listState.animateScrollToItem(idx) } scope.launch { listState.animateScrollToItem(idx) }
}, },
modifier = Modifier.width(190.dp).fillMaxHeight(), modifier = Modifier.width(190.dp).fillMaxHeight(),
@@ -141,57 +92,22 @@ fun BrowseScreen(state: AppState) {
VerticalDivider() VerticalDivider()
// Right: swap between grouped and dense view DensePhotoGrid(
when (viewMode) { folderSections = folderSections,
ViewMode.GROUPED -> UnifiedPhotoScroll( selectedPhotos = selectedPhotos,
folderSections = folderSections, device = device,
selectedPhotos = selectedPhotos, adbBin = config.adbPath ?: "adb",
device = device, columnCount = columnCount,
adbBin = config.adbPath ?: "adb", listState = listState,
listState = groupedListState, onTogglePhoto = { state.togglePhotoSelection(it) },
columnCount = columnCount, onSelectAllFolder = { folder ->
onTogglePhoto = { state.togglePhotoSelection(it) }, folder.forEach { p -> if (p !in selectedPhotos) state.togglePhotoSelection(p) }
onSelectAllFolder = { folder -> },
folder.forEach { p -> onDeselectAllFolder = { folder ->
if (p !in selectedPhotos) state.togglePhotoSelection(p) folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
} },
}, modifier = Modifier.weight(1f),
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(
folderSections = folderSections,
selectedPhotos = selectedPhotos,
device = device,
adbBin = config.adbPath ?: "adb",
columnCount = columnCount,
listState = denseListState,
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)
}
},
modifier = Modifier.weight(1f),
)
}
} }
} }
is PhotoListState.Error -> { is PhotoListState.Error -> {
@@ -208,10 +124,10 @@ fun BrowseScreen(state: AppState) {
} }
} }
// ─── Data helpers ───────────────────────────────────────────────────────────── // ─── Data helpers ─────────────────────────────────────────────────────────────
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> { private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
val zoneId = java.time.ZoneId.systemDefault() val zoneId = ZoneId.systemDefault()
return pl.folders.map { folder -> return pl.folders.map { folder ->
val photos = pl.all val photos = pl.all
.filter { it.sourceFolder == folder.name } .filter { it.sourceFolder == folder.name }
@@ -225,7 +141,7 @@ private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection>
} }
} }
// ─── Left jump list ─────────────────────────────────────────────────────────── // ─── Folder jump list ─────────────────────────────────────────────────────────
@Composable @Composable
private fun FolderJumpList( private fun FolderJumpList(
@@ -244,38 +160,24 @@ private fun FolderJumpList(
if (cameraRoll.isNotEmpty()) { if (cameraRoll.isNotEmpty()) {
item { item {
Text( Text("CAMERA", style = MaterialTheme.typography.labelSmall,
"CAMERA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 -> items(cameraRoll.size) { i ->
JumpRow( JumpRow(cameraRoll[i], cameraRoll[i].name == activeFolder,
folder = cameraRoll[i], selectedPhotos.count { it.sourceFolder == cameraRoll[i].name }) { onJump(cameraRoll[i].name) }
isActive = cameraRoll[i].name == activeFolder,
selectedCount = selectedPhotos.count { it.sourceFolder == cameraRoll[i].name },
onClick = { onJump(cameraRoll[i].name) },
)
} }
} }
if (other.isNotEmpty()) { if (other.isNotEmpty()) {
item { item {
Text( Text("OTHER", style = MaterialTheme.typography.labelSmall,
"OTHER",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 -> items(other.size) { i ->
JumpRow( JumpRow(other[i], other[i].name == activeFolder,
folder = other[i], selectedPhotos.count { it.sourceFolder == other[i].name }) { onJump(other[i].name) }
isActive = other[i].name == activeFolder,
selectedCount = selectedPhotos.count { it.sourceFolder == other[i].name },
onClick = { onJump(other[i].name) },
)
} }
} }
} }
@@ -283,37 +185,20 @@ private fun FolderJumpList(
} }
@Composable @Composable
private fun JumpRow( private fun JumpRow(folder: FolderInfo, isActive: Boolean, selectedCount: Int, onClick: () -> Unit) {
folder: FolderInfo,
isActive: Boolean,
selectedCount: Int,
onClick: () -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)
.fillMaxWidth() .background(if (isActive) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.background(
if (isActive) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface,
)
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(folder.name, style = MaterialTheme.typography.bodySmall,
folder.name,
style = MaterialTheme.typography.bodySmall,
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1, maxLines = 1, overflow = TextOverflow.Ellipsis)
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, Text("${folder.photoCount}", style = MaterialTheme.typography.labelSmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"${folder.photoCount}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
if (selectedCount > 0) { if (selectedCount > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) { Badge(containerColor = MaterialTheme.colorScheme.primary) {
@@ -323,94 +208,7 @@ private fun JumpRow(
} }
} }
// ─── Right unified scroll ───────────────────────────────────────────────────── // ─── Folder divider header ────────────────────────────────────────────────────
@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))
}
}
}
}
}
@Composable @Composable
internal fun FolderDividerHeader( internal fun FolderDividerHeader(
@@ -436,61 +234,25 @@ internal fun FolderDividerHeader(
if (selected > 0) "$selected / $total selected" else "$total photos", if (selected > 0) "$selected / $total selected" else "$total photos",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (selected > 0) MaterialTheme.colorScheme.primary color = if (selected > 0) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton( TextButton(onClick = onSelectAll, enabled = !allSelected,
onClick = onSelectAll, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
enabled = !allSelected, Text("Select all", style = MaterialTheme.typography.labelMedium)
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), }
) { Text("Select all", style = MaterialTheme.typography.labelMedium) }
if (selected > 0) { if (selected > 0) {
TextButton( TextButton(onClick = onDeselectAll,
onClick = onDeselectAll, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), Text("Deselect all", style = MaterialTheme.typography.labelMedium)
) { 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 ────────────────────────────────────────────────────────────────── // ─── Top bar ──────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -499,12 +261,10 @@ private fun BrowseTopBar(
deviceName: String, deviceName: String,
selectedCount: Int, selectedCount: Int,
columnCount: Int, columnCount: Int,
viewMode: ViewMode,
onBack: () -> Unit, onBack: () -> Unit,
onSelectNone: () -> Unit, onSelectNone: () -> Unit,
onImport: () -> Unit, onImport: () -> Unit,
onColumnChange: (Int) -> Unit, onColumnChange: (Int) -> Unit,
onToggleView: () -> Unit,
) { ) {
TopAppBar( TopAppBar(
title = { Text(deviceName) }, title = { Text(deviceName) },
@@ -514,15 +274,6 @@ private fun BrowseTopBar(
} }
}, },
actions = { 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) { IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
Text("", style = MaterialTheme.typography.titleMedium) Text("", style = MaterialTheme.typography.titleMedium)
} }
@@ -551,121 +302,17 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
) { ) {
Row( Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp)) Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
Text( Text("Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
"Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer, color = MaterialTheme.colorScheme.onTertiaryContainer)
)
} }
TextButton(onClick = onDismiss) { Text("Dismiss") } 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 com.bolenpad.photophetch.model.PhonePhoto
import java.time.LocalDate 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( data class FolderSection(
val folder: FolderInfo, val folder: FolderInfo,
val byDay: List<Pair<LocalDate, List<PhonePhoto>>>, val byDay: List<Pair<LocalDate, List<PhonePhoto>>>,
val allPhotos: List<PhonePhoto>, val allPhotos: List<PhonePhoto>,
) )
/** Item types used in the DensePhotoGrid flat list */ /** Item types used in DensePhotoGrid flat list */
enum class GridItemType { FOLDER_HEADER, DAY_BOUNDARY, PHOTO_ROW } enum class GridItemType { FOLDER_HEADER, 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,
)