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:
@@ -1,45 +1,58 @@
|
||||
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.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 com.bolenpad.photophetch.model.DeviceInfo
|
||||
import com.bolenpad.photophetch.model.MediaType
|
||||
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
|
||||
fun BrowseScreen(state: AppState) {
|
||||
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 device by state.selectedDevice.collectAsState()
|
||||
val config by state.config.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
// ── Top bar ──────────────────────────────────────────────────────────
|
||||
BrowseTopBar(
|
||||
deviceName = device?.displayName ?: "Browse Photos",
|
||||
selectedCount = selectedPhotos.size,
|
||||
onBack = { state.navigateTo(Screen.CONNECT) },
|
||||
onSelectAll = { state.selectAllVisible() },
|
||||
onSelectNone = { state.selectNone() },
|
||||
onImport = { state.prepareImport() },
|
||||
)
|
||||
|
||||
// ── HEIC warning ─────────────────────────────────────────────────────
|
||||
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
||||
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
|
||||
}
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────────────────
|
||||
when (val pl = photoList) {
|
||||
is PhotoListState.Idle, is PhotoListState.Loading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -51,43 +64,79 @@ fun BrowseScreen(state: AppState) {
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
// Left: folder panel — fixed 200dp width
|
||||
FolderPanel(
|
||||
// Left: folder jump list
|
||||
FolderJumpList(
|
||||
folders = pl.folders,
|
||||
selectedFolder = selectedFolder,
|
||||
activeFolder = activeFolder,
|
||||
selectedPhotos = selectedPhotos,
|
||||
onSelectFolder = { state.selectFolder(it) },
|
||||
modifier = Modifier.width(200.dp).fillMaxHeight(),
|
||||
onJump = { folderName ->
|
||||
val idx = folderScrollIndex[folderName] ?: return@FolderJumpList
|
||||
scope.launch { listState.animateScrollToItem(idx) }
|
||||
},
|
||||
modifier = Modifier.width(190.dp).fillMaxHeight(),
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Right: day-grouped photo grid
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Folder subtitle bar
|
||||
FolderSubtitleBar(
|
||||
folderName = selectedFolder ?: "All Camera Photos",
|
||||
photoCount = visiblePhotos.size,
|
||||
selectedCount = selectedPhotos.size,
|
||||
)
|
||||
DayGroupedGrid(
|
||||
photos = visiblePhotos,
|
||||
selectedPhotos = selectedPhotos,
|
||||
device = device,
|
||||
adbBin = config.adbPath ?: "adb",
|
||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||
onSelectDay = { _, dayPhotos ->
|
||||
// Toggle: if all selected → deselect, else select all
|
||||
val allSelected = dayPhotos.all { it in selectedPhotos }
|
||||
dayPhotos.forEach { photo ->
|
||||
val inSet = photo in selectedPhotos
|
||||
if (allSelected && inSet) state.togglePhotoSelection(photo)
|
||||
else if (!allSelected && !inSet) state.togglePhotoSelection(photo)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
// Right: unified continuous scroll
|
||||
UnifiedPhotoScroll(
|
||||
folderSections = folderSections,
|
||||
selectedPhotos = selectedPhotos,
|
||||
device = device,
|
||||
adbBin = config.adbPath ?: "adb",
|
||||
listState = listState,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
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)
|
||||
@Composable
|
||||
private fun BrowseTopBar(
|
||||
deviceName: String,
|
||||
selectedCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onSelectNone: () -> Unit,
|
||||
onImport: () -> Unit,
|
||||
) {
|
||||
@@ -122,8 +452,9 @@ private fun BrowseTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onSelectAll) { Text("All") }
|
||||
TextButton(onClick = onSelectNone) { Text("None") }
|
||||
if (selectedCount > 0) {
|
||||
TextButton(onClick = onSelectNone) { Text("Clear") }
|
||||
}
|
||||
Button(
|
||||
onClick = onImport,
|
||||
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
|
||||
private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
||||
Card(
|
||||
@@ -178,7 +482,7 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
||||
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
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,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user