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
|
### 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) {
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
|
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 |