diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index e421cc1..b78ae17 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -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>>, + val allPhotos: List, +) + +private fun buildFolderSections(pl: PhotoListState.Loaded): List { + 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, + activeFolder: String?, + selectedPhotos: Set, + 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, + selectedPhotos: Set, + device: com.bolenpad.photophetch.model.DeviceInfo?, + adbBin: String, + listState: LazyListState, + onTogglePhoto: (PhonePhoto) -> Unit, + onSelectAllFolder: (List) -> Unit, + onDeselectAllFolder: (List) -> Unit, + onSelectDay: (List) -> 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(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), + ) + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt deleted file mode 100644 index 3b3963e..0000000 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt +++ /dev/null @@ -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, - selectedPhotos: Set, - device: DeviceInfo?, - adbBin: String, - onTogglePhoto: (PhonePhoto) -> Unit, - onSelectDay: (LocalDate, List) -> 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>> = 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(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)) - ) - } - } -} diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/FolderPanel.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/FolderPanel.kt deleted file mode 100644 index 7c3c874..0000000 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/FolderPanel.kt +++ /dev/null @@ -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, - selectedFolder: String?, - selectedPhotos: Set, - 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) - } - } - } -}