From 3da4e464d29f0793768af09baaad09567e4d7f80 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 04:28:17 +0000 Subject: [PATCH] Add Show All mode with optional folder structure preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrowseScreen: Show All toggle chip in toolbar bypasses ExifFilter and shows every file from every DCIM subfolder - ImportScreen: Preserve folder structure toggle (auto-enabled in Show All mode) — copies files into Camera/, Screenshots/ etc subfolders under the staging destination instead of flat - AppState: _showAll StateFlow, toggleShowAll(), loadPhotos and setDateFilter respect the flag - Copier: extracts DCIM subfolder from devicePath and creates matching subdirectory in staging when preserveFolderStructure=true --- .../bolenpad/photophetch/model/ImportJob.kt | 7 +++ .../bolenpad/photophetch/transfer/Copier.kt | 14 ++++- .../com/bolenpad/photophetch/ui/AppState.kt | 54 ++++++++++++++----- .../bolenpad/photophetch/ui/BrowseScreen.kt | 11 ++++ .../bolenpad/photophetch/ui/ImportScreen.kt | 19 +++++++ 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt index 94b9313..e8d1e6d 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/model/ImportJob.kt @@ -19,6 +19,13 @@ data class ImportJob( val stripLivePhotoMotion: Boolean = true, /** Whether to delete photos from the phone after successful verification */ val deleteAfterVerify: Boolean = false, + /** + * Whether to preserve the device's subfolder structure under the staging folder. + * e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg + * _staging/2026-07-28_Samsung/Screenshots/ (excluded by filter normally) + * Used when importing in "show all" mode. + */ + val preserveFolderStructure: Boolean = false, ) /** diff --git a/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt index 56d3016..4d9cb56 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/transfer/Copier.kt @@ -63,7 +63,19 @@ class Copier( photosToImport.forEachIndexed { idx, photo -> onProgress(idx, photosToImport.size, photo.filename) - val result = copyOne(photo, stagingDir, job.convertHeicToJpeg) + // Determine destination directory — flat or preserve subfolder + val destDir = if (job.preserveFolderStructure) { + // devicePath is like /sdcard/DCIM/Camera/IMG_001.jpg + // Extract the subfolder name relative to DCIM root + val subFolder = photo.devicePath + .substringAfter("/DCIM/", "") + .substringBeforeLast("/", "") + .ifBlank { "Camera" } + stagingDir.resolve(subFolder).also { it.toFile().mkdirs() } + } else { + stagingDir + } + val result = copyOne(photo, destDir, job.convertHeicToJpeg) results.add(result) if (result.error != null) { log.error("Failed to copy ${photo.filename}: ${result.error}") diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index 1b72595..5ef89bd 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -68,7 +68,6 @@ data class ImportProgress( /** * Application state container — the single source of truth for all UI state. - * All mutations happen via public methods; UI observes via StateFlows. */ class AppState( private val configStore: ConfigStore, @@ -108,11 +107,7 @@ class AppState( val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2") android.detectDevices() + ios.detectDevices() } - _deviceScan.value = if (devices.isEmpty()) { - DeviceScanState.Found(emptyList()) - } else { - DeviceScanState.Found(devices) - } + _deviceScan.value = DeviceScanState.Found(devices) } catch (e: Exception) { log.error("Device scan failed", e) _deviceScan.value = DeviceScanState.Error(e.message ?: "Unknown error") @@ -138,13 +133,25 @@ class AppState( 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() + fun loadPhotos(device: DeviceInfo) { scope.launch { _photoList.value = PhotoListState.Loading try { val connector = connectorFor(device) val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) } - val filtered = ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion) + 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, @@ -158,13 +165,27 @@ 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 setDateFilter(from: LocalDate?, to: LocalDate?) { _dateFilter.value = from to to - // Re-apply filter over already-loaded photos val current = _photoList.value as? PhotoListState.Loaded ?: return - val filtered = ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion) - .let { applyDateFilter(it, from, to) } - _photoList.value = current.copy(filtered = filtered) + val base = if (_showAll.value) current.all + else ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion) + _photoList.value = current.copy(filtered = applyDateFilter(base, from, to)) } fun togglePhotoSelection(photo: PhonePhoto) { @@ -202,6 +223,7 @@ class AppState( convertHeicToJpeg = true, stripLivePhotoMotion = true, deleteAfterVerify = false, + preserveFolderStructure = false, folderName = "", ) ) @@ -211,6 +233,7 @@ class AppState( val convertHeicToJpeg: Boolean, val stripLivePhotoMotion: Boolean, val deleteAfterVerify: Boolean, + val preserveFolderStructure: Boolean, val folderName: String, ) @@ -226,14 +249,18 @@ class AppState( .replace("{date}", today.toString()) .replace("{device}", StagingPath.deviceLabel(device)) - // Auto-increment if the folder already exists: 2026-07-28_iPhone15Pro_2, _3, … + // Auto-increment if the folder already exists val folderName = if (cfg.stagingRoot.isNotBlank()) { StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName) } else { baseName } - _importConfig.update { it.copy(folderName = folderName) } + _importConfig.update { it.copy( + folderName = folderName, + // When showing all files, default to preserving folder structure + preserveFolderStructure = _showAll.value, + ) } navigateTo(Screen.IMPORT_CONFIG) } @@ -260,6 +287,7 @@ class AppState( convertHeicToJpeg = importCfg.convertHeicToJpeg, stripLivePhotoMotion = importCfg.stripLivePhotoMotion, deleteAfterVerify = importCfg.deleteAfterVerify, + preserveFolderStructure = importCfg.preserveFolderStructure, ) navigateTo(Screen.IMPORTING) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt index 1490e44..49ddde4 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/BrowseScreen.kt @@ -34,15 +34,18 @@ fun BrowseScreen(state: AppState) { 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 BrowseTopBar( device = device, selected = selected, + showAll = showAll, onBack = { state.navigateTo(Screen.CONNECT) }, onSelectAll = { state.selectAll() }, onSelectNone = { state.selectNone() }, + onToggleShowAll = { state.toggleShowAll() }, onImport = { state.prepareImport() }, ) @@ -124,9 +127,11 @@ fun BrowseScreen(state: AppState) { private fun BrowseTopBar( device: DeviceInfo?, selected: Set, + showAll: Boolean, onBack: () -> Unit, onSelectAll: () -> Unit, onSelectNone: () -> Unit, + onToggleShowAll: () -> Unit, onImport: () -> Unit, ) { TopAppBar( @@ -139,6 +144,12 @@ 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(), diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt index 6d2f17d..b12933d 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/ImportScreen.kt @@ -121,6 +121,25 @@ fun ImportScreen(state: AppState) { // Delete after verify Text("After Import", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Preserve folder structure") + Text( + "Copy files into subfolders matching their device path (e.g. Camera/, Screenshots/). Enabled automatically in Show All mode.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = importConfig.preserveFolderStructure, + onCheckedChange = { state.updateImportConfig { copy(preserveFolderStructure = it) } }, + ) + } + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,