Rewrite BrowseScreen: unified scroll with folder sections + jump list

- Single LazyColumn with folder divider headers embedded — no page switching
- Left panel is a jump list: click scrolls to that folder, highlights
  active folder as you scroll (derivedStateOf scroll spy)
- Each folder section header has Select All / Deselect All buttons
- Each day sub-header has Select Day / Deselect Day toggle
- Removed 'All Photos' merged view — folders are always separate sections
- PhotoThumb moved inline, cleaned up imports
This commit is contained in:
Kyle Bolen
2026-07-28 05:24:29 +00:00
parent f6aede99f1
commit fd5753382f
3 changed files with 456 additions and 455 deletions

View File

@@ -1,45 +1,58 @@
package com.bolenpad.photophetch.ui 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.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.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.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.unit.dp import androidx.compose.ui.unit.dp
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 java.time.LocalDate 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
@Composable @Composable
fun BrowseScreen(state: AppState) { fun BrowseScreen(state: AppState) {
val photoList by state.photoList.collectAsState() val photoList by state.photoList.collectAsState()
val selectedFolder by state.selectedFolder.collectAsState()
val visiblePhotos by state.visiblePhotos.collectAsState()
val selectedPhotos by state.selectedPhotos.collectAsState() val selectedPhotos by state.selectedPhotos.collectAsState()
val device by state.selectedDevice.collectAsState() val device by state.selectedDevice.collectAsState()
val config by state.config.collectAsState() val config by state.config.collectAsState()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// ── Top bar ──────────────────────────────────────────────────────────
BrowseTopBar( BrowseTopBar(
deviceName = device?.displayName ?: "Browse Photos", deviceName = device?.displayName ?: "Browse Photos",
selectedCount = selectedPhotos.size, selectedCount = selectedPhotos.size,
onBack = { state.navigateTo(Screen.CONNECT) }, onBack = { state.navigateTo(Screen.CONNECT) },
onSelectAll = { state.selectAllVisible() },
onSelectNone = { state.selectNone() }, onSelectNone = { state.selectNone() },
onImport = { state.prepareImport() }, onImport = { state.prepareImport() },
) )
// ── HEIC warning ─────────────────────────────────────────────────────
if (state.shootingInHeic && !config.heicWarningDismissed) { if (state.shootingInHeic && !config.heicWarningDismissed) {
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() }) HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
} }
// ── Body ─────────────────────────────────────────────────────────────
when (val pl = photoList) { when (val pl = photoList) {
is PhotoListState.Idle, is PhotoListState.Loading -> { is PhotoListState.Idle, is PhotoListState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -51,43 +64,79 @@ 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 listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Map folder name → first item index in the lazy list
val folderScrollIndex = remember(folderSections) {
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
}
folder.name to startIdx
}
}
// Scroll spy: which folder is currently at the top of the visible area
val activeFolder by 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 panel — fixed 200dp width // Left: folder jump list
FolderPanel( FolderJumpList(
folders = pl.folders, folders = pl.folders,
selectedFolder = selectedFolder, activeFolder = activeFolder,
selectedPhotos = selectedPhotos, selectedPhotos = selectedPhotos,
onSelectFolder = { state.selectFolder(it) }, onJump = { folderName ->
modifier = Modifier.width(200.dp).fillMaxHeight(), val idx = folderScrollIndex[folderName] ?: return@FolderJumpList
scope.launch { listState.animateScrollToItem(idx) }
},
modifier = Modifier.width(190.dp).fillMaxHeight(),
) )
VerticalDivider() VerticalDivider()
// Right: day-grouped photo grid // Right: unified continuous scroll
Column(modifier = Modifier.weight(1f).fillMaxHeight()) { UnifiedPhotoScroll(
// Folder subtitle bar folderSections = folderSections,
FolderSubtitleBar(
folderName = selectedFolder ?: "All Camera Photos",
photoCount = visiblePhotos.size,
selectedCount = selectedPhotos.size,
)
DayGroupedGrid(
photos = visiblePhotos,
selectedPhotos = selectedPhotos, selectedPhotos = selectedPhotos,
device = device, device = device,
adbBin = config.adbPath ?: "adb", adbBin = config.adbPath ?: "adb",
listState = listState,
onTogglePhoto = { state.togglePhotoSelection(it) }, onTogglePhoto = { state.togglePhotoSelection(it) },
onSelectDay = { _, dayPhotos -> onSelectAllFolder = { folder ->
// Toggle: if all selected → deselect, else select all folder.forEach { p ->
val allSelected = dayPhotos.all { it in selectedPhotos } if (p !in selectedPhotos) state.togglePhotoSelection(p)
dayPhotos.forEach { photo ->
val inSet = photo in selectedPhotos
if (allSelected && inSet) state.togglePhotoSelection(photo)
else if (!allSelected && !inSet) state.togglePhotoSelection(photo)
} }
}, },
) 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),
)
} }
} }
is PhotoListState.Error -> { is PhotoListState.Error -> {
@@ -104,13 +153,294 @@ fun BrowseScreen(state: AppState) {
} }
} }
// ─── Data helpers ──────────────────────────────────────────────────────────────
private data class FolderSection(
val folder: FolderInfo,
val byDay: List<Pair<java.time.LocalDate, List<PhonePhoto>>>,
val allPhotos: List<PhonePhoto>,
)
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
val zoneId = java.time.ZoneId.systemDefault()
return pl.folders.map { folder ->
val photos = pl.all
.filter { it.sourceFolder == folder.name }
.sortedByDescending { it.dateTaken }
val byDay = photos
.groupBy { it.dateTaken.atZone(zoneId).toLocalDate() }
.entries
.sortedByDescending { it.key }
.map { it.key to it.value.sortedByDescending { p -> p.dateTaken } }
FolderSection(folder, byDay, photos)
}
}
// ─── Left jump list ───────────────────────────────────────────────────────────
@Composable
private fun FolderJumpList(
folders: List<FolderInfo>,
activeFolder: String?,
selectedPhotos: Set<PhonePhoto>,
onJump: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(tonalElevation = 1.dp, modifier = modifier) {
androidx.compose.foundation.lazy.LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
val (cameraRoll, other) = folders.partition { it.isCameraRoll }
if (cameraRoll.isNotEmpty()) {
item {
Text(
"CAMERA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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) },
)
}
}
if (other.isNotEmpty()) {
item {
Text(
"OTHER",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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) },
)
}
}
}
}
}
@Composable
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,
)
.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,
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,
)
}
if (selectedCount > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text("$selectedCount", style = MaterialTheme.typography.labelSmall)
}
}
}
}
// ─── Right unified scroll ─────────────────────────────────────────────────────
@Composable
private fun UnifiedPhotoScroll(
folderSections: List<FolderSection>,
selectedPhotos: Set<PhonePhoto>,
device: com.bolenpad.photophetch.model.DeviceInfo?,
adbBin: String,
listState: LazyListState,
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
onSelectDay: (List<PhonePhoto>) -> Unit,
modifier: Modifier = Modifier,
) {
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 ─────────────────────────────────────────────────
section.byDay.forEach { (day, dayPhotos) ->
val daySelected = dayPhotos.count { it in selectedPhotos }
item(key = "day-${section.folder.name}-$day") {
DaySubHeader(
day = day,
total = dayPhotos.size,
selected = daySelected,
onSelectDay = { onSelectDay(dayPhotos) },
)
}
val rows = dayPhotos.chunked(4)
items(
count = rows.size,
key = { idx -> "row-${section.folder.name}-$day-$idx" },
) { rowIdx ->
val rowPhotos = rows[rowIdx]
Row(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
rowPhotos.forEach { photo ->
PhotoThumb(
photo = photo,
isSelected = photo in selectedPhotos,
device = device,
adbBin = adbBin,
onToggle = { onTogglePhoto(photo) },
modifier = Modifier.weight(1f),
)
}
repeat(4 - rowPhotos.size) { Spacer(Modifier.weight(1f)) }
}
Spacer(Modifier.height(3.dp))
}
}
}
}
}
@Composable
private fun FolderDividerHeader(
name: String,
total: Int,
selected: Int,
allSelected: Boolean,
onSelectAll: () -> Unit,
onDeselectAll: () -> Unit,
) {
Column {
HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(top = 8.dp))
Row(
modifier = Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
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,
)
}
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) }
if (selected > 0) {
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 ──────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun BrowseTopBar( private fun BrowseTopBar(
deviceName: String, deviceName: String,
selectedCount: Int, selectedCount: Int,
onBack: () -> Unit, onBack: () -> Unit,
onSelectAll: () -> Unit,
onSelectNone: () -> Unit, onSelectNone: () -> Unit,
onImport: () -> Unit, onImport: () -> Unit,
) { ) {
@@ -122,8 +452,9 @@ private fun BrowseTopBar(
} }
}, },
actions = { actions = {
TextButton(onClick = onSelectAll) { Text("All") } if (selectedCount > 0) {
TextButton(onClick = onSelectNone) { Text("None") } TextButton(onClick = onSelectNone) { Text("Clear") }
}
Button( Button(
onClick = onImport, onClick = onImport,
enabled = selectedCount > 0, enabled = selectedCount > 0,
@@ -135,33 +466,6 @@ private fun BrowseTopBar(
) )
} }
@Composable
private fun FolderSubtitleBar(
folderName: String,
photoCount: Int,
selectedCount: Int,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
folderName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
if (selectedCount > 0) "$selectedCount / $photoCount selected"
else "$photoCount photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable @Composable
private fun HeicWarningBanner(onDismiss: () -> Unit) { private fun HeicWarningBanner(onDismiss: () -> Unit) {
Card( Card(
@@ -178,7 +482,7 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
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 during import. To avoid this: 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,
) )
@@ -187,3 +491,86 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
} }
} }
} }
// ─── Photo thumbnail cell ─────────────────────────────────────────────────────
@Composable
private fun PhotoThumb(
photo: PhonePhoto,
isSelected: Boolean,
device: DeviceInfo?,
adbBin: String,
onToggle: () -> Unit,
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)
}
}
}
// 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

