ImportScreen: scrollable content with pinned bottom button

- Body scrolls via verticalScroll
- Header and Start Import button stay fixed outside the scroll
- Summary card now shows source folder breakdown for multi-folder imports
- HorizontalDivider separates pinned button from scroll area
This commit is contained in:
Kyle Bolen
2026-07-28 05:29:17 +00:00
parent fd5753382f
commit edaf4cb0d7

View File

@@ -1,6 +1,8 @@
package com.bolenpad.photophetch.ui package com.bolenpad.photophetch.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -23,86 +25,130 @@ fun ImportScreen(state: AppState) {
StagingPath.validateStagingRoot(Paths.get(config.stagingRoot)) StagingPath.validateStagingRoot(Paths.get(config.stagingRoot))
} else "Staging directory not configured" } else "Staging directory not configured"
Column( val canImport = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty()
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp), // Outer column: header + scrollable body + pinned button
) { Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) { // ── Header (always visible) ──────────────────────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, top = 8.dp, end = 16.dp, bottom = 4.dp),
) {
IconButton(onClick = { state.navigateTo(Screen.BROWSE) }) { IconButton(onClick = { state.navigateTo(Screen.BROWSE) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
Text("Import Configuration", style = MaterialTheme.typography.headlineSmall) Text("Import Configuration", style = MaterialTheme.typography.headlineSmall)
} }
// Summary
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${selected.size} files selected", fontWeight = FontWeight.SemiBold)
device?.let { Text("From: ${it.displayName}", style = MaterialTheme.typography.bodySmall) }
}
}
HorizontalDivider() HorizontalDivider()
// Destination // ── Scrollable body ──────────────────────────────────────────────────
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Column(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { modifier = Modifier
Text( .weight(1f)
"${config.stagingRoot}/", .verticalScroll(rememberScrollState())
style = MaterialTheme.typography.bodySmall, .padding(horizontal = 32.dp, vertical = 20.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, verticalArrangement = Arrangement.spacedBy(20.dp),
)
OutlinedTextField(
value = importConfig.folderName,
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
label = { Text("Folder name") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
if (stagingError != null) {
Text(stagingError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
HorizontalDivider()
// Options
Text("Options", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
ToggleRow(
label = "Convert HEIC → JPEG",
description = "Recommended for PhotoPhile on Linux. Uses macOS sips.",
checked = importConfig.convertHeicToJpeg,
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
)
ToggleRow(
label = "Strip Live Photo motion clips",
description = "Excludes .MOV companions from Live Photos.",
checked = importConfig.stripLivePhotoMotion,
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
)
ToggleRow(
label = "Preserve folder structure",
description = "Files go into subfolders matching their source (e.g. Camera/, Screenshots/).",
checked = importConfig.preserveFolderStructure,
onCheckedChange = { state.updateImportConfig { copy(preserveFolderStructure = it) } },
)
ToggleRow(
label = "Delete from phone after verification",
description = "Only deletes files that passed SHA-256 check. You'll confirm on the next screen.",
checked = importConfig.deleteAfterVerify,
onCheckedChange = { state.updateImportConfig { copy(deleteAfterVerify = it) } },
)
Spacer(Modifier.weight(1f))
Button(
onClick = { state.startImport() },
enabled = stagingError == null && importConfig.folderName.isNotBlank() && selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().height(52.dp),
) { ) {
Text("Start Import (${selected.size} files)", style = MaterialTheme.typography.titleMedium) // Summary card
Card {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("${selected.size} files selected", fontWeight = FontWeight.SemiBold)
device?.let {
Text("From: ${it.displayName}", style = MaterialTheme.typography.bodySmall)
}
// Show source folders breakdown if multi-folder selection
val folders = selected.map { it.sourceFolder }.distinct().sorted()
if (folders.size > 1) {
Text(
"Folders: ${folders.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
HorizontalDivider()
// Destination
Text("Destination Folder", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"${config.stagingRoot}/",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = importConfig.folderName,
onValueChange = { state.updateImportConfig { copy(folderName = it) } },
label = { Text("Folder name") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
if (stagingError != null) {
Text(
stagingError,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
HorizontalDivider()
// Options
Text("Options", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
ToggleRow(
label = "Convert HEIC → JPEG",
description = "Recommended for PhotoPhile on Linux. Uses macOS sips (built-in).",
checked = importConfig.convertHeicToJpeg,
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
)
ToggleRow(
label = "Strip Live Photo motion clips",
description = "Excludes .MOV companions from Live Photos.",
checked = importConfig.stripLivePhotoMotion,
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
)
ToggleRow(
label = "Preserve folder structure",
description = "Files go into subfolders matching their source (Camera/, Screenshots/, etc.).",
checked = importConfig.preserveFolderStructure,
onCheckedChange = { state.updateImportConfig { copy(preserveFolderStructure = it) } },
)
ToggleRow(
label = "Delete from phone after verification",
description = "Only deletes files that passed SHA-256 check. You'll confirm on the next screen.",
checked = importConfig.deleteAfterVerify,
onCheckedChange = { state.updateImportConfig { copy(deleteAfterVerify = it) } },
)
// Extra bottom padding so last item isn't flush against the button
Spacer(Modifier.height(8.dp))
}
// ── Pinned bottom button ─────────────────────────────────────────────
HorizontalDivider()
Box(modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
Button(
onClick = { state.startImport() },
enabled = canImport,
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text(
"Start Import (${selected.size} files)",
style = MaterialTheme.typography.titleMedium,
)
}
} }
} }
} }
@@ -121,8 +167,11 @@ private fun ToggleRow(
) { ) {
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
Text(label) Text(label)
Text(description, style = MaterialTheme.typography.bodySmall, Text(
color = MaterialTheme.colorScheme.onSurfaceVariant) description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
Switch(checked = checked, onCheckedChange = onCheckedChange) Switch(checked = checked, onCheckedChange = onCheckedChange)
} }