Let a folder be starred, and put the starred ones on the home screen
The app bar of any folder now carries a star, and a starred folder appears in a Favourites section on the home screen, above the libraries. Above, because it is the user's own list - and absent entirely while empty, so someone opening the app for the first time still meets the libraries first, which is the order they were given for a reason. The star in the row is the removal, which is what a star means everywhere else: the mark that says "this is a favourite" is the thing you tap to stop it being one. That avoids a long-press as well, and with it a destructive gesture nobody can see. A favourite is a reference, not a copy: data/BookmarkStore keeps one path per row and nothing about the folder's contents, so reading the whole list is one query and no filesystem work, which is what makes it affordable on every visit to the home screen. It has its own SQLite database rather than a table in the trash index - one table and three queries do not justify Room in a build that has no annotation processor, and the two stores have nothing to say to each other. Two behaviours there are decisions rather than defaults. A folder that has gone is kept and marked (Folder not found) instead of being deleted: a card out of its slot and a folder deleted in another app are indistinguishable from here, and silently forgetting someone's list because their SD card was out is the worse of the two mistakes. And opening the row re-checks existence at that moment, because the flag is as old as the screen - so the answer is a message rather than an empty listing, which would read as an empty folder instead of a missing one. Two smaller refusals came out of the same question. There is no star in a library or on the root screen, because neither is a folder and there would be nothing to come back to: the distinction that governs the new-folder button, and one the view model enforces too rather than trusting a stale screen. And a bookmark has no undo, because Undo means "the last change to the filesystem the app made" and a favourite is not one - the star is one tap to put back. The row's label comes from the same place the breadcrumbs do, which is why a starred volume root reads as "Internal storage" rather than as "0". Sixteen tests: nine against real SQLite through Robolectric (round trip, ordering, starring twice neither duplicating nor moving a row, and the same no-drop migration guard the trash index carries), and seven through the view model (toggling, outliving the view model that added it, newest first, the missing folder kept and marked, the missing folder refusing to open, and a library refusing to be starred). 371 JVM tests, lint clean. Verified on the phone: the star appears only inside a folder and flips from "Add to favourites" to "Remove from favourites" without leaving the screen; the home screen lists the volume root above the libraries with its own label and its path underneath; the row opens it with the star already filled; and the row's star takes the whole section away with a snackbar. The documentation moved with it - CHANGELOG, ROADMAP (M15), README, COMPETITIVE_ANALYSIS (that gap closes instead of joining the candidates), EDGE_CASE_TESTING, and the generated code map.
This commit is contained in:
@@ -6,12 +6,14 @@ import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.video.VideoFrameDecoder
|
||||
import de.softwareapp_hb.filemanager.data.ApkInspector
|
||||
import de.softwareapp_hb.filemanager.data.BookmarkStore
|
||||
import de.softwareapp_hb.filemanager.data.ContentResolverIncomingSource
|
||||
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
|
||||
import de.softwareapp_hb.filemanager.data.IncomingSource
|
||||
import de.softwareapp_hb.filemanager.data.MediaStoreDeleter
|
||||
import de.softwareapp_hb.filemanager.data.PackageManagerApkInspector
|
||||
import de.softwareapp_hb.filemanager.data.SearchIndex
|
||||
import de.softwareapp_hb.filemanager.data.SqliteBookmarkStore
|
||||
import de.softwareapp_hb.filemanager.data.SqliteSearchIndex
|
||||
import de.softwareapp_hb.filemanager.data.SqliteTrashIndex
|
||||
import de.softwareapp_hb.filemanager.data.StorageProvider
|
||||
@@ -22,9 +24,9 @@ import de.softwareapp_hb.filemanager.service.ServiceOperationNotifier
|
||||
/**
|
||||
* Application entry point and the app's tiny object graph.
|
||||
*
|
||||
* There is no dependency-injection framework here on purpose: four long-lived collaborators
|
||||
* are built once and handed to the one ViewModel that needs them. Nothing holds a reference
|
||||
* to an Activity, so none of this leaks.
|
||||
* There is no dependency-injection framework here on purpose: the long-lived collaborators below are
|
||||
* built once and handed to the one ViewModel that needs them. Nothing holds a reference to an
|
||||
* Activity, so none of this leaks.
|
||||
*/
|
||||
class FileManagerApplication : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
@@ -56,6 +58,10 @@ class FileManagerApplication : Application(), SingletonImageLoader.Factory {
|
||||
lateinit var searchIndex: SearchIndex
|
||||
private set
|
||||
|
||||
/** Remembers the folders the user starred, so the home screen can offer them back. */
|
||||
lateinit var bookmarkStore: BookmarkStore
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
storage = StorageProvider(this)
|
||||
@@ -65,6 +71,7 @@ class FileManagerApplication : Application(), SingletonImageLoader.Factory {
|
||||
trashIndex = SqliteTrashIndex(this)
|
||||
apkInspector = PackageManagerApkInspector(this)
|
||||
searchIndex = SqliteSearchIndex(this)
|
||||
bookmarkStore = SqliteBookmarkStore(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.softwareapp_hb.filemanager.data
|
||||
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
|
||||
/**
|
||||
* The folders the user starred.
|
||||
*
|
||||
* A favourite is a **reference**, not a copy: nothing about the folder's contents is stored, so
|
||||
* reading the whole list is one small query and no filesystem work at all. That is what makes it
|
||||
* cheap enough to read every time the home screen appears.
|
||||
*
|
||||
* Storing a path and nothing else has one consequence worth stating, because it is a decision
|
||||
* rather than an oversight: whether a starred folder still *exists* is not this store's question.
|
||||
* A card taken out of the slot and a folder deleted in another app look exactly alike from here, so
|
||||
* the list keeps its entry and the screen marks it as not found — silently forgetting someone's
|
||||
* list because an SD card was out of the device would be the worse of the two mistakes.
|
||||
*/
|
||||
interface BookmarkStore {
|
||||
|
||||
/** Every starred folder, most recently added first. */
|
||||
suspend fun bookmarks(): List<StoragePath>
|
||||
|
||||
/**
|
||||
* Star [path], recorded as added at [addedAtMillis].
|
||||
*
|
||||
* Starring a folder that is already starred is not an error and does **not** move it: a
|
||||
* favourite's place in the list belongs to the moment it was added, not to the last time
|
||||
* something asked for it.
|
||||
*/
|
||||
suspend fun add(path: StoragePath, addedAtMillis: Long)
|
||||
|
||||
/** Un-star [path]. Removing one that is not starred is not an error. */
|
||||
suspend fun remove(path: StoragePath)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package de.softwareapp_hb.filemanager.data
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* The starred folders, on SQLite.
|
||||
*
|
||||
* Its own database rather than a table in the trash index: the two have nothing to say to each
|
||||
* other, and a favourite list that cannot be lost by a bug in the trash schema is worth one more
|
||||
* file. No Room, for the same reason as [SqliteTrashIndex] — one table and three queries do not
|
||||
* justify an annotation processor in a build that has none, and Robolectric runs the platform's
|
||||
* SQLite on the JVM.
|
||||
*/
|
||||
class SqliteBookmarkStore(context: Context) : BookmarkStore {
|
||||
|
||||
private val helper = Helper(context.applicationContext)
|
||||
|
||||
override suspend fun bookmarks(): List<StoragePath> = withContext(Dispatchers.IO) {
|
||||
helper.readableDatabase.query(
|
||||
TABLE,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
// Newest first, and stable between two entries added in the same millisecond: the order
|
||||
// a person added things is the only order that means anything here.
|
||||
"$COLUMN_ADDED_AT DESC, $COLUMN_PATH ASC",
|
||||
).use { cursor ->
|
||||
buildList {
|
||||
while (cursor.moveToNext()) add(StoragePath.of(cursor.getString(0)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun add(path: StoragePath, addedAtMillis: Long) = withContext(Dispatchers.IO) {
|
||||
// IGNORE rather than REPLACE: re-starring something that is already starred must not move
|
||||
// it to the top, so the row keeps the timestamp from the first time it was added.
|
||||
val values = ContentValues().apply {
|
||||
put(COLUMN_PATH, path.value)
|
||||
put(COLUMN_ADDED_AT, addedAtMillis)
|
||||
}
|
||||
helper.writableDatabase.insertWithOnConflict(TABLE, null, values, SQLiteDatabase.CONFLICT_IGNORE)
|
||||
Unit
|
||||
}
|
||||
|
||||
override suspend fun remove(path: StoragePath) = withContext(Dispatchers.IO) {
|
||||
helper.writableDatabase.delete(TABLE, "$COLUMN_PATH = ?", arrayOf(path.value))
|
||||
Unit
|
||||
}
|
||||
|
||||
private class Helper(context: Context) : SQLiteOpenHelper(
|
||||
context,
|
||||
DATABASE_NAME,
|
||||
null,
|
||||
DATABASE_VERSION,
|
||||
) {
|
||||
override fun onCreate(db: SQLiteDatabase) = create(db)
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) =
|
||||
upgrade(db, oldVersion, newVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
* The table's shape, and how that shape moves forward.
|
||||
*
|
||||
* `internal` rather than `private` for the same reason as the trash index: a test can drive
|
||||
* `create` and `upgrade` against a throwaway database, which is the only way to prove the
|
||||
* upgrade path before a version 2 exists.
|
||||
*/
|
||||
internal companion object {
|
||||
const val DATABASE_NAME = "bookmarks.db"
|
||||
const val DATABASE_VERSION = 1
|
||||
const val TABLE = "bookmarks"
|
||||
const val COLUMN_PATH = "path"
|
||||
const val COLUMN_ADDED_AT = "added_at"
|
||||
|
||||
fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE (
|
||||
$COLUMN_PATH TEXT NOT NULL PRIMARY KEY,
|
||||
$COLUMN_ADDED_AT INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX bookmarks_added_at ON $TABLE ($COLUMN_ADDED_AT)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring [db] from [oldVersion] up to [newVersion], one version at a time.
|
||||
*
|
||||
* Version 1 is the first this app has shipped, so the loop is empty. The *shape* is the
|
||||
* point: when version 2 arrives, its step is the work that gets there. **A bare `DROP`
|
||||
* must never appear here.** Losing a favourite list is not catastrophic the way losing the
|
||||
* trash index is, but it is the user's own list, and a schema bump that silently empties it
|
||||
* is not a migration — which is why a version with no step reports itself instead of
|
||||
* recreating the table.
|
||||
*/
|
||||
fun upgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
for (version in oldVersion until newVersion) {
|
||||
error(
|
||||
"No migration from bookmarks schema version $version to ${version + 1}. " +
|
||||
"Add one to SqliteBookmarkStore.upgrade and bump DATABASE_VERSION; " +
|
||||
"do not drop the table.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ fun BrowserContent(
|
||||
onOpenRoot: (StoragePath) -> Unit,
|
||||
onNavigate: (StoragePath) -> Unit,
|
||||
onOpenCategory: (FileCategory) -> Unit,
|
||||
onOpenBookmark: (StoragePath) -> Unit,
|
||||
onRemoveBookmark: (StoragePath) -> Unit,
|
||||
onGrantAllFiles: () -> Unit,
|
||||
onPickFolder: () -> Unit,
|
||||
onRebuildIndex: () -> Unit,
|
||||
@@ -139,6 +141,12 @@ fun BrowserContent(
|
||||
|
||||
state.isRootView && wide -> Row(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(modifier = Modifier.width(MasterPaneWidth), state = listState) {
|
||||
bookmarkSection(
|
||||
state = state,
|
||||
crumbLabel = crumbLabel,
|
||||
onOpenBookmark = onOpenBookmark,
|
||||
onRemoveBookmark = onRemoveBookmark,
|
||||
)
|
||||
librarySection(
|
||||
state = state,
|
||||
onOpenCategory = onOpenCategory,
|
||||
@@ -162,6 +170,12 @@ fun BrowserContent(
|
||||
}
|
||||
|
||||
state.isRootView -> LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
|
||||
bookmarkSection(
|
||||
state = state,
|
||||
crumbLabel = crumbLabel,
|
||||
onOpenBookmark = onOpenBookmark,
|
||||
onRemoveBookmark = onRemoveBookmark,
|
||||
)
|
||||
librarySection(
|
||||
state = state,
|
||||
onOpenCategory = onOpenCategory,
|
||||
@@ -207,6 +221,35 @@ private fun LazyListScope.librarySection(
|
||||
/** A key that cannot collide with a folder's path, which always starts with `/`. */
|
||||
private const val LIBRARY_SECTION_KEY = "libraries-section"
|
||||
|
||||
/**
|
||||
* The home screen's starred folders, as items for the root list.
|
||||
*
|
||||
* Above the libraries because it is the user's own list — and absent entirely while it is empty, so
|
||||
* someone opening the app for the first time still meets the libraries first. [crumbLabel] is the
|
||||
* browser's own naming, which is what makes a starred volume read as "Internal storage" rather than
|
||||
* as its last path segment.
|
||||
*/
|
||||
private fun LazyListScope.bookmarkSection(
|
||||
state: BrowserUiState,
|
||||
crumbLabel: (StoragePath) -> String,
|
||||
onOpenBookmark: (StoragePath) -> Unit,
|
||||
onRemoveBookmark: (StoragePath) -> Unit,
|
||||
) {
|
||||
if (state.bookmarks.isEmpty()) return
|
||||
item(key = BOOKMARK_SECTION_KEY) { BookmarkSectionHeader() }
|
||||
items(items = state.bookmarks, key = { "bookmark-${it.path.value}" }) { bookmark ->
|
||||
BookmarkRow(
|
||||
label = crumbLabel(bookmark.path),
|
||||
bookmark = bookmark,
|
||||
onClick = { onOpenBookmark(bookmark.path) },
|
||||
onRemove = { onRemoveBookmark(bookmark.path) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A key that cannot collide with a bookmark's path, which always starts with `/`. */
|
||||
private const val BOOKMARK_SECTION_KEY = "bookmarks-section"
|
||||
|
||||
/** A directory: its breadcrumbs, then its entries as a list or a grid. */
|
||||
@Composable
|
||||
private fun DirectoryView(
|
||||
|
||||
@@ -11,9 +11,12 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material.icons.filled.SdCard
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -92,15 +95,89 @@ fun EmptyState(message: String, modifier: Modifier = Modifier) {
|
||||
|
||||
/** The heading over the libraries on the home screen. */
|
||||
@Composable
|
||||
fun LibrarySectionHeader(modifier: Modifier = Modifier) {
|
||||
fun LibrarySectionHeader(modifier: Modifier = Modifier) =
|
||||
SectionHeader(stringResource(R.string.library_section), modifier)
|
||||
|
||||
/** The heading over the starred folders on the home screen. */
|
||||
@Composable
|
||||
fun BookmarkSectionHeader(modifier: Modifier = Modifier) =
|
||||
SectionHeader(stringResource(R.string.bookmark_section), modifier)
|
||||
|
||||
/** One heading for every section, so the home screen's two lists cannot drift apart. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.library_section),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row on the home screen: one starred folder.
|
||||
*
|
||||
* The filled star in the row **is** the removal, which is the pattern a star has everywhere else —
|
||||
* the mark that says "this is a favourite" is the thing you tap to stop it being one. [label] comes
|
||||
* from the same place the breadcrumbs do, so a starred volume reads as "Internal storage" rather
|
||||
* than as its last path segment.
|
||||
*
|
||||
* A folder that has gone keeps its place and says so instead of disappearing: see
|
||||
* [BrowserUiState.bookmarks].
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun BookmarkRow(
|
||||
label: String,
|
||||
bookmark: Bookmark,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val tint = if (bookmark.exists) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
ListItem(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Folder,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(label) },
|
||||
supportingContent = {
|
||||
// The full path is what tells two folders with the same name apart, and it is what the
|
||||
// details sheet shows for a file as well.
|
||||
Text(
|
||||
text = if (bookmark.exists) {
|
||||
bookmark.path.value
|
||||
} else {
|
||||
stringResource(R.string.bookmark_missing)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = stringResource(R.string.browser_action_unbookmark),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row on the home screen: one library, and how many files the index knows it holds.
|
||||
*
|
||||
|
||||
@@ -357,6 +357,10 @@ fun BrowserScreen(
|
||||
BrowserTopBar(
|
||||
title = title,
|
||||
canNavigateUp = !state.isRootView,
|
||||
// A library is not a folder, so there is nothing to come back to: the star means
|
||||
// "come back to *this folder*", which only exists when there is a folder on screen.
|
||||
canBookmark = state.path != null,
|
||||
bookmarked = state.isCurrentFolderBookmarked,
|
||||
query = state.query,
|
||||
searchOpen = searchOpen,
|
||||
searchScope = state.searchScope,
|
||||
@@ -388,6 +392,7 @@ fun BrowserScreen(
|
||||
},
|
||||
onSort = { showSort = true },
|
||||
onPaste = viewModel::paste,
|
||||
onToggleBookmark = viewModel::toggleBookmark,
|
||||
onRefresh = viewModel::refresh,
|
||||
onUsage = viewModel::openUsage,
|
||||
onDuplicates = viewModel::findDuplicates,
|
||||
@@ -480,6 +485,8 @@ fun BrowserScreen(
|
||||
onOpenRoot = viewModel::openDirectory,
|
||||
onNavigate = viewModel::openDirectory,
|
||||
onOpenCategory = viewModel::openCategory,
|
||||
onOpenBookmark = viewModel::openBookmark,
|
||||
onRemoveBookmark = viewModel::removeBookmark,
|
||||
onGrantAllFiles = {
|
||||
val missing = provider.missingRuntimePermissions()
|
||||
if (missing.isEmpty()) {
|
||||
|
||||
@@ -15,11 +15,14 @@ import androidx.compose.material.icons.filled.ContentPaste
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Sort
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -38,12 +41,15 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.softwareapp_hb.filemanager.R
|
||||
|
||||
/** The browser's app bar: title, up, and the search / sort / overflow actions. */
|
||||
/** The browser's app bar: title, up, the favourite star, and the search / sort / overflow actions. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BrowserTopBar(
|
||||
title: String,
|
||||
canNavigateUp: Boolean,
|
||||
/** Whether this view is a folder, and so can be starred at all. */
|
||||
canBookmark: Boolean,
|
||||
bookmarked: Boolean,
|
||||
query: String,
|
||||
searchOpen: Boolean,
|
||||
searchScope: SearchScope,
|
||||
@@ -56,6 +62,7 @@ fun BrowserTopBar(
|
||||
onSearchScope: () -> Unit,
|
||||
onSort: () -> Unit,
|
||||
onPaste: () -> Unit,
|
||||
onToggleBookmark: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onUsage: () -> Unit,
|
||||
onDuplicates: () -> Unit,
|
||||
@@ -147,6 +154,27 @@ fun BrowserTopBar(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (canBookmark) {
|
||||
IconButton(onClick = onToggleBookmark) {
|
||||
Icon(
|
||||
// An outline is the invitation and a filled star is the state, which is
|
||||
// how a star behaves everywhere else it appears.
|
||||
imageVector = if (bookmarked) Icons.Filled.Star else Icons.Filled.StarBorder,
|
||||
contentDescription = stringResource(
|
||||
if (bookmarked) {
|
||||
R.string.browser_action_unbookmark
|
||||
} else {
|
||||
R.string.browser_action_bookmark
|
||||
},
|
||||
),
|
||||
tint = if (bookmarked) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onSearchOpen(true) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
|
||||
@@ -70,6 +70,16 @@ data class RootItem(
|
||||
val freeBytes: Long? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One starred folder as the home screen shows it.
|
||||
*
|
||||
* [exists] is a **guess with a timestamp**: it is checked when the home screen is read, and a card
|
||||
* can be pulled or a folder deleted a second later. So the view model checks again when the row is
|
||||
* opened — a stale `true` then costs a message, rather than an empty listing that would read as
|
||||
* "this folder is empty" instead of "this folder is gone".
|
||||
*/
|
||||
data class Bookmark(val path: StoragePath, val exists: Boolean = true)
|
||||
|
||||
/**
|
||||
* The entry highlighted in the master pane on a wide screen, and what the detail pane
|
||||
* shows for it.
|
||||
@@ -220,6 +230,11 @@ data class BrowserUiState(
|
||||
* know — the same distinction the search row draws between "not indexed yet" and "none".
|
||||
*/
|
||||
val libraryCounts: Map<FileCategory, Int> = emptyMap(),
|
||||
/**
|
||||
* The starred folders, newest first: the user's own list, shown above the libraries on the
|
||||
* home screen.
|
||||
*/
|
||||
val bookmarks: List<Bookmark> = emptyList(),
|
||||
val entries: List<FileEntry> = emptyList(),
|
||||
val breadcrumbs: List<StoragePath> = emptyList(),
|
||||
val query: String = "",
|
||||
@@ -278,6 +293,15 @@ data class BrowserUiState(
|
||||
) {
|
||||
val isRootView: Boolean get() = path == null && category == null
|
||||
|
||||
/**
|
||||
* Whether the folder on screen is starred.
|
||||
*
|
||||
* `false` on the root list and in a library, neither of which is a folder — the same distinction
|
||||
* the new-folder button draws, and the reason the screen shows no star there.
|
||||
*/
|
||||
val isCurrentFolderBookmarked: Boolean
|
||||
get() = path != null && bookmarks.any { it.path == path }
|
||||
|
||||
val isSelecting: Boolean get() = selection.isNotEmpty()
|
||||
|
||||
val selectedEntries: List<FileEntry> get() = entries.filter { it.path in selection }
|
||||
|
||||
@@ -26,6 +26,7 @@ import de.softwareapp_hb.filemanager.core.StoragePath
|
||||
import de.softwareapp_hb.filemanager.core.TransferAction
|
||||
import de.softwareapp_hb.filemanager.core.isUsableName
|
||||
import de.softwareapp_hb.filemanager.data.ApkInspector
|
||||
import de.softwareapp_hb.filemanager.data.BookmarkStore
|
||||
import de.softwareapp_hb.filemanager.data.DeleteRequestFactory
|
||||
import de.softwareapp_hb.filemanager.data.IncomingSource
|
||||
import de.softwareapp_hb.filemanager.data.IndexedFile
|
||||
@@ -82,6 +83,13 @@ class BrowserViewModel(
|
||||
private val savedState: SavedStateHandle = SavedStateHandle(),
|
||||
private val incoming: IncomingSource? = null,
|
||||
private val trashIndex: TrashIndex = TrashIndex.None,
|
||||
/**
|
||||
* Remembers the folders the user starred.
|
||||
*
|
||||
* Required rather than defaulted: a view model with nowhere to keep favourites would have a
|
||||
* star that lights up and then forgets, which is worse than not offering one.
|
||||
*/
|
||||
private val bookmarkStore: BookmarkStore,
|
||||
private val apkInspector: ApkInspector = ApkInspector.None,
|
||||
/** Remembers what is on the volumes by name; [SearchIndex.None] searches live instead. */
|
||||
private val searchIndex: SearchIndex = SearchIndex.None,
|
||||
@@ -140,6 +148,10 @@ class BrowserViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
// The star's state and the home screen's list live in the store rather than in the saved
|
||||
// view state, so they are read again whenever everything else is.
|
||||
viewModelScope.launch { refreshBookmarks() }
|
||||
|
||||
val path = _state.value.path
|
||||
_state.value.category?.let { return loadLibrary(it, walk = false) }
|
||||
if (path == null) loadRoots() else load(path)
|
||||
@@ -147,6 +159,79 @@ class BrowserViewModel(
|
||||
|
||||
fun openDirectory(path: StoragePath) = load(path)
|
||||
|
||||
// -------------------------------------------------------------- favourites
|
||||
|
||||
/**
|
||||
* Star the folder on screen, or un-star it if it already is.
|
||||
*
|
||||
* There is nothing to star on the root list or in a library: neither is a folder, so there
|
||||
* would be nothing to come back to. The screen hides the star there, and this refuses the tap
|
||||
* as well rather than trusting a stale screen.
|
||||
*/
|
||||
fun toggleBookmark() {
|
||||
val path = _state.value.path ?: return
|
||||
val starred = _state.value.bookmarks.any { it.path == path }
|
||||
viewModelScope.launch {
|
||||
if (starred) {
|
||||
bookmarkStore.remove(path)
|
||||
message(R.string.message_bookmark_removed)
|
||||
} else {
|
||||
bookmarkStore.add(path, clock())
|
||||
message(R.string.message_bookmark_added)
|
||||
}
|
||||
refreshBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
/** Un-star [path] from the home screen, where the star on the row is the action. */
|
||||
fun removeBookmark(path: StoragePath) {
|
||||
viewModelScope.launch {
|
||||
bookmarkStore.remove(path)
|
||||
message(R.string.message_bookmark_removed)
|
||||
refreshBookmarks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a starred folder, or say that it has gone.
|
||||
*
|
||||
* Checked again here rather than trusting [Bookmark.exists]: that flag is as old as the home
|
||||
* screen, and a card can be pulled or a folder deleted in another app between the two. Opening
|
||||
* it regardless would show an empty listing, which reads as an *empty folder* rather than a
|
||||
* missing one.
|
||||
*/
|
||||
fun openBookmark(path: StoragePath) {
|
||||
val source = storage.activeSource() ?: return
|
||||
viewModelScope.launch {
|
||||
if (runCatching { source.exists(path) }.getOrDefault(false)) {
|
||||
load(path)
|
||||
} else {
|
||||
message(R.string.message_bookmark_missing)
|
||||
refreshBookmarks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read the starred folders, with whether each is still there. */
|
||||
private suspend fun refreshBookmarks() {
|
||||
val source = storage.activeSource()
|
||||
_state.update { it.copy(bookmarks = bookmarksOf(source)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Each starred folder, marked with whether it is still there.
|
||||
*
|
||||
* One `exists` per bookmark, which is only affordable because the list is short — and worth it,
|
||||
* because a row that opens a folder which is not there looks like an empty folder instead.
|
||||
* With no source at all the answer is "unknown", which is reported as present: the permission
|
||||
* prompt is what is on screen in that case, and calling every favourite gone would be a lie.
|
||||
*/
|
||||
private suspend fun bookmarksOf(source: StorageSource?): List<Bookmark> =
|
||||
bookmarkStore.bookmarks().map { path ->
|
||||
val exists = source == null || runCatching { source.exists(path) }.getOrDefault(false)
|
||||
Bookmark(path, exists = exists)
|
||||
}
|
||||
|
||||
fun navigateUp() {
|
||||
// A library has nowhere above it but the root list.
|
||||
if (_state.value.category != null) return loadRoots()
|
||||
@@ -177,6 +262,9 @@ class BrowserViewModel(
|
||||
// The counts belong to the root screen, so they are read when it is shown
|
||||
// rather than kept from whenever the index last ran.
|
||||
libraryCounts = libraryCountsOf(),
|
||||
// The same reasoning as the counts: a folder deleted since the list was last
|
||||
// read should stop being offered as openable.
|
||||
bookmarks = bookmarksOf(storage.activeSource()),
|
||||
)
|
||||
}
|
||||
recompute()
|
||||
@@ -1993,6 +2081,7 @@ class BrowserViewModel(
|
||||
savedState = createSavedStateHandle(),
|
||||
incoming = app.incoming,
|
||||
trashIndex = app.trashIndex,
|
||||
bookmarkStore = app.bookmarkStore,
|
||||
apkInspector = app.apkInspector,
|
||||
searchIndex = app.searchIndex,
|
||||
scratchRoot = app.cacheDir.resolve(SCRATCH_DIRECTORY),
|
||||
|
||||
@@ -65,6 +65,19 @@
|
||||
library is not a folder, and what is missing here is an index, not files. -->
|
||||
<string name="library_empty">No files indexed here yet</string>
|
||||
|
||||
<!-- ===== Browser: favourites =====
|
||||
Folders the user starred, shown above the libraries on the home screen. The identifier is
|
||||
"bookmark" throughout the code; the word a reader sees is "favourite", which is what the
|
||||
row and the star's description say. -->
|
||||
<string name="bookmark_section">Favourites</string>
|
||||
<string name="browser_action_bookmark">Add to favourites</string>
|
||||
<string name="browser_action_unbookmark">Remove from favourites</string>
|
||||
<!-- A starred folder that is not there any more, e.g. on a card that is out of the slot. -->
|
||||
<string name="bookmark_missing">Folder not found</string>
|
||||
<string name="message_bookmark_added">Added to favourites</string>
|
||||
<string name="message_bookmark_removed">Removed from favourites</string>
|
||||
<string name="message_bookmark_missing">That folder no longer exists</string>
|
||||
|
||||
<!-- ===== Browser: duplicates =====
|
||||
A scan compares file contents, so every line here is about what was read and what was
|
||||
found, never about what a name or a date suggests. -->
|
||||
|
||||
Reference in New Issue
Block a user