diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index b96b2e9..a131b3c 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -38,6 +38,8 @@ import org.jetbrains.skia.Image as SkiaImage import java.time.LocalDate import java.time.format.DateTimeFormatter +enum class ViewMode { GROUPED, DENSE } + @Composable fun BrowseScreen(state: AppState) { val photoList by state.photoList.collectAsState() @@ -47,15 +49,18 @@ fun BrowseScreen(state: AppState) { Column(modifier = Modifier.fillMaxSize()) { var columnCount by remember { mutableStateOf(4) } + var viewMode by remember { mutableStateOf(ViewMode.GROUPED) } BrowseTopBar( deviceName = device?.displayName ?: "Browse Photos", selectedCount = selectedPhotos.size, columnCount = columnCount, + viewMode = viewMode, onBack = { state.navigateTo(Screen.CONNECT) }, onSelectNone = { state.selectNone() }, onImport = { state.prepareImport() }, onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) }, + onToggleView = { viewMode = if (viewMode == ViewMode.GROUPED) ViewMode.DENSE else ViewMode.GROUPED }, ) if (state.shootingInHeic && !config.heicWarningDismissed) { @@ -118,35 +123,46 @@ fun BrowseScreen(state: AppState) { VerticalDivider() - // Right: unified continuous scroll - UnifiedPhotoScroll( - folderSections = folderSections, - selectedPhotos = selectedPhotos, - device = device, - adbBin = config.adbPath ?: "adb", - listState = listState, - columnCount = columnCount, - onTogglePhoto = { state.togglePhotoSelection(it) }, - onSelectAllFolder = { folder -> - folder.forEach { p -> - if (p !in selectedPhotos) state.togglePhotoSelection(p) - } - }, - onDeselectAllFolder = { folder -> - folder.forEach { p -> - if (p in selectedPhotos) state.togglePhotoSelection(p) - } - }, - onSelectDay = { dayPhotos -> - val allSelected = dayPhotos.all { it in selectedPhotos } - dayPhotos.forEach { p -> - val inSet = p in selectedPhotos - if (allSelected && inSet) state.togglePhotoSelection(p) - else if (!allSelected && !inSet) state.togglePhotoSelection(p) - } - }, - modifier = Modifier.weight(1f), - ) + // Right: swap between grouped and dense view + when (viewMode) { + ViewMode.GROUPED -> UnifiedPhotoScroll( + folderSections = folderSections, + selectedPhotos = selectedPhotos, + device = device, + adbBin = config.adbPath ?: "adb", + listState = listState, + columnCount = columnCount, + onTogglePhoto = { state.togglePhotoSelection(it) }, + onSelectAllFolder = { folder -> + folder.forEach { p -> + if (p !in selectedPhotos) state.togglePhotoSelection(p) + } + }, + onDeselectAllFolder = { folder -> + folder.forEach { p -> + if (p in selectedPhotos) state.togglePhotoSelection(p) + } + }, + onSelectDay = { dayPhotos -> + val allSelected = dayPhotos.all { it in selectedPhotos } + dayPhotos.forEach { p -> + val inSet = p in selectedPhotos + if (allSelected && inSet) state.togglePhotoSelection(p) + else if (!allSelected && !inSet) state.togglePhotoSelection(p) + } + }, + modifier = Modifier.weight(1f), + ) + ViewMode.DENSE -> DensePhotoGrid( + photos = pl.all.sortedByDescending { it.dateTaken }, + selectedPhotos = selectedPhotos, + device = device, + adbBin = config.adbPath ?: "adb", + columnCount = columnCount, + onTogglePhoto = { state.togglePhotoSelection(it) }, + modifier = Modifier.weight(1f), + ) + } } } is PhotoListState.Error -> { @@ -460,10 +476,12 @@ private fun BrowseTopBar( deviceName: String, selectedCount: Int, columnCount: Int, + viewMode: ViewMode, onBack: () -> Unit, onSelectNone: () -> Unit, onImport: () -> Unit, onColumnChange: (Int) -> Unit, + onToggleView: () -> Unit, ) { TopAppBar( title = { Text(deviceName) }, @@ -473,6 +491,14 @@ private fun BrowseTopBar( } }, actions = { + // View mode toggle + FilterChip( + selected = viewMode == ViewMode.DENSE, + onClick = onToggleView, + label = { Text(if (viewMode == ViewMode.DENSE) "Dense" else "Grouped", + style = MaterialTheme.typography.labelMedium) }, + modifier = Modifier.padding(end = 4.dp), + ) // Grid size controls IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) { Text("−", style = MaterialTheme.typography.titleMedium) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt new file mode 100644 index 0000000..c1032b4 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DensePhotoGrid.kt @@ -0,0 +1,286 @@ +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.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.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bolenpad.photophetch.model.DeviceInfo +import com.bolenpad.photophetch.model.MediaType +import com.bolenpad.photophetch.model.PhonePhoto +import com.bolenpad.photophetch.util.ThumbnailCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image as SkiaImage +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Dense grid view with a date scrubber strip on the left edge. + * + * Photos flow as a flat grid with no day-header rows breaking it up. + * A narrow scrubber column shows date labels at day boundaries. + * Clicking a date label on the scrubber jumps to that day in the grid. + * + * Layout: + * [24dp scrubber] [3dp gap] [photo grid fills remaining width] + */ +@Composable +fun DensePhotoGrid( + photos: List, + selectedPhotos: Set, + device: DeviceInfo?, + adbBin: String, + columnCount: Int, + onTogglePhoto: (PhonePhoto) -> Unit, + modifier: Modifier = Modifier, +) { + if (photos.isEmpty()) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No photos", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + return + } + + val zoneId = ZoneId.systemDefault() + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + // Chunk into rows + val rows: List> = photos.chunked(columnCount) + + // For each row index, compute the date of its first photo + val rowDates: List = rows.map { + it.first().dateTaken.atZone(zoneId).toLocalDate() + } + + // Day boundary rows: the first row index where a new date appears + // Maps LocalDate → row index + val dayBoundaries: Map = buildMap { + var lastDate: LocalDate? = null + rowDates.forEachIndexed { idx, date -> + if (date != lastDate) { + put(date, idx) + lastDate = date + } + } + } + + // Ordered list of (date, rowIndex) sorted newest first + val scrubberDates: List> = dayBoundaries.entries + .sortedByDescending { it.key } + .map { it.key to it.value } + + val monthFmt = remember { DateTimeFormatter.ofPattern("MMM") } + val dayFmt = remember { DateTimeFormatter.ofPattern("d") } + + Row(modifier = modifier.fillMaxSize()) { + + // ── Date scrubber strip ─────────────────────────────────────────── + // We overlay date labels proportionally over the full height. + // Using a Box with proportional placement rather than a LazyColumn + // so labels stay anchored to their visual position even when scrolling. + + Box( + modifier = Modifier + .width(28.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + // Render scrubber labels using scroll-proportional positioning + val totalRows = rows.size + if (totalRows > 0) { + ScrubberOverlay( + scrubberDates = scrubberDates, + totalRows = totalRows, + onJump = { rowIdx -> scope.launch { listState.animateScrollToItem(rowIdx) } }, + monthFmt = monthFmt, + dayFmt = dayFmt, + ) + } + } + + Spacer(Modifier.width(2.dp)) + + // ── Photo grid ──────────────────────────────────────────────────── + LazyColumn( + state = listState, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 32.dp), + ) { + items( + count = rows.size, + key = { idx -> "dense-row-${rows[idx].first().devicePath}" }, + ) { rowIdx -> + val rowPhotos = rows[rowIdx] + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + rowPhotos.forEach { photo -> + DenseThumb( + photo = photo, + isSelected = photo in selectedPhotos, + device = device, + adbBin = adbBin, + onToggle = { onTogglePhoto(photo) }, + modifier = Modifier.weight(1f), + ) + } + repeat(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) } + } + Spacer(Modifier.height(2.dp)) + } + } + } +} + +/** + * Scrubber overlay: proportionally-positioned date labels. + * Uses a custom layout approach with fillMaxHeight + proportional offset. + */ +@Composable +private fun ScrubberOverlay( + scrubberDates: List>, + totalRows: Int, + onJump: (Int) -> Unit, + monthFmt: DateTimeFormatter, + dayFmt: DateTimeFormatter, +) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val totalHeight = maxHeight + + // Group by month — only show month label at first day of each month + var lastMonth: Int? = null + + scrubberDates.forEach { (date, rowIdx) -> + val fraction = rowIdx.toFloat() / totalRows.toFloat() + val yOffset = totalHeight * fraction + + val showMonth = date.monthValue != lastMonth + if (showMonth) lastMonth = date.monthValue + + Column( + modifier = Modifier + .offset(y = yOffset) + .fillMaxWidth() + .clickable { onJump(rowIdx) } + .padding(vertical = 1.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (showMonth) { + Text( + monthFmt.format(date), + fontSize = 8.sp, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + lineHeight = 9.sp, + ) + } + Text( + dayFmt.format(date), + fontSize = 9.sp, + fontWeight = if (showMonth) FontWeight.Bold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = 10.sp, + ) + } + } + } +} + +@Composable +private fun DenseThumb( + 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(2.dp, MaterialTheme.colorScheme.primary) else Modifier), + ) { + if (thumbnail != null) { + Image( + bitmap = thumbnail!!, + contentDescription = null, + 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(20.dp), + ) + } + } + + // Video badge + if (photo.mediaType == MediaType.VIDEO) { + Badge( + containerColor = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.align(Alignment.TopStart).padding(2.dp), + ) { Text("▶", style = MaterialTheme.typography.labelSmall) } + } + + // Selection indicator + if (isSelected) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopEnd).padding(2.dp).size(16.dp), + ) + } + } +}