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.
@@ -31,6 +31,20 @@ upload needs a **higher `versionCode`** than every previous upload.
|
||||
|
||||
### 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`
|
||||
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*
|
||||
@@ -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
|
||||
centre-square crop by cropping the rendered PNG, not by trusting the arithmetic.
|
||||
|
||||
**Not finished:** the screenshots themselves. The generator and the raw-capture
|
||||
directories are in place, but no captures have been taken, so `store-assets/screenshots/`
|
||||
is still empty. The listing is publishable except for that.
|
||||
**Screenshots:** 5 phone shots at 1080×2160, 4 on a 7-inch tablet at 1200×1920 and 4 on a
|
||||
10-inch tablet at 2560×1600 — all 24-bit RGB with no alpha, inside Play's 2:1 cap. The
|
||||
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
|
||||
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,
|
||||
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,6 +30,19 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
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_location), entry.path.parent?.value ?: "/")
|
||||
DetailLine(
|
||||
@@ -44,8 +57,6 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) {
|
||||
Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified ->
|
||||
DetailLine(stringResource(R.string.browser_details_modified), modified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +115,16 @@ POST_NOTIFICATIONS
|
||||
|
||||
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
|
||||
graphic 1024×500, and at least 4 phone screenshots (Play asks for 4+ per large-screen form
|
||||
factor too).
|
||||
graphic 1024×500, and the screenshots.
|
||||
|
||||
- [ ] 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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |