From 0e6f0d9c7e65fa3b0a546dd79abd623249376627 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 05:05:21 +0000 Subject: [PATCH] Rewrite BrowseScreen: folder panel + day-grouped photo grid New two-panel layout: - Left (200dp): FolderPanel lists all DCIM subfolders with photo count and date range. Camera-roll folders highlighted separately from other folders (Screenshots, Video Editor, etc.). Selection badge per folder. - Right: DayGroupedGrid groups photos by date with day headers. Each day header shows count and a 'Select day' / 'Deselect day' toggle. Photos displayed in rows of 4 with thumbnails. PhonePhoto now carries sourceFolder name. AndroidConnector tags each photo with its DCIM subfolder. AppState manages selectedFolder and visiblePhotos StateFlows. ExifFilter.isCameraRollFolder() classifies folders as camera-roll vs other. Preserves all existing functionality: thumbnails, import flow, HEIC warning, selection. --- .../photophetch/device/AndroidConnector.kt | 8 +- .../bolenpad/photophetch/model/PhonePhoto.kt | 2 + .../com/bolenpad/photophetch/ui/AppState.kt | 186 ++++++----- .../bolenpad/photophetch/ui/BrowseScreen.kt | 289 +++++------------- .../bolenpad/photophetch/ui/DayGroupedGrid.kt | 244 +++++++++++++++ .../bolenpad/photophetch/ui/FolderPanel.kt | 142 +++++++++ .../bolenpad/photophetch/util/ExifFilter.kt | 10 + 7 files changed, 567 insertions(+), 314 deletions(-) create mode 100644 src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt create mode 100644 src/main/kotlin/com/bolenpad/photophetch/ui/FolderPanel.kt diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt index 5f59d7a..870fe11 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/AndroidConnector.kt @@ -90,14 +90,13 @@ class AndroidConnector( val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null") if (check.contains("No such file") || check.isBlank()) continue - // Single ls -la call for the whole directory — one ADB round trip per folder - // instead of one per file. Format varies slightly by Android version but is - // consistently: permissions links user group SIZE DATE TIME FILENAME + // Derive the human-readable folder name from the path + val folderName = dir.substringAfterLast("/") + val lsOutput = runAdb(serial, "shell", "ls", "-la", dir) for (line in lsOutput.lines()) { val trimmed = line.trim() - // Skip total line, blank lines, directories if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue val parts = trimmed.split(Regex("\\s+"), limit = 8) @@ -120,6 +119,7 @@ class AndroidConnector( exifModel = device.displayName, mediaType = if (ext in setOf("mp4", "mov", "3gp", "avi")) MediaType.VIDEO else MediaType.PHOTO, + sourceFolder = folderName, ) ) } diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt b/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt index 7c16004..26cdd1e 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/model/PhonePhoto.kt @@ -20,6 +20,8 @@ data class PhonePhoto( val exifModel: String?, /** Media type */ val mediaType: MediaType, + /** Source folder name on the device (e.g. "Camera", "OpenCamera", "PeakLens") */ + val sourceFolder: String = "Camera", /** True if this is the still component of an Apple Live Photo (paired with a .MOV) */ val isLivePhotoStill: Boolean = false, /** True if this is the motion component of an Apple Live Photo (.MOV companion) */ diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index 5ef89bd..0247930 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -5,7 +5,6 @@ import com.bolenpad.photophetch.device.DeviceConnector import com.bolenpad.photophetch.device.DeviceException import com.bolenpad.photophetch.device.IosConnector import com.bolenpad.photophetch.model.AppConfig -import com.bolenpad.photophetch.model.CopyResult import com.bolenpad.photophetch.model.DeviceInfo import com.bolenpad.photophetch.model.DeviceType import com.bolenpad.photophetch.model.ImportJob @@ -31,15 +30,9 @@ import java.time.LocalDate /** Top-level navigation destinations */ enum class Screen { - CONNECT, - BROWSE, - IMPORT_CONFIG, - IMPORTING, - VERIFY, - SETTINGS, + CONNECT, BROWSE, IMPORT_CONFIG, IMPORTING, VERIFY, SETTINGS, } -/** Sealed state for the device scan */ sealed class DeviceScanState { object Idle : DeviceScanState() object Scanning : DeviceScanState() @@ -47,36 +40,40 @@ sealed class DeviceScanState { data class Error(val message: String) : DeviceScanState() } -/** Sealed state for the photo listing */ sealed class PhotoListState { object Idle : PhotoListState() object Loading : PhotoListState() data class Loaded( + /** All photos from the device, unfiltered */ val all: List, - val filtered: List, - val shootingInHeic: Boolean, + /** Folders derived from all photos, sorted: camera-roll folders first then alphabetical */ + val folders: List, ) : PhotoListState() data class Error(val message: String) : PhotoListState() } -/** Progress during active import */ +/** Summary of a DCIM subfolder */ +data class FolderInfo( + val name: String, + val photoCount: Int, + val earliestDate: LocalDate, + val latestDate: LocalDate, + /** True if this folder passes the camera-roll heuristic (not screenshots etc.) */ + val isCameraRoll: Boolean, +) + data class ImportProgress( val completed: Int, val total: Int, val currentFile: String, ) -/** - * Application state container — the single source of truth for all UI state. - */ -class AppState( - private val configStore: ConfigStore, -) { +class AppState(private val configStore: ConfigStore) { private val log = LoggerFactory.getLogger(AppState::class.java) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) // ------------------------------------------------------------------------- - // Persisted config + // Config // ------------------------------------------------------------------------- private val _config = MutableStateFlow(configStore.load()) val config: StateFlow = _config.asStateFlow() @@ -86,7 +83,6 @@ class AppState( // ------------------------------------------------------------------------- private val _currentScreen = MutableStateFlow(Screen.CONNECT) val currentScreen: StateFlow = _currentScreen.asStateFlow() - fun navigateTo(screen: Screen) { _currentScreen.value = screen } // ------------------------------------------------------------------------- @@ -122,42 +118,45 @@ class AppState( } // ------------------------------------------------------------------------- - // Photo listing + // Photo listing + folder state // ------------------------------------------------------------------------- private val _photoList = MutableStateFlow(PhotoListState.Idle) val photoList: StateFlow = _photoList.asStateFlow() + /** Currently active folder name — null means "all camera-roll folders" */ + private val _selectedFolder = MutableStateFlow(null) + val selectedFolder: StateFlow = _selectedFolder.asStateFlow() + + /** Photos currently visible in the grid (filtered by folder) */ + private val _visiblePhotos = MutableStateFlow>(emptyList()) + val visiblePhotos: StateFlow> = _visiblePhotos.asStateFlow() + + /** Selected photo set */ private val _selectedPhotos = MutableStateFlow>(emptySet()) val selectedPhotos: StateFlow> = _selectedPhotos.asStateFlow() - private val _dateFilter = MutableStateFlow>(null to null) - val dateFilter: StateFlow> = _dateFilter.asStateFlow() - - /** When true, bypass ExifFilter and show all photos from all DCIM folders */ - private val _showAll = MutableStateFlow(false) - val showAll: StateFlow = _showAll.asStateFlow() + /** Whether HEIC warning should be shown */ + val shootingInHeic: Boolean + get() = (_photoList.value as? PhotoListState.Loaded) + ?.all?.let { ExifFilter.isShootingInHeic(it) } ?: false fun loadPhotos(device: DeviceInfo) { scope.launch { _photoList.value = PhotoListState.Loading + _selectedFolder.value = null + _selectedPhotos.value = emptySet() try { val connector = connectorFor(device) val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) } - val (from, to) = _dateFilter.value - val filtered = if (_showAll.value) { - applyDateFilter(raw, from, to) - } else { - applyDateFilter( - ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion), - from, to, - ) - } - val shootingInHeic = ExifFilter.isShootingInHeic(filtered) - _photoList.value = PhotoListState.Loaded( - all = raw, - filtered = filtered, - shootingInHeic = shootingInHeic, - ) + + // Build folder summaries + val folders = buildFolderList(raw) + + _photoList.value = PhotoListState.Loaded(all = raw, folders = folders) + + // Default: select the first camera-roll folder + val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name + selectFolder(defaultFolder) } catch (e: Exception) { log.error("Photo listing failed", e) _photoList.value = PhotoListState.Error(e.message ?: "Unknown error") @@ -165,54 +164,62 @@ class AppState( } } - fun toggleShowAll() { - _showAll.value = !_showAll.value - val current = _photoList.value as? PhotoListState.Loaded ?: return - val (from, to) = _dateFilter.value - val filtered = if (_showAll.value) { - applyDateFilter(current.all, from, to) - } else { - applyDateFilter( - ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion), - from, to, - ) - } - _photoList.value = current.copy(filtered = filtered) + fun selectFolder(folderName: String?) { + _selectedFolder.value = folderName + _selectedPhotos.value = emptySet() + refreshVisiblePhotos() } - fun setDateFilter(from: LocalDate?, to: LocalDate?) { - _dateFilter.value = from to to - val current = _photoList.value as? PhotoListState.Loaded ?: return - val base = if (_showAll.value) current.all - else ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion) - _photoList.value = current.copy(filtered = applyDateFilter(base, from, to)) + private fun refreshVisiblePhotos() { + val loaded = _photoList.value as? PhotoListState.Loaded ?: return + val folder = _selectedFolder.value + _visiblePhotos.value = if (folder == null) { + // All camera-roll folders merged + ExifFilter.filter(loaded.all, _config.value.defaultStripLivePhotoMotion) + .sortedByDescending { it.dateTaken } + } else { + loaded.all.filter { it.sourceFolder == folder } + .sortedByDescending { it.dateTaken } + } + } + + private fun buildFolderList(photos: List): List { + val zoneId = java.time.ZoneId.systemDefault() + return photos + .groupBy { it.sourceFolder } + .map { (name, folderPhotos) -> + val dates = folderPhotos.map { + it.dateTaken.atZone(zoneId).toLocalDate() + } + FolderInfo( + name = name, + photoCount = folderPhotos.size, + earliestDate = dates.min(), + latestDate = dates.max(), + isCameraRoll = ExifFilter.isCameraRollFolder(name), + ) + } + // Camera-roll folders first, then alphabetical + .sortedWith(compareByDescending { it.isCameraRoll }.thenBy { it.name }) } fun togglePhotoSelection(photo: PhonePhoto) { - _selectedPhotos.update { current -> - if (photo in current) current - photo else current + photo - } + _selectedPhotos.update { if (photo in it) it - photo else it + photo } } - fun selectAll() { - val photos = (_photoList.value as? PhotoListState.Loaded)?.filtered ?: return - _selectedPhotos.value = photos.toSet() + fun selectAllVisible() { + _selectedPhotos.value = _visiblePhotos.value.toSet() } fun selectNone() { _selectedPhotos.value = emptySet() } - private fun applyDateFilter( - photos: List, - from: LocalDate?, - to: LocalDate?, - ): List { - if (from == null && to == null) return photos - return photos.filter { photo -> - val date = photo.dateTaken.atZone(java.time.ZoneId.systemDefault()).toLocalDate() - (from == null || !date.isBefore(from)) && (to == null || !date.isAfter(to)) - } + fun selectFolder_All() { + val loaded = _photoList.value as? PhotoListState.Loaded ?: return + val folder = _selectedFolder.value ?: return + val folderPhotos = loaded.all.filter { it.sourceFolder == folder }.toSet() + _selectedPhotos.update { it + folderPhotos } } // ------------------------------------------------------------------------- @@ -249,17 +256,15 @@ class AppState( .replace("{date}", today.toString()) .replace("{device}", StagingPath.deviceLabel(device)) - // Auto-increment if the folder already exists val folderName = if (cfg.stagingRoot.isNotBlank()) { StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName) - } else { - baseName - } + } else baseName + // Multi-folder selection → preserve structure by default + val multipleSourceFolders = _selectedPhotos.value.map { it.sourceFolder }.distinct().size > 1 _importConfig.update { it.copy( folderName = folderName, - // When showing all files, default to preserving folder structure - preserveFolderStructure = _showAll.value, + preserveFolderStructure = multipleSourceFolders, ) } navigateTo(Screen.IMPORT_CONFIG) } @@ -311,16 +316,11 @@ class AppState( fun deleteVerifiedFromDevice() { val device = _selectedDevice.value ?: return val result = _importResult.value ?: return - val toDelete = result.results.filter { it.verified }.map { it.photo } - scope.launch(Dispatchers.IO) { val connector = connectorFor(device) - toDelete.forEach { photo -> - try { - connector.deletePhoto(device, photo) - } catch (e: DeviceException) { - log.error("Delete failed for ${photo.filename}: ${e.message}") - } + result.results.filter { it.verified }.forEach { r -> + try { connector.deletePhoto(device, r.photo) } + catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") } } } } @@ -338,9 +338,7 @@ class AppState( saveConfig() } - fun saveConfig() { - configStore.save(_config.value) - } + fun saveConfig() { configStore.save(_config.value) } // ------------------------------------------------------------------------- // Helpers diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index 34135ef..e421cc1 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -1,61 +1,45 @@ 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.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items 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.DateRange -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.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 @Composable fun BrowseScreen(state: AppState) { val photoList by state.photoList.collectAsState() - val selected by state.selectedPhotos.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() - val dateFilter by state.dateFilter.collectAsState() - val showAll by state.showAll.collectAsState() Column(modifier = Modifier.fillMaxSize()) { - // Top bar + + // ── Top bar ────────────────────────────────────────────────────────── BrowseTopBar( - device = device, - selected = selected, - showAll = showAll, + deviceName = device?.displayName ?: "Browse Photos", + selectedCount = selectedPhotos.size, onBack = { state.navigateTo(Screen.CONNECT) }, - onSelectAll = { state.selectAll() }, + onSelectAll = { state.selectAllVisible() }, onSelectNone = { state.selectNone() }, - onToggleShowAll = { state.toggleShowAll() }, 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) { @@ -67,53 +51,42 @@ fun BrowseScreen(state: AppState) { } } is PhotoListState.Loaded -> { - // HEIC warning banner - if (pl.shootingInHeic && !config.heicWarningDismissed) { - HeicWarningBanner( - convertByDefault = config.defaultConvertHeicToJpeg, - onDismiss = { state.dismissHeicWarning() }, + Row(modifier = Modifier.fillMaxSize()) { + // Left: folder panel — fixed 200dp width + FolderPanel( + folders = pl.folders, + selectedFolder = selectedFolder, + selectedPhotos = selectedPhotos, + onSelectFolder = { state.selectFolder(it) }, + modifier = Modifier.width(200.dp).fillMaxHeight(), ) - } - // Date filter row - DateFilterRow( - from = dateFilter.first, - to = dateFilter.second, - onFilterChange = { from, to -> state.setDateFilter(from, to) }, - ) + VerticalDivider() - // Photo count summary - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - "${pl.filtered.size} photos • ${selected.size} selected", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (pl.filtered.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No photos match the current filter", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 110.dp), - modifier = Modifier.fillMaxSize().padding(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(pl.filtered, key = { it.devicePath }) { photo -> - PhotoCell( - photo = photo, - device = device, - adbBin = state.config.collectAsState().value.adbPath ?: "adb", - isSelected = photo in selected, - onToggle = { state.togglePhotoSelection(photo) }, - ) - } + // 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) + } + }, + ) } } } @@ -134,17 +107,15 @@ fun BrowseScreen(state: AppState) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun BrowseTopBar( - device: DeviceInfo?, - selected: Set, - showAll: Boolean, + deviceName: String, + selectedCount: Int, onBack: () -> Unit, onSelectAll: () -> Unit, onSelectNone: () -> Unit, - onToggleShowAll: () -> Unit, onImport: () -> Unit, ) { TopAppBar( - title = { Text(device?.displayName ?: "Browse Photos") }, + title = { Text(deviceName) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") @@ -153,158 +124,46 @@ private fun BrowseTopBar( actions = { TextButton(onClick = onSelectAll) { Text("All") } TextButton(onClick = onSelectNone) { Text("None") } - FilterChip( - selected = showAll, - onClick = onToggleShowAll, - label = { Text("Show all") }, - modifier = Modifier.padding(horizontal = 4.dp), - ) Button( onClick = onImport, - enabled = selected.isNotEmpty(), + enabled = selectedCount > 0, modifier = Modifier.padding(end = 8.dp), ) { - Text("Import ${if (selected.isNotEmpty()) "(${selected.size})" else ""}") + Text(if (selectedCount > 0) "Import ($selectedCount)" else "Import") } }, ) } @Composable -private fun PhotoCell( - photo: PhonePhoto, - device: DeviceInfo?, - adbBin: String, - isSelected: Boolean, - onToggle: () -> Unit, +private fun FolderSubtitleBar( + folderName: String, + photoCount: Int, + selectedCount: Int, ) { - val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate() - val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d")) - - // Load thumbnail asynchronously — limited concurrency to avoid overwhelming ADB - var thumbnail by remember(photo.devicePath) { mutableStateOf(null) } - var failed by remember(photo.devicePath) { mutableStateOf(false) } - LaunchedEffect(photo.devicePath) { - if (device?.adbSerial != null && !failed) { - val bytes = withContext(Dispatchers.IO) { - ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename) - } - if (bytes != null) { - runCatching { - thumbnail = Image.makeFromEncoded(bytes).toComposeImageBitmap() - }.onFailure { failed = true } - } else { - failed = true - } - } - } - - Box( - modifier = Modifier - .aspectRatio(1f) - .clickable(onClick = onToggle) - .background( - if (isSelected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant - ) - .then( - if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary) - else Modifier - ), - ) { - // Thumbnail image or placeholder - if (thumbnail != null) { - androidx.compose.foundation.Image( - bitmap = thumbnail!!, - contentDescription = photo.filename, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Icon( - Icons.Default.Image, - contentDescription = null, - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(32.dp), - ) - } - } - - // Overlay: badges top-left, date bottom-left, checkmark top-right - Column( - modifier = Modifier.fillMaxSize().padding(4.dp), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { - if (photo.isHeic) Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) } - if (photo.mediaType == MediaType.VIDEO) { - Badge(containerColor = MaterialTheme.colorScheme.tertiary) { - Text("VID", style = MaterialTheme.typography.labelSmall) - } - } - if (photo.isLivePhotoStill) { - Badge(containerColor = MaterialTheme.colorScheme.secondary) { - Text("LIVE", style = MaterialTheme.typography.labelSmall) - } - } - } - Text( - dateStr, - style = MaterialTheme.typography.labelSmall, - color = androidx.compose.ui.graphics.Color.White, - modifier = Modifier.background( - androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f), - shape = MaterialTheme.shapes.extraSmall, - ).padding(horizontal = 3.dp, vertical = 1.dp), - ) - } - - Icon( - if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, - contentDescription = if (isSelected) "Selected" else "Not selected", - tint = if (isSelected) MaterialTheme.colorScheme.primary - else androidx.compose.ui.graphics.Color.White, - modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(20.dp), - ) - } -} - -@Composable -private fun DateFilterRow( - from: LocalDate?, - to: LocalDate?, - onFilterChange: (from: LocalDate?, to: LocalDate?) -> Unit, -) { - // Simple quick-filter buttons: Last 7 days, Last 30 days, All - val today = LocalDate.now() Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Default.DateRange, contentDescription = null, modifier = Modifier.size(16.dp)) - Text("Filter:", style = MaterialTheme.typography.labelMedium) - FilterChip( - selected = from == null && to == null, - onClick = { onFilterChange(null, null) }, - label = { Text("All") }, + Text( + folderName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, ) - FilterChip( - selected = from == today.minusDays(7) && to == null, - onClick = { onFilterChange(today.minusDays(7), null) }, - label = { Text("Last 7 days") }, - ) - FilterChip( - selected = from == today.minusDays(30) && to == null, - onClick = { onFilterChange(today.minusDays(30), null) }, - label = { Text("Last 30 days") }, + Text( + if (selectedCount > 0) "$selectedCount / $photoCount selected" + else "$photoCount photos", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable -private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit) { +private fun HeicWarningBanner(onDismiss: () -> Unit) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), @@ -316,12 +175,10 @@ private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit) ) { Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp)) Column(modifier = Modifier.weight(1f)) { - Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodySmall) + Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.bodySmall) Text( - if (convertByDefault) - "Files will be converted to JPEG during import. To avoid this, go to Settings → Camera → Formats → Most Compatible on your iPhone." - else - "HEIC files will be kept as-is. PhotoPhile may not handle them on Linux. Consider Settings → Camera → Formats → Most Compatible.", + "Files will be converted to JPEG during import. To avoid this: Settings → Camera → Formats → Most Compatible.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer, ) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt new file mode 100644 index 0000000..3b3963e --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt @@ -0,0 +1,244 @@ +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 new file mode 100644 index 0000000..7c3c874 --- /dev/null +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/FolderPanel.kt @@ -0,0 +1,142 @@ +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) + } + } + } +} diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt index 10921b5..44aa9c4 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ExifFilter.kt @@ -90,6 +90,16 @@ object ExifFilter { return true } + /** + * Returns true if a folder name is likely a camera-roll source + * (i.e. not screenshots, received images, etc.) + */ + fun isCameraRollFolder(folderName: String): Boolean { + val lower = folderName.lowercase() + return excludedPathFragments.none { lower.contains(it) } && + screenshotFilePrefixes.none { lower.startsWith(it) } + } + /** * Returns photos that are HEIC files (and not Live Photo motion clips). * Used to determine whether to show the HEIC conversion warning.