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,
|
||||
/** 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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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<Pair<LocalDate?, LocalDate?>>(null to null)
|
||||
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) {
|
||||
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)
|
||||
|
||||
@@ -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<PhonePhoto>,
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user