library improvements: search
This commit is contained in:
1 parent
ade4fb1896
commit
1239f15836
10 files changed
+618
-28
No files matched your search
@@ -116,6 +116,9 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
||||
}
|
||||
val message = report.fold(
|
||||
onSuccess = {
|
||||
if (isDownloadsScanPath(path)) {
|
||||
saveDownloadsWasScanned(true)
|
||||
}
|
||||
"Scanned ${it.scannedFiles}, imported ${it.importedFiles}, skipped ${it.skippedFiles}, failed ${it.failedFiles}."
|
||||
},
|
||||
onFailure = { it.message ?: "Scan failed." },
|
||||
@@ -138,6 +141,7 @@ private fun BookReaderApp(onThemeToggle: () -> Unit) {
|
||||
activeScan = activeScan,
|
||||
onStateChange = { state = it },
|
||||
onNavigateToScan = { state = AppState.Scan(current.items, current.scanPath, current.message) },
|
||||
onStartScan = ::startScan,
|
||||
)
|
||||
is AppState.Scan -> ScanScreen(
|
||||
state = current,
|
||||
|
||||
@@ -17,6 +17,7 @@ data class LibraryItem(
|
||||
val lastSeenAt: Long?,
|
||||
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
|
||||
val lastReadAt: Long? = null,
|
||||
val importedAt: Long? = null,
|
||||
val coverImage: ByteArray? = null,
|
||||
val coverImageMimeType: String? = null,
|
||||
)
|
||||
@@ -89,6 +90,8 @@ expect suspend fun loadLibraryItems(): List<LibraryItem>
|
||||
|
||||
expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem>
|
||||
|
||||
expect suspend fun searchLibraryItems(query: String, limit: Int = 100): List<LibraryItem>
|
||||
|
||||
expect suspend fun loadLibraryItem(fileId: String): LibraryItem?
|
||||
|
||||
expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover?
|
||||
@@ -115,6 +118,18 @@ expect suspend fun loadThemeMode(): ThemeMode
|
||||
|
||||
expect suspend fun saveThemeMode(mode: ThemeMode)
|
||||
|
||||
expect suspend fun loadScanDownloadsAutomatically(): Boolean
|
||||
|
||||
expect suspend fun saveScanDownloadsAutomatically(enabled: Boolean)
|
||||
|
||||
expect suspend fun loadDownloadsWasScanned(): Boolean
|
||||
|
||||
expect suspend fun saveDownloadsWasScanned(scanned: Boolean)
|
||||
|
||||
expect fun downloadsScanPath(): String?
|
||||
|
||||
expect fun isDownloadsScanPath(path: String): Boolean
|
||||
|
||||
expect fun isPlatformDarkTheme(): Boolean
|
||||
|
||||
expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit
|
||||
|
||||
@@ -14,16 +14,19 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -33,8 +36,11 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -47,6 +53,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -64,6 +75,7 @@ internal fun LibraryScreen(
|
||||
activeScan: LibraryScanProgress?,
|
||||
onStateChange: (AppState) -> Unit,
|
||||
onNavigateToScan: () -> Unit,
|
||||
onStartScan: (String) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -73,7 +85,15 @@ internal fun LibraryScreen(
|
||||
var loadingPage by remember(state.items) { mutableStateOf(false) }
|
||||
var endReached by remember(state.items) { mutableStateOf(false) }
|
||||
var wasScanning by remember { mutableStateOf(false) }
|
||||
var settingsMenuOpen by remember { mutableStateOf(false) }
|
||||
var autoScanDownloads by remember { mutableStateOf(true) }
|
||||
var autoScanSettingLoaded by remember { mutableStateOf(false) }
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
|
||||
val searchActive = searchText.isNotBlank()
|
||||
val visibleItems = if (searchActive) searchResults else items
|
||||
|
||||
suspend fun loadPage(reset: Boolean = false) {
|
||||
if (loadingPage) return
|
||||
@@ -100,13 +120,67 @@ internal fun LibraryScreen(
|
||||
|
||||
fun refresh(nextMessage: String? = message) {
|
||||
message = nextMessage
|
||||
scope.launch { loadPage(reset = true) }
|
||||
scope.launch {
|
||||
if (searchActive) {
|
||||
searching = true
|
||||
searchResults = searchLibraryItems(searchText, SearchResultLimit)
|
||||
searching = false
|
||||
} else {
|
||||
loadPage(reset = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSearch() {
|
||||
searchText = ""
|
||||
searchResults = emptyList()
|
||||
searching = false
|
||||
}
|
||||
|
||||
LaunchedEffect(state.scanPath, state.message) {
|
||||
if (items.isEmpty() && !endReached) loadPage(reset = true)
|
||||
}
|
||||
|
||||
LaunchedEffect(searchText) {
|
||||
val query = searchText
|
||||
if (query.isBlank()) {
|
||||
searchResults = emptyList()
|
||||
searching = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
searching = true
|
||||
delay(1_000)
|
||||
runCatching { searchLibraryItems(query, SearchResultLimit) }
|
||||
.onSuccess {
|
||||
if (searchText == query) {
|
||||
searchResults = it
|
||||
message = null
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
if (searchText == query) {
|
||||
searchResults = emptyList()
|
||||
message = it.message ?: "Search failed."
|
||||
}
|
||||
}
|
||||
if (searchText == query) searching = false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
autoScanDownloads = loadScanDownloadsAutomatically()
|
||||
autoScanSettingLoaded = true
|
||||
}
|
||||
|
||||
LaunchedEffect(autoScanSettingLoaded, autoScanDownloads) {
|
||||
if (!autoScanSettingLoaded || !autoScanDownloads || activeScan != null) return@LaunchedEffect
|
||||
if (loadDownloadsWasScanned()) {
|
||||
downloadsScanPath()?.let { path ->
|
||||
message = "Scanning Downloads..."
|
||||
onStartScan(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activeScan != null) {
|
||||
if (activeScan != null) {
|
||||
wasScanning = true
|
||||
@@ -122,8 +196,15 @@ internal fun LibraryScreen(
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("Library") },
|
||||
TopAppBar(
|
||||
title = {
|
||||
LibrarySearchField(
|
||||
value = searchText,
|
||||
onValueChange = { searchText = it },
|
||||
onClear = ::clearSearch,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
@@ -131,6 +212,25 @@ internal fun LibraryScreen(
|
||||
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { settingsMenuOpen = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = "Library options")
|
||||
}
|
||||
DropdownMenu(expanded = settingsMenuOpen, onDismissRequest = { settingsMenuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Checkbox(checked = autoScanDownloads, onCheckedChange = null)
|
||||
},
|
||||
text = { Text("Scan Downloads automatically") },
|
||||
onClick = {
|
||||
val next = !autoScanDownloads
|
||||
autoScanDownloads = next
|
||||
settingsMenuOpen = false
|
||||
scope.launch { saveScanDownloadsAutomatically(next) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -144,32 +244,44 @@ internal fun LibraryScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && searchText.isNotBlank()) {
|
||||
clearSearch()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.background(readerBackground()),
|
||||
) {
|
||||
val wide = maxWidth >= 800.dp
|
||||
if (items.isEmpty() && loadingPage) {
|
||||
if (visibleItems.isEmpty() && (loadingPage || searching)) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else if (items.isEmpty()) {
|
||||
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
|
||||
} else if (visibleItems.isEmpty()) {
|
||||
if (searchActive) {
|
||||
EmptySearchPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
|
||||
} else {
|
||||
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val hasReadingNow = items.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
||||
val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING
|
||||
if (hasReadingNow) {
|
||||
item(key = "section-reading") {
|
||||
LibrarySectionHeader("reading now")
|
||||
}
|
||||
}
|
||||
itemsIndexed(items, key = { _, item -> item.fileId }) { index, item ->
|
||||
itemsIndexed(visibleItems, key = { _, item -> item.fileId }) { index, item ->
|
||||
if (
|
||||
hasReadingNow &&
|
||||
item.readingStatus != BookReadingStatus.READING &&
|
||||
(index == 0 || items[index - 1].readingStatus == BookReadingStatus.READING)
|
||||
(index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING)
|
||||
) {
|
||||
LibrarySectionHeader("my library")
|
||||
}
|
||||
@@ -189,12 +301,12 @@ internal fun LibraryScreen(
|
||||
AppState.Reader(
|
||||
fileId = item.fileId,
|
||||
book = book,
|
||||
libraryItems = items,
|
||||
libraryItems = visibleItems,
|
||||
scanPath = state.scanPath,
|
||||
message = message,
|
||||
)
|
||||
}.getOrElse {
|
||||
AppState.Library(items, state.scanPath, it.message ?: "Could not open book.")
|
||||
AppState.Library(visibleItems, state.scanPath, it.message ?: "Could not open book.")
|
||||
}
|
||||
onStateChange(next)
|
||||
} finally {
|
||||
@@ -208,7 +320,7 @@ internal fun LibraryScreen(
|
||||
try {
|
||||
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
|
||||
message = "Marked ${item.title} as read."
|
||||
loadPage(reset = true)
|
||||
refresh()
|
||||
} else {
|
||||
message = "Could not update ${item.title}."
|
||||
}
|
||||
@@ -223,7 +335,7 @@ internal fun LibraryScreen(
|
||||
try {
|
||||
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
|
||||
message = "Marked ${item.title} as unread."
|
||||
loadPage(reset = true)
|
||||
refresh()
|
||||
} else {
|
||||
message = "Could not update ${item.title}."
|
||||
}
|
||||
@@ -238,7 +350,7 @@ internal fun LibraryScreen(
|
||||
try {
|
||||
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
|
||||
message = "Marked ${item.title} as not interesting."
|
||||
loadPage(reset = true)
|
||||
refresh()
|
||||
} else {
|
||||
message = "Could not update ${item.title}."
|
||||
}
|
||||
@@ -255,6 +367,7 @@ internal fun LibraryScreen(
|
||||
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
|
||||
if (deleted) {
|
||||
items = items.filterNot { it.fileId == item.fileId }
|
||||
searchResults = searchResults.filterNot { it.fileId == item.fileId }
|
||||
coverCache.remove(item.fileId)
|
||||
nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
|
||||
}
|
||||
@@ -265,7 +378,7 @@ internal fun LibraryScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!endReached) {
|
||||
if (!searchActive && !endReached) {
|
||||
item(key = "load-more") {
|
||||
LaunchedEffect(nextOffset, items.size) {
|
||||
if (!loadingPage) loadPage()
|
||||
@@ -292,6 +405,63 @@ internal fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibrarySearchField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(18.dp)
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
modifier = modifier
|
||||
.height(56.dp)
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape && value.isNotBlank()) {
|
||||
onClear()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
"Search library",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = "Search library",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (value.isNotBlank()) {
|
||||
IconButton(onClick = onClear, modifier = Modifier.size(34.dp)) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Clear search", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = shape,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
@@ -304,6 +474,13 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySearchPane(modifier: Modifier = Modifier) {
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
Text("No matches", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibrarySectionHeader(text: String) {
|
||||
Text(
|
||||
@@ -519,3 +696,4 @@ private fun LibraryScanProgress.toCatalogScanMessage(): String {
|
||||
}
|
||||
|
||||
private const val LibraryPageSize: Int = 50
|
||||
private const val SearchResultLimit: Int = 100
|
||||
Reference in new issue
Block a user