Add Show All mode with optional folder structure preservation

- 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
This commit is contained in:
Kyle Bolen
2026-07-28 04:28:17 +00:00
parent ff812a9550
commit 3da4e464d2
5 changed files with 91 additions and 14 deletions

View File

@@ -19,6 +19,13 @@ data class ImportJob(
val stripLivePhotoMotion: Boolean = true, val stripLivePhotoMotion: Boolean = true,
/** Whether to delete photos from the phone after successful verification */ /** Whether to delete photos from the phone after successful verification */
val deleteAfterVerify: Boolean = false, 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,
) )
/** /**

View File

@@ -63,7 +63,19 @@ class Copier(
photosToImport.forEachIndexed { idx, photo -> photosToImport.forEachIndexed { idx, photo ->
onProgress(idx, photosToImport.size, photo.filename) 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) results.add(result)
if (result.error != null) { if (result.error != null) {
log.error("Failed to copy ${photo.filename}: ${result.error}") log.error("Failed to copy ${photo.filename}: ${result.error}")

View File

@@ -68,7 +68,6 @@ data class ImportProgress(
/** /**
* Application state container — the single source of truth for all UI state. * Application state container — the single source of truth for all UI state.
* All mutations happen via public methods; UI observes via StateFlows.
*/ */
class AppState( class AppState(
private val configStore: ConfigStore, private val configStore: ConfigStore,
@@ -108,11 +107,7 @@ class AppState(
val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2") val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2")
android.detectDevices() + ios.detectDevices() android.detectDevices() + ios.detectDevices()
} }
_deviceScan.value = if (devices.isEmpty()) { _deviceScan.value = DeviceScanState.Found(devices)
DeviceScanState.Found(emptyList())
} else {
DeviceScanState.Found(devices)
}
} catch (e: Exception) { } catch (e: Exception) {
log.error("Device scan failed", e) log.error("Device scan failed", e)
_deviceScan.value = DeviceScanState.Error(e.message ?: "Unknown error") _deviceScan.value = DeviceScanState.Error(e.message ?: "Unknown error")
@@ -138,13 +133,25 @@ class AppState(
private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null) private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null)
val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow() val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow()
/** When true, bypass ExifFilter and show all photos from all DCIM folders */
private val _showAll = MutableStateFlow(false)
val showAll: StateFlow<Boolean> = _showAll.asStateFlow()
fun loadPhotos(device: DeviceInfo) { fun loadPhotos(device: DeviceInfo) {
scope.launch { scope.launch {
_photoList.value = PhotoListState.Loading _photoList.value = PhotoListState.Loading
try { try {
val connector = connectorFor(device) val connector = connectorFor(device)
val raw = withContext(Dispatchers.IO) { connector.listPhotos(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) val shootingInHeic = ExifFilter.isShootingInHeic(filtered)
_photoList.value = PhotoListState.Loaded( _photoList.value = PhotoListState.Loaded(
all = raw, 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?) { fun setDateFilter(from: LocalDate?, to: LocalDate?) {
_dateFilter.value = from to to _dateFilter.value = from to to
// Re-apply filter over already-loaded photos
val current = _photoList.value as? PhotoListState.Loaded ?: return val current = _photoList.value as? PhotoListState.Loaded ?: return
val filtered = ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion) val base = if (_showAll.value) current.all
.let { applyDateFilter(it, from, to) } else ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion)
_photoList.value = current.copy(filtered = filtered) _photoList.value = current.copy(filtered = applyDateFilter(base, from, to))
} }
fun togglePhotoSelection(photo: PhonePhoto) { fun togglePhotoSelection(photo: PhonePhoto) {
@@ -202,6 +223,7 @@ class AppState(
convertHeicToJpeg = true, convertHeicToJpeg = true,
stripLivePhotoMotion = true, stripLivePhotoMotion = true,
deleteAfterVerify = false, deleteAfterVerify = false,
preserveFolderStructure = false,
folderName = "", folderName = "",
) )
) )
@@ -211,6 +233,7 @@ class AppState(
val convertHeicToJpeg: Boolean, val convertHeicToJpeg: Boolean,
val stripLivePhotoMotion: Boolean, val stripLivePhotoMotion: Boolean,
val deleteAfterVerify: Boolean, val deleteAfterVerify: Boolean,
val preserveFolderStructure: Boolean,
val folderName: String, val folderName: String,
) )
@@ -226,14 +249,18 @@ class AppState(
.replace("{date}", today.toString()) .replace("{date}", today.toString())
.replace("{device}", StagingPath.deviceLabel(device)) .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()) { val folderName = if (cfg.stagingRoot.isNotBlank()) {
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName) StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName)
} else { } else {
baseName 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) navigateTo(Screen.IMPORT_CONFIG)
} }
@@ -260,6 +287,7 @@ class AppState(
convertHeicToJpeg = importCfg.convertHeicToJpeg, convertHeicToJpeg = importCfg.convertHeicToJpeg,
stripLivePhotoMotion = importCfg.stripLivePhotoMotion, stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
deleteAfterVerify = importCfg.deleteAfterVerify, deleteAfterVerify = importCfg.deleteAfterVerify,
preserveFolderStructure = importCfg.preserveFolderStructure,
) )
navigateTo(Screen.IMPORTING) navigateTo(Screen.IMPORTING)

View File

@@ -34,15 +34,18 @@ fun BrowseScreen(state: AppState) {
val device by state.selectedDevice.collectAsState() val device by state.selectedDevice.collectAsState()
val config by state.config.collectAsState() val config by state.config.collectAsState()
val dateFilter by state.dateFilter.collectAsState() val dateFilter by state.dateFilter.collectAsState()
val showAll by state.showAll.collectAsState()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Top bar // Top bar
BrowseTopBar( BrowseTopBar(
device = device, device = device,
selected = selected, selected = selected,
showAll = showAll,
onBack = { state.navigateTo(Screen.CONNECT) }, onBack = { state.navigateTo(Screen.CONNECT) },
onSelectAll = { state.selectAll() }, onSelectAll = { state.selectAll() },
onSelectNone = { state.selectNone() }, onSelectNone = { state.selectNone() },
onToggleShowAll = { state.toggleShowAll() },
onImport = { state.prepareImport() }, onImport = { state.prepareImport() },
) )
@@ -124,9 +127,11 @@ fun BrowseScreen(state: AppState) {
private fun BrowseTopBar( private fun BrowseTopBar(
device: DeviceInfo?, device: DeviceInfo?,
selected: Set<PhonePhoto>, selected: Set<PhonePhoto>,
showAll: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onSelectNone: () -> Unit, onSelectNone: () -> Unit,
onToggleShowAll: () -> Unit,
onImport: () -> Unit, onImport: () -> Unit,
) { ) {
TopAppBar( TopAppBar(
@@ -139,6 +144,12 @@ private fun BrowseTopBar(
actions = { actions = {
TextButton(onClick = onSelectAll) { Text("All") } TextButton(onClick = onSelectAll) { Text("All") }
TextButton(onClick = onSelectNone) { Text("None") } TextButton(onClick = onSelectNone) { Text("None") }
FilterChip(
selected = showAll,
onClick = onToggleShowAll,
label = { Text("Show all") },
modifier = Modifier.padding(horizontal = 4.dp),
)
Button( Button(
onClick = onImport, onClick = onImport,
enabled = selected.isNotEmpty(), enabled = selected.isNotEmpty(),

View File

@@ -121,6 +121,25 @@ fun ImportScreen(state: AppState) {
// Delete after verify // Delete after verify
Text("After Import", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,