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
+21 -3
View File
@@ -31,6 +31,20 @@ upload needs a **higher `versionCode`** than every previous upload.
### Added ### Added
- **A two-pane layout for large screens.** A window at or above Material's "expanded"
breakpoint — 840 dp, which a 10-inch tablet in landscape at 1280 dp clears — now splits
into a master pane and a detail pane. Tapping a row highlights it, and the second pane
answers "what is in there?" without a navigation step: a folder's children, or a preview
plus the metadata for a file. Opening therefore moves to a double tap **on wide windows
only**, because once there is somewhere to show a selection a single tap has to mean
"show me"; phone (~411 dp) and 7-inch-portrait (~600 dp) windows keep the layout and the
tap behaviour they already had. The root screen previews the main volume on arrival
rather than leaving the second pane empty.
Found while taking the tablet screenshots, where it turned out the app had **no**
large-screen layout at all: on a 2560×1600 canvas every row was 2560 px wide with its
content stranded in the left ~240 px.
- **M10 — store assets.** The app is named **Plain Files** (one string; the `applicationId` - **M10 — store assets.** The app is named **Plain Files** (one string; the `applicationId`
was already locked in M0). `tools/make-store-assets.sh` renders the 512×512 RGBA icon and was already locked in M0). `tools/make-store-assets.sh` renders the 512×512 RGBA icon and
the 1024×500 RGB feature graphic from the app's own icon vectors, so the store icon *is* the 1024×500 RGB feature graphic from the app's own icon vectors, so the store icon *is*
@@ -42,9 +56,13 @@ upload needs a **higher `versionCode`** than every previous upload.
declaration requires. The feature graphic's copy was verified to survive Play's declaration requires. The feature graphic's copy was verified to survive Play's
centre-square crop by cropping the rendered PNG, not by trusting the arithmetic. centre-square crop by cropping the rendered PNG, not by trusting the arithmetic.
**Not finished:** the screenshots themselves. The generator and the raw-capture **Screenshots:** 5 phone shots at 1080×2160, 4 on a 7-inch tablet at 1200×1920 and 4 on a
directories are in place, but no captures have been taken, so `store-assets/screenshots/` 10-inch tablet at 2560×1600 — all 24-bit RGB with no alpha, inside Play's 2:1 cap. The
is still empty. The listing is publishable except for that. tablet sets come from AOSP AVDs, because there was no tablet hardware to hand. The
generator now measures every capture and refuses to normalise a blank one: a headless AVD
that has not composed the app's first frame still draws its system bars, so a
pure-background capture is not obviously empty — and one had already been committed
before the check existed.
- **M9 — monetisation.** Decided: a **one-time paid app at €4.49, with a 14-day launch price - **M9 — monetisation.** Decided: a **one-time paid app at €4.49, with a 14-day launch price
of €2.29**, both set in the Play Console. No ads, no subscription and no in-app purchases — of €2.29**, both set in the Play Console. No ads, no subscription and no in-app purchases —
@@ -97,12 +97,17 @@ fun EntryRow(
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
) { ) {
ListItem( ListItem(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onDoubleClick = onDoubleClick,
),
colors = if (selected) { colors = if (selected) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else { } else {
@@ -127,12 +132,17 @@ fun EntryGridCell(
selected: Boolean, selected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
) { ) {
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = onLongClick), .combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onDoubleClick = onDoubleClick,
),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = if (selected) { color = if (selected) {
MaterialTheme.colorScheme.primaryContainer 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.core.StoragePath
import de.softwareapp_hb.filemanager.data.StorageAccessMode 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. */ /** Everything below the app bar: the permission prompt, the roots, or a directory. */
@Composable @Composable
fun BrowserContent( fun BrowserContent(
@@ -34,17 +51,29 @@ fun BrowserContent(
contentPadding: PaddingValues, contentPadding: PaddingValues,
crumbLabel: (StoragePath) -> String, crumbLabel: (StoragePath) -> String,
onOpenEntry: (FileEntry) -> Unit, onOpenEntry: (FileEntry) -> Unit,
onFocusEntry: (FileEntry) -> Unit,
onFocusRoot: (RootItem) -> Unit,
onLongPressEntry: (StoragePath) -> Unit, onLongPressEntry: (StoragePath) -> Unit,
onOpenRoot: (StoragePath) -> Unit, onOpenRoot: (StoragePath) -> Unit,
onNavigate: (StoragePath) -> Unit, onNavigate: (StoragePath) -> Unit,
onGrantAllFiles: () -> Unit, onGrantAllFiles: () -> Unit,
onPickFolder: () -> Unit, onPickFolder: () -> Unit,
) { ) {
Box( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(contentPadding), .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 { when {
state.needsTreeGrant -> StorageAccessPrompt( state.needsTreeGrant -> StorageAccessPrompt(
folderMode = true, folderMode = true,
@@ -52,16 +81,59 @@ fun BrowserContent(
onPickFolder = onPickFolder, 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()) { state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = state.roots, key = { it.path.value }) { root -> items(items = state.roots, key = { it.path.value }) { root ->
RootRow(item = root, onClick = { onOpenRoot(root.path) }) 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( else -> DirectoryView(
state = state, state = state,
crumbLabel = crumbLabel, crumbLabel = crumbLabel,
wide = false,
onOpenEntry = onOpenEntry, onOpenEntry = onOpenEntry,
onFocusEntry = onFocusEntry,
onLongPressEntry = onLongPressEntry, onLongPressEntry = onLongPressEntry,
onNavigate = onNavigate, onNavigate = onNavigate,
onGrantAllFiles = onGrantAllFiles, onGrantAllFiles = onGrantAllFiles,
@@ -74,16 +146,20 @@ fun BrowserContent(
} }
} }
/** A directory: its breadcrumbs, then its entries as a list or a grid. */
@Composable @Composable
private fun DirectoryView( private fun DirectoryView(
state: BrowserUiState, state: BrowserUiState,
crumbLabel: (StoragePath) -> String, crumbLabel: (StoragePath) -> String,
wide: Boolean,
onOpenEntry: (FileEntry) -> Unit, onOpenEntry: (FileEntry) -> Unit,
onFocusEntry: (FileEntry) -> Unit,
onLongPressEntry: (StoragePath) -> Unit, onLongPressEntry: (StoragePath) -> Unit,
onNavigate: (StoragePath) -> Unit, onNavigate: (StoragePath) -> Unit,
onGrantAllFiles: () -> Unit, onGrantAllFiles: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
Breadcrumbs(crumbs = state.breadcrumbs, labelFor = crumbLabel, onNavigate = onNavigate) Breadcrumbs(crumbs = state.breadcrumbs, labelFor = crumbLabel, onNavigate = onNavigate)
// Full access is a better experience than the folder fallback, so offer the upgrade // Full access is a better experience than the folder fallback, so offer the upgrade
@@ -118,14 +194,20 @@ private fun DirectoryView(
return@Column 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) { when (state.viewMode) {
ViewMode.LIST -> LazyColumn(modifier = Modifier.fillMaxSize()) { ViewMode.LIST -> LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = state.entries, key = { it.path.value }) { entry -> items(items = state.entries, key = { it.path.value }) { entry ->
val open: () -> Unit = { onOpenEntry(entry) }
val highlight: () -> Unit = { onFocusEntry(entry) }
EntryRow( EntryRow(
entry = entry, entry = entry,
selected = entry.path in state.selection, selected = entry.isHighlighted(state),
onClick = { onOpenEntry(entry) }, onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) }, onLongClick = { onLongPressEntry(entry.path) },
onDoubleClick = if (wide) open else null,
) )
} }
} }
@@ -138,14 +220,21 @@ private fun DirectoryView(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(items = state.entries, key = { it.path.value }) { entry -> items(items = state.entries, key = { it.path.value }) { entry ->
val open: () -> Unit = { onOpenEntry(entry) }
val highlight: () -> Unit = { onFocusEntry(entry) }
EntryGridCell( EntryGridCell(
entry = entry, entry = entry,
selected = entry.path in state.selection, selected = entry.isHighlighted(state),
onClick = { onOpenEntry(entry) }, onClick = if (wide) highlight else open,
onLongClick = { onLongPressEntry(entry.path) }, 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 package de.softwareapp_hb.filemanager.ui.browser
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.material.icons.filled.Storage
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -30,15 +30,27 @@ import de.softwareapp_hb.filemanager.core.FileSize
import de.softwareapp_hb.filemanager.core.StoragePath import de.softwareapp_hb.filemanager.core.StoragePath
/** A row on the root screen: a volume, with its free space when the platform reports it. */ /** A row on the root screen: a volume, with its free space when the platform reports it. */
@OptIn(ExperimentalFoundationApi::class)
@Composable @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 name = item.label ?: item.labelRes?.let { stringResource(it) }.orEmpty()
val removable = item.labelRes == null val removable = item.labelRes == null
ListItem( ListItem(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick), .combinedClickable(onClick = onClick, onDoubleClick = onDoubleClick),
colors = if (selected) {
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
ListItemDefaults.colors()
},
leadingContent = { leadingContent = {
Icon( Icon(
imageVector = if (removable) Icons.Filled.SdCard else Icons.Filled.Storage, imageVector = if (removable) Icons.Filled.SdCard else Icons.Filled.Storage,
@@ -230,6 +230,8 @@ fun BrowserScreen(viewModel: BrowserViewModel = viewModel(factory = BrowserViewM
contentPadding = innerPadding, contentPadding = innerPadding,
crumbLabel = crumbLabel, crumbLabel = crumbLabel,
onOpenEntry = viewModel::open, onOpenEntry = viewModel::open,
onFocusEntry = viewModel::focus,
onFocusRoot = viewModel::focusRoot,
onLongPressEntry = viewModel::toggleSelection, onLongPressEntry = viewModel::toggleSelection,
onOpenRoot = viewModel::openDirectory, onOpenRoot = viewModel::openDirectory,
onNavigate = viewModel::openDirectory, onNavigate = viewModel::openDirectory,
@@ -49,6 +49,21 @@ data class RootItem(
val freeBytes: Long? = null, 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. */ /** A full-screen view the browser hands over to. */
sealed interface Overlay { sealed interface Overlay {
data object None : Overlay data object None : Overlay
@@ -84,6 +99,8 @@ data class BrowserUiState(
val showHidden: Boolean = false, val showHidden: Boolean = false,
val viewMode: ViewMode = ViewMode.LIST, val viewMode: ViewMode = ViewMode.LIST,
val selection: Set<StoragePath> = emptySet(), 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 clipboard: Clipboard? = null,
val loading: Boolean = false, val loading: Boolean = false,
val freeBytes: Long? = null, val freeBytes: Long? = null,
@@ -108,6 +108,7 @@ class BrowserViewModel(
path = null, path = null,
breadcrumbs = emptyList(), breadcrumbs = emptyList(),
selection = emptySet(), selection = emptySet(),
focus = null,
query = "", query = "",
freeBytes = null, freeBytes = null,
loading = false, loading = false,
@@ -127,6 +128,7 @@ class BrowserViewModel(
path = path, path = path,
breadcrumbs = breadcrumbsOf(path), breadcrumbs = breadcrumbsOf(path),
selection = emptySet(), selection = emptySet(),
focus = null,
freeBytes = storage.space(path)?.freeBytes, freeBytes = storage.space(path)?.freeBytes,
loading = false, loading = false,
) )
@@ -235,6 +237,47 @@ class BrowserViewModel(
_state.update { it.copy(selection = emptySet()) } _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 // ------------------------------------------------------------------ opening
/** Open an entry: folders navigate, images and text open in the built-in viewers. */ /** Open an entry: folders navigate, images and text open in the built-in viewers. */
@@ -30,6 +30,19 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) {
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp), modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
) )
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_name), entry.name)
DetailLine(stringResource(R.string.browser_details_location), entry.path.parent?.value ?: "/") DetailLine(stringResource(R.string.browser_details_location), entry.path.parent?.value ?: "/")
DetailLine( DetailLine(
@@ -45,8 +58,6 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) {
DetailLine(stringResource(R.string.browser_details_modified), modified) DetailLine(stringResource(R.string.browser_details_modified), modified)
} }
} }
}
}
@Composable @Composable
private fun DetailLine(label: String, value: String) { private fun DetailLine(label: String, value: String) {
+4
View File
@@ -89,6 +89,10 @@
<string name="browser_details_modified">Modified</string> <string name="browser_details_modified">Modified</string>
<string name="browser_details_folder">Folder</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) ===== --> <!-- ===== File categories (the "Type" row of the details sheet) ===== -->
<string name="browser_kind_image">Image</string> <string name="browser_kind_image">Image</string>
<string name="browser_kind_video">Video</string> <string name="browser_kind_video">Video</string>
@@ -299,4 +299,74 @@ class BrowserViewModelTest {
model.openDirectory(treeRoot) model.openDirectory(treeRoot)
assertEquals(listOf("in-tree.txt"), model.state.value.entries.map { it.name }) 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" })
}
} }
+10 -2
View File
@@ -115,8 +115,16 @@ POST_NOTIFICATIONS
Full copy and the generated graphics land in **M10** (`docs/STORE_LISTING.md`). Console Full copy and the generated graphics land in **M10** (`docs/STORE_LISTING.md`). Console
fields needed: app name, short description, full description, app icon 512×512, feature fields needed: app name, short description, full description, app icon 512×512, feature
graphic 1024×500, and at least 4 phone screenshots (Play asks for 4+ per large-screen form graphic 1024×500, and the screenshots.
factor too).
- [ ] Upload the screenshots from `store-assets/screenshots/`: **phone** 1080×2160 (5),
**7-inch tablet** 1200×1920 (4), **10-inch tablet** 2560×1600 (4). Play wants 4+ per
large-screen form factor. All three sets clear its rules — 24-bit PNG with no alpha,
shorter side ≥ 320, longer side ≤ 3840, and longer side ≤ 2× the shorter side.
`tools/make-store-screenshots.sh` enforces the aspect ratio and rejects a capture
whose app area is blank.
- [ ] Expect the 7-inch set to be single-pane and the 10-inch set two-pane. That is the
adaptive layout working, not an inconsistency — see the CHANGELOG.
## 7. Release tracks and pricing ## 7. Release tracks and pricing
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB