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:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user