diff --git a/CHANGELOG.md b/CHANGELOG.md index 231ae9d..ebfb788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt index 15f06ca..23f4d47 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserComponents.kt @@ -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 diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt index 6179158..d558d4f 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserContent.kt @@ -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 diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDetailPane.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDetailPane.kt new file mode 100644 index 0000000..37276ea --- /dev/null +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserDetailPane.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt index 9f4237d..5471c91 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserRoots.kt @@ -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, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt index 4456f36..db3bcfc 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserScreen.kt @@ -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, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt index 1824b29..052ce99 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserUiState.kt @@ -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 = 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 = 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, diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt index 46c8f13..36f8b91 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModel.kt @@ -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. */ diff --git a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt index 633d3d3..438141f 100644 --- a/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt +++ b/app/src/main/java/de/softwareapp_hb/filemanager/ui/browser/DetailsSheet.kt @@ -30,24 +30,35 @@ fun DetailsSheet(entry: FileEntry, onDismiss: () -> Unit) { style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp), ) - DetailLine(stringResource(R.string.browser_details_name), entry.name) - DetailLine(stringResource(R.string.browser_details_location), entry.path.parent?.value ?: "/") - DetailLine( - label = stringResource(R.string.browser_details_size), - value = if (entry.isDirectory) { - stringResource(R.string.browser_details_folder) - } else { - FileSize.format(entry.sizeBytes) - }, - ) - DetailLine(stringResource(R.string.browser_details_kind), stringResource(entry.kind.labelRes())) - Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified -> - DetailLine(stringResource(R.string.browser_details_modified), modified) - } + DetailLines(entry) } } } +/** + * The name/location/size/type/modified block. + * + * Shared by the phone's bottom sheet and the wide-screen detail pane, so the two can + * never drift apart. + */ +@Composable +fun DetailLines(entry: FileEntry) { + DetailLine(stringResource(R.string.browser_details_name), entry.name) + DetailLine(stringResource(R.string.browser_details_location), entry.path.parent?.value ?: "/") + DetailLine( + label = stringResource(R.string.browser_details_size), + value = if (entry.isDirectory) { + stringResource(R.string.browser_details_folder) + } else { + FileSize.format(entry.sizeBytes) + }, + ) + DetailLine(stringResource(R.string.browser_details_kind), stringResource(entry.kind.labelRes())) + Timestamps.format(entry.lastModifiedMillis).takeIf { it.isNotEmpty() }?.let { modified -> + DetailLine(stringResource(R.string.browser_details_modified), modified) + } +} + @Composable private fun DetailLine(label: String, value: String) { Row( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 435ef91..3dacba7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,6 +89,10 @@ Modified Folder + + Select an item to preview it + Loading… + Image Video diff --git a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt index 72dfd37..73df5eb 100644 --- a/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt +++ b/app/src/test/java/de/softwareapp_hb/filemanager/ui/browser/BrowserViewModelTest.kt @@ -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" }) + } } diff --git a/docs/PLAY_RELEASE_CHECKLIST.md b/docs/PLAY_RELEASE_CHECKLIST.md index af33df7..a7249d9 100644 --- a/docs/PLAY_RELEASE_CHECKLIST.md +++ b/docs/PLAY_RELEASE_CHECKLIST.md @@ -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 diff --git a/store-assets/screenshots/tablet-10in/01-roots.png b/store-assets/screenshots/tablet-10in/01-roots.png index de0c161..f533374 100644 Binary files a/store-assets/screenshots/tablet-10in/01-roots.png and b/store-assets/screenshots/tablet-10in/01-roots.png differ diff --git a/store-assets/screenshots/tablet-10in/02-folder-list.png b/store-assets/screenshots/tablet-10in/02-folder-list.png index ab5f2e5..da0c0d2 100644 Binary files a/store-assets/screenshots/tablet-10in/02-folder-list.png and b/store-assets/screenshots/tablet-10in/02-folder-list.png differ diff --git a/store-assets/screenshots/tablet-10in/03-image-preview.png b/store-assets/screenshots/tablet-10in/03-image-preview.png new file mode 100644 index 0000000..8a7349c Binary files /dev/null and b/store-assets/screenshots/tablet-10in/03-image-preview.png differ diff --git a/store-assets/screenshots/tablet-10in/03-photos-thumbnails.png b/store-assets/screenshots/tablet-10in/03-photos-thumbnails.png deleted file mode 100644 index 574a3dd..0000000 Binary files a/store-assets/screenshots/tablet-10in/03-photos-thumbnails.png and /dev/null differ diff --git a/store-assets/screenshots/tablet-10in/04-storage-usage.png b/store-assets/screenshots/tablet-10in/04-storage-usage.png index 684e2c9..b7ceb07 100644 Binary files a/store-assets/screenshots/tablet-10in/04-storage-usage.png and b/store-assets/screenshots/tablet-10in/04-storage-usage.png differ diff --git a/store-assets/screenshots/tablet-7in/01-roots.png b/store-assets/screenshots/tablet-7in/01-roots.png index f0e6d86..9181cc8 100644 Binary files a/store-assets/screenshots/tablet-7in/01-roots.png and b/store-assets/screenshots/tablet-7in/01-roots.png differ diff --git a/store-assets/screenshots/tablet-7in/02-folder-list.png b/store-assets/screenshots/tablet-7in/02-folder-list.png index 68068e1..14b18dd 100644 Binary files a/store-assets/screenshots/tablet-7in/02-folder-list.png and b/store-assets/screenshots/tablet-7in/02-folder-list.png differ diff --git a/store-assets/screenshots/tablet-7in/03-photos-thumbnails.png b/store-assets/screenshots/tablet-7in/03-photos-thumbnails.png index 78de11f..d244036 100644 Binary files a/store-assets/screenshots/tablet-7in/03-photos-thumbnails.png and b/store-assets/screenshots/tablet-7in/03-photos-thumbnails.png differ diff --git a/store-assets/screenshots/tablet-7in/04-storage-usage.png b/store-assets/screenshots/tablet-7in/04-storage-usage.png index fbaa7d4..de8ceef 100644 Binary files a/store-assets/screenshots/tablet-7in/04-storage-usage.png and b/store-assets/screenshots/tablet-7in/04-storage-usage.png differ