@@ -1,244 +0,0 @@
package com.bolenpad.photophetch.ui
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.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 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.withContext
import org.jetbrains.skia.Image
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
private val dayHeaderFmt = DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy")
@Composable
fun DayGroupedGrid(
photos: List<PhonePhoto>,
selectedPhotos: Set<PhonePhoto>,
device: DeviceInfo?,
adbBin: String,
onTogglePhoto: (PhonePhoto) -> Unit,
onSelectDay: (LocalDate, List<PhonePhoto>) -> Unit,
modifier: Modifier = Modifier,
) {
if (photos.isEmpty()) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No photos in this folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
return
}
// Group by date descending
val zoneId = ZoneId.systemDefault()
val byDay: List<Pair<LocalDate, List<PhonePhoto>>> = photos
.groupBy { it.dateTaken.atZone(zoneId).toLocalDate() }
.entries
.sortedByDescending { it.key }
.map { it.key to it.value.sortedByDescending { p -> p.dateTaken } }
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp),
) {
byDay.forEach { (day, dayPhotos) ->
val daySelected = dayPhotos.count { it in selectedPhotos }
item(key = "header-$day") {
DayHeader(
day = day,
total = dayPhotos.size,
selected = daySelected,
onSelectAll = { onSelectDay(day, dayPhotos) },
)
}
// Rows of 4 cells
val rows = dayPhotos.chunked(4)
items(rows, key = { "row-$day-${it.first().devicePath}" }) { rowPhotos ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
rowPhotos.forEach { photo ->
PhotoThumb(
photo = photo,
isSelected = photo in selectedPhotos,
device = device,
adbBin = adbBin,
onToggle = { onTogglePhoto(photo) },
modifier = Modifier.weight(1f),
)
}
// Fill remaining slots in last row
repeat(4 - rowPhotos.size) {
Spacer(Modifier.weight(1f))
}
}
Spacer(Modifier.height(4.dp))
}
}
}
}
@Composable
private fun DayHeader(
day: LocalDate,
total: Int,
selected: Int,
onSelectAll: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 8.dp, top = 16.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
dayHeaderFmt.format(day),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
if (selected > 0) {
Text(
"$selected / $total selected",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
"$total photos",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(
onClick = onSelectAll,
contentPadding = PaddingValues(horizontal = 8.dp),
) {
Text(
if (selected == total) "Deselect day" else "Select day",
style = MaterialTheme.typography.labelMedium,
)
}
}
}
@Composable
private fun PhotoThumb(
photo: PhonePhoto,
isSelected: Boolean,
device: DeviceInfo?,
adbBin: String,
onToggle: () -> Unit,
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 = Image.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) {
androidx.compose.foundation.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)
}
}
}
// Selection indicator top-right
Icon(
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.primary
else androidx.compose.ui.graphics.Color.White.copy(alpha = 0.8f),
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(22.dp),
)
// Dark scrim at bottom when selected
if (isSelected) {
Box(
Modifier.fillMaxWidth().height(24.dp).align(Alignment.BottomCenter)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.3f))
)
}
}
}

