Add a two-pane layout for large screens, and re-shoot the tablet sets

A window at or above Material expanded breakpoint (840dp) now splits into a master pane and a detail pane: tapping a row highlights it and the second pane shows a folder children, or a preview plus the metadata for a file. Opening moves to a double tap on wide windows only; phones (~411dp) and the 7-inch in portrait (~600dp) keep their layout and their tap behaviour.

Found while taking the tablet screenshots: the app had no large-screen layout at all, so on a 2560x1600 canvas every row was 2560px wide with its content stranded in the left ~240px.

The tablet sets are re-shot on this build. That also caught a blank capture (7-inch 01-roots) that had already been committed: a headless AVD draws its system bars before Compose composes its first frame, so the file was not obviously empty. make-store-screenshots.sh now measures each capture app area and refuses to normalise one that is blank.
This commit is contained in:
2026-10-15 00:18:38 +02:00
parent b28e9dca37
commit 55dc2293b2
21 changed files with 447 additions and 32 deletions
@@ -97,12 +97,17 @@ fun EntryRow(
selected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
) {
ListItem(
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onDoubleClick = onDoubleClick,
),
colors = if (selected) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
@@ -127,12 +132,17 @@ fun EntryGridCell(
selected: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onDoubleClick = onDoubleClick,
),
shape = RoundedCornerShape(12.dp),
color = if (selected) {
MaterialTheme.colorScheme.primaryContainer
@@ -2,12 +2,14 @@ package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -17,7 +19,9 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -27,6 +31,19 @@ import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.StoragePath
import de.softwareapp_hb.filemanager.data.StorageAccessMode
/**
* The window width at which the browser splits into two panes.
*
* This is Material's "expanded" breakpoint: the point where a 360dp master pane still
* leaves a detail pane worth reading, and where a single full-width list starts to look
* stretched. A phone (~411dp) and a 7-inch tablet in portrait (~600dp) stay below it and
* keep the single-pane layout they always had.
*/
val WIDE_LAYOUT_MIN_WIDTH = 840.dp
/** How wide the master (left) pane is when two panes are shown. */
private val MasterPaneWidth = 360.dp
/** Everything below the app bar: the permission prompt, the roots, or a directory. */
@Composable
fun BrowserContent(
@@ -34,17 +51,29 @@ fun BrowserContent(
contentPadding: PaddingValues,
crumbLabel: (StoragePath) -> String,
onOpenEntry: (FileEntry) -> Unit,
onFocusEntry: (FileEntry) -> Unit,
onFocusRoot: (RootItem) -> Unit,
onLongPressEntry: (StoragePath) -> Unit,
onOpenRoot: (StoragePath) -> Unit,
onNavigate: (StoragePath) -> Unit,
onGrantAllFiles: () -> Unit,
onPickFolder: () -> Unit,
) {
Box(
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
) {
val wide = maxWidth >= WIDE_LAYOUT_MIN_WIDTH
// Landing on "Storage" with a tablet in landscape should already show what is
// inside the device's main volume, rather than an empty second pane.
LaunchedEffect(wide, state.isRootView, state.roots) {
if (wide && state.isRootView && state.focus == null) {
state.roots.firstOrNull()?.let(onFocusRoot)
}
}
when {
state.needsTreeGrant -> StorageAccessPrompt(
folderMode = true,
@@ -52,16 +81,59 @@ fun BrowserContent(
onPickFolder = onPickFolder,
)
state.isRootView && wide -> Row(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.width(MasterPaneWidth)) {
items(items = state.roots, key = { it.path.value }) { root ->
RootRow(
item = root,
selected = root.path == state.focus?.entry?.path,
onClick = { onFocusRoot(root) },
onDoubleClick = { onOpenRoot(root.path) },
)
}
}
VerticalDivider()
DetailPane(
focus = state.focus,
crumbLabel = crumbLabel,
onOpenEntry = onOpenEntry,
modifier = Modifier.weight(1f),
)
}
state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = state.roots, key = { it.path.value }) { root ->
RootRow(item = root, onClick = { onOpenRoot(root.path) })
}
}
wide -> Row(modifier = Modifier.fillMaxSize()) {
DirectoryView(
state = state,
crumbLabel = crumbLabel,
wide = true,
onOpenEntry = onOpenEntry,
onFocusEntry = onFocusEntry,
onLongPressEntry = onLongPressEntry,
onNavigate = onNavigate,
onGrantAllFiles = onGrantAllFiles,
modifier = Modifier.width(MasterPaneWidth),
)
VerticalDivider()
DetailPane(
focus = state.focus,
crumbLabel = crumbLabel,
onOpenEntry = onOpenEntry,
modifier = Modifier.weight(1f),
)
}
else -> DirectoryView(
state = state,
crumbLabel = crumbLabel,
wide = false,
onOpenEntry = onOpenEntry,
onFocusEntry = onFocusEntry,
onLongPressEntry = onLongPressEntry,
onNavigate = onNavigate,
onGrantAllFiles = onGrantAllFiles,
@@ -74,16 +146,20 @@ fun BrowserContent(
}
}
/** A directory: its breadcrumbs, then its entries as a list or a grid. */
@Composable
private fun DirectoryView(
state: BrowserUiState,
crumbLabel: (StoragePath) -> String,
wide: Boolean,
onOpenEntry: (FileEntry) -> Unit,
onFocusEntry: (FileEntry) -> Unit,
onLongPressEntry: (StoragePath) -> Unit,
onNavigate: (StoragePath) -> Unit,
onGrantAllFiles: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = modifier.fillMaxSize()) {
Breadcrumbs(crumbs = state.breadcrumbs, labelFor = crumbLabel, onNavigate = onNavigate)
// Full access is a better experience than the folder fallback, so offer the upgrade
@@ -118,14 +194,20 @@ private fun DirectoryView(
return@Column
}
// With two panes on screen a single tap only highlights — the second pane is the
// reward — so opening moves to a double tap. A single pane has nowhere to preview
// into, so there a tap still opens outright.
when (state.viewMode) {
ViewMode.LIST -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = state.entries, key = { it.path.value }) { entry ->
val open: () -> Unit = { onOpenEntry(entry) }
val highlight: () -> Unit = { onFocusEntry(entry) }
EntryRow(
entry = entry,
selected = entry.path in state.selection,
onClick = { onOpenEntry(entry) },
selected = entry.isHighlighted(state),
onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) },
onDoubleClick = if (wide) open else null,
)
}
}
@@ -138,14 +220,21 @@ private fun DirectoryView(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = state.entries, key = { it.path.value }) { entry ->
val open: () -> Unit = { onOpenEntry(entry) }
val highlight: () -> Unit = { onFocusEntry(entry) }
EntryGridCell(
entry = entry,
selected = entry.path in state.selection,
onClick = { onOpenEntry(entry) },
selected = entry.isHighlighted(state),
onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) },
onDoubleClick = if (wide) open else null,
)
}
}
}
}
}
/** Highlighted for either reason a row can be: multi-selected, or driving the detail pane. */
private fun FileEntry.isHighlighted(state: BrowserUiState): Boolean =
path in state.selection || path == state.focus?.entry?.path
@@ -0,0 +1,131 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import de.softwareapp_hb.filemanager.R
import de.softwareapp_hb.filemanager.core.FileEntry
import de.softwareapp_hb.filemanager.core.FileKind
import de.softwareapp_hb.filemanager.core.StoragePath
import java.io.File
/**
* The second pane of the wide layout: what is inside the folder the user picked, or a
* preview and the details of the file they picked.
*
* This is the whole point of two panes. The master keeps its place and its scroll
* position while the detail answers "and what is in there?" — no navigation step, and no
* 2000-pixel-wide row with the content stranded at the left edge.
*/
@Composable
fun DetailPane(
focus: DetailFocus?,
crumbLabel: (StoragePath) -> String,
onOpenEntry: (FileEntry) -> Unit,
modifier: Modifier = Modifier,
) {
val current = focus
if (current == null) {
EmptyState(
message = stringResource(R.string.browser_detail_placeholder),
modifier = modifier.fillMaxSize(),
)
return
}
Column(modifier = modifier.fillMaxSize()) {
Text(
// A volume root's path ends in "0" (/storage/emulated/0), so this uses the
// same localised label the breadcrumbs do rather than the raw entry name.
text = crumbLabel(current.entry.path),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
when {
current.entry.isDirectory -> FolderPreview(focus = current, onOpenEntry = onOpenEntry)
// Coil needs a real filesystem path; a SAF path is relative to the granted
// tree and would silently fail, so a preview is only attempted for all-files
// mode. Everything else falls through to the metadata.
current.entry.kind == FileKind.IMAGE && current.entry.path.value.startsWith("/") ->
ImagePreview(entry = current.entry)
else -> Column(modifier = Modifier.padding(top = 8.dp, bottom = 24.dp)) {
DetailLines(current.entry)
}
}
}
}
/** One level deeper: the children of the highlighted folder, ready to be opened. */
@Composable
private fun FolderPreview(focus: DetailFocus, onOpenEntry: (FileEntry) -> Unit) {
if (focus.entries.isEmpty()) {
EmptyState(
message = if (focus.loading) {
stringResource(R.string.browser_detail_loading)
} else {
stringResource(R.string.browser_empty_folder)
},
modifier = Modifier.fillMaxSize(),
)
return
}
Text(
text = itemCountLabel(focus.entries.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 24.dp, bottom = 4.dp),
)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = focus.entries, key = { it.path.value }) { entry ->
EntryRow(
entry = entry,
selected = false,
onClick = { onOpenEntry(entry) },
)
}
}
}
/** A large as-you-like preview above the metadata, on a canvas that reads as a preview. */
@Composable
private fun ImagePreview(entry: FileEntry) {
Column(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = File(entry.path.value),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 24.dp, vertical = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
)
Column(modifier = Modifier.padding(top = 8.dp, bottom = 24.dp)) {
DetailLines(entry)
}
}
}
@@ -1,7 +1,6 @@
package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -16,6 +15,7 @@ import androidx.compose.material.icons.filled.SdCard
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -30,15 +30,27 @@ import de.softwareapp_hb.filemanager.core.FileSize
import de.softwareapp_hb.filemanager.core.StoragePath
/** A row on the root screen: a volume, with its free space when the platform reports it. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RootRow(item: RootItem, modifier: Modifier = Modifier, onClick: () -> Unit) {
fun RootRow(
item: RootItem,
modifier: Modifier = Modifier,
selected: Boolean = false,
onClick: () -> Unit,
onDoubleClick: (() -> Unit)? = null,
) {
val name = item.label ?: item.labelRes?.let { stringResource(it) }.orEmpty()
val removable = item.labelRes == null
ListItem(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
.combinedClickable(onClick = onClick, onDoubleClick = onDoubleClick),
colors = if (selected) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
ListItemDefaults.colors()
},
leadingContent = {
Icon(
imageVector = if (removable) Icons.Filled.SdCard else Icons.Filled.Storage,
@@ -230,6 +230,8 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
contentPadding = innerPadding,
crumbLabel = crumbLabel,
onOpenEntry = viewModel::open,
onFocusEntry = viewModel::focus,
onFocusRoot = viewModel::focusRoot,
onLongPressEntry = viewModel::toggleSelection,
onOpenRoot = viewModel::openDirectory,
onNavigate = viewModel::openDirectory,
@@ -49,6 +49,21 @@ data class RootItem(
val freeBytes: Long? = null,
)
/**
* The entry highlighted in the master pane on a wide screen, and what the detail pane
* shows for it.
*
* A phone never sets this. Tapping a row there opens it outright, because there is no
* second pane to preview into; only a window with room for two panes highlights on tap
* and opens on double tap.
*/
data class DetailFocus(
val entry: FileEntry,
/** The listing of [entry] when it is a folder; empty for a file. */
val entries: List<FileEntry> = emptyList(),
val loading: Boolean = false,
)
/** A full-screen view the browser hands over to. */
sealed interface Overlay {
data object None : Overlay
@@ -84,6 +99,8 @@ data class BrowserUiState(
val showHidden: Boolean = false,
val viewMode: ViewMode = ViewMode.LIST,
val selection: Set<StoragePath> = emptySet(),
/** The entry the wide-screen detail pane is showing; `null` until the user picks one. */
val focus: DetailFocus? = null,
val clipboard: Clipboard? = null,
val loading: Boolean = false,
val freeBytes: Long? = null,
@@ -108,6 +108,7 @@ class BrowserViewModel(
path = null,
breadcrumbs = emptyList(),
selection = emptySet(),
focus = null,
query = "",
freeBytes = null,
loading = false,
@@ -127,6 +128,7 @@ class BrowserViewModel(
path = path,
breadcrumbs = breadcrumbsOf(path),
selection = emptySet(),
focus = null,
freeBytes = storage.space(path)?.freeBytes,
loading = false,
)
@@ -235,6 +237,47 @@ class BrowserViewModel(
_state.update { it.copy(selection = emptySet()) }
}
// --------------------------------------------------------------- detail focus
/**
* Highlight [entry] so the wide-screen detail pane can show it.
*
* A folder is listed so the pane shows one level deeper; a file needs nothing beyond
* the entry itself. Only the UI calls this, and only when the window is wide enough
* for two panes — on a phone a tap opens the entry outright instead.
*/
fun focus(entry: FileEntry) {
val source = if (entry.isDirectory) storage.activeSource() else null
_state.update { it.copy(focus = DetailFocus(entry, loading = source != null)) }
if (source == null) return
viewModelScope.launch {
val children = runCatching { source.list(entry.path) }.getOrDefault(emptyList())
_state.update { current ->
// A listing that lands after the user picked something else is stale.
if (current.focus?.entry?.path != entry.path) return@update current
val shown = children.filter { current.showHidden || !it.isHidden }
current.copy(focus = DetailFocus(entry, current.sort.sort(shown), loading = false))
}
}
}
/**
* Preview a root volume in the detail pane.
*
* The platform reports roots as `VolumeInfo` rather than as directory entries, so a
* [FileEntry] is shaped here. Size and timestamp are placeholders — the detail pane
* shows a folder's children, never the folder's own metadata.
*/
fun focusRoot(root: RootItem) = focus(
FileEntry(
path = root.path,
isDirectory = true,
sizeBytes = 0L,
lastModifiedMillis = 0L,
),
)
// ------------------------------------------------------------------ opening
/** Open an entry: folders navigate, images and text open in the built-in viewers. */
@@ -30,24 +30,35 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) {
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
)
DetailLine(stringResource(R.string.browser_details_name), entry.name)
DetailLine(stringResource(R.string.browser_details_location), entry.path.parent?.value ?: "/")
DetailLine(
label = stringResource(R.string.browser_details_size),
value = if (entry.isDirectory) {
stringResource(R.string.browser_details_folder)
} else {
FileSize.format(entry.sizeBytes)
},
)
DetailLine(stringResource(R.string.browser_details_kind), stringResource(entry.kind.labelRes()))
Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified ->
DetailLine(stringResource(R.string.browser_details_modified), modified)
}
DetailLines(entry)
}
}
}
/**
* The name/location/size/type/modified block.
*
* Shared by the phone's bottom sheet and the wide-screen detail pane, so the two can
* never drift apart.
*/
@Composable
fun DetailLines(entry: FileEntry) {
DetailLine(stringResource(R.string.browser_details_name), entry.name)
DetailLine(stringResource(R.string.browser_details_location), entry.path.parent?.value ?: "/")
DetailLine(
label = stringResource(R.string.browser_details_size),
value = if (entry.isDirectory) {
stringResource(R.string.browser_details_folder)
} else {
FileSize.format(entry.sizeBytes)
},
)
DetailLine(stringResource(R.string.browser_details_kind), stringResource(entry.kind.labelRes()))
Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified ->
DetailLine(stringResource(R.string.browser_details_modified), modified)
}
}
@Composable
private fun DetailLine(label: String, value: String) {
Row(
+4
View File
@@ -89,6 +89,10 @@
<string name="browser_details_modified">Modified</string>
<string name="browser_details_folder">Folder</string>
<!-- ===== Browser: wide-screen detail pane ===== -->
<string name="browser_detail_placeholder">Select an item to preview it</string>
<string name="browser_detail_loading">Loading…</string>
<!-- ===== File categories (the "Type" row of the details sheet) ===== -->
<string name="browser_kind_image">Image</string>
<string name="browser_kind_video">Video</string>
@@ -299,4 +299,74 @@ class BrowserViewModelTest {
model.openDirectory(treeRoot)
assertEquals(listOf("in-tree.txt"), model.state.value.entries.map { it.name })
}
// --------------------------------------------------------- wide-screen focus
@Test
fun `focusing a folder in the master pane lists it for the detail pane`() {
val model = viewModel()
model.openDirectory(root)
val folder = model.state.value.entries.single { it.path == downloads }
model.focus(folder)
val focus = model.state.value.focus
assertNotNull(focus)
assertEquals(downloads, focus!!.entry.path)
assertTrue(focus.entry.isDirectory)
assertEquals(listOf("a.txt", "b.txt"), focus.entries.map { it.name })
assertFalse(focus.loading)
}
@Test
fun `focusing a file keeps the entry and previews no children`() {
val model = viewModel()
model.openDirectory(downloads)
model.focus(model.state.value.entries.single { it.name == "a.txt" })
val focus = model.state.value.focus
assertNotNull(focus)
assertEquals("a.txt", focus!!.entry.name)
assertTrue(focus.entries.isEmpty())
assertFalse(focus.loading)
}
@Test
fun `the detail pane honours the hidden-files preference`() {
val model = viewModel()
model.openDirectory(root)
val folder = model.state.value.entries.single { it.path == downloads }
model.focus(folder)
assertFalse(model.state.value.focus!!.entries.any { it.name == ".hidden" })
model.toggleShowHidden()
model.focus(folder)
assertTrue(model.state.value.focus!!.entries.any { it.name == ".hidden" })
}
@Test
fun `navigating clears the focus so the pane cannot show a stale folder`() {
val model = viewModel()
model.openDirectory(root)
model.focus(model.state.value.entries.single { it.path == downloads })
assertNotNull(model.state.value.focus)
model.navigateUp()
assertNull(model.state.value.focus)
}
@Test
fun `a focused volume previews the contents of that volume`() {
val model = viewModel()
model.focusRoot(model.state.value.roots.single())
val focus = model.state.value.focus
assertNotNull(focus)
assertEquals(root, focus!!.entry.path)
assertTrue(focus.entries.any { it.path == downloads })
assertTrue(focus.entries.any { it.name == "top.txt" })
}
}