View File

@@ -1,142 +0,0 @@
package com.bolenpad.photophetch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import java.time.format.DateTimeFormatter
private val monthFmt = DateTimeFormatter.ofPattern("MMM d")
@Composable
fun FolderPanel(
folders: List<FolderInfo>,
selectedFolder: String?,
selectedPhotos: Set<com.bolenpad.photophetch.model.PhonePhoto>,
onSelectFolder: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
tonalElevation = 1.dp,
modifier = modifier,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
item {
FolderRow(
name = "All Photos",
subtitle = "${folders.sumOf { it.photoCount }} total",
isCameraRoll = true,
isSelected = selectedFolder == null,
selectedCount = if (selectedFolder == null) selectedPhotos.size else 0,
onClick = { onSelectFolder(null) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp))
}
val (cameraRoll, other) = folders.partition { it.isCameraRoll }
if (cameraRoll.isNotEmpty()) {
item {
Text(
"CAMERA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
)
}
items(cameraRoll, key = { it.name }) { folder ->
FolderRow(
name = folder.name,
subtitle = "${folder.photoCount} · ${monthFmt.format(folder.earliestDate)}${monthFmt.format(folder.latestDate)}",
isCameraRoll = true,
isSelected = selectedFolder == folder.name,
selectedCount = if (selectedFolder == folder.name) selectedPhotos.size else 0,
onClick = { onSelectFolder(folder.name) },
)
}
}
if (other.isNotEmpty()) {
item {
Text(
"OTHER",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
)
}
items(other, key = { it.name }) { folder ->
FolderRow(
name = folder.name,
subtitle = "${folder.photoCount} · ${monthFmt.format(folder.earliestDate)}${monthFmt.format(folder.latestDate)}",
isCameraRoll = false,
isSelected = selectedFolder == folder.name,
selectedCount = if (selectedFolder == folder.name) selectedPhotos.size else 0,
onClick = { onSelectFolder(folder.name) },
)
}
}
}
}
}
@Composable
private fun FolderRow(
name: String,
subtitle: String,
isCameraRoll: Boolean,
isSelected: Boolean,
selectedCount: Int,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.background(
if (isSelected) MaterialTheme.colorScheme.secondaryContainer
else MaterialTheme.colorScheme.surface
)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
if (isCameraRoll) Icons.Default.FolderSpecial else Icons.Default.Folder,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.secondary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text(
name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
)
Text(
subtitle,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (selectedCount > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text("$selectedCount", style = MaterialTheme.typography.labelSmall)
}
}
}
}