better library organization

This commit is contained in:
Sergey Chernov 2026-05-17 23:50:21 +03:00
parent 06b4614cbe
commit ddf68c1e6a
3 changed files with 236 additions and 126 deletions

View File

@ -17,12 +17,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
@ -90,6 +93,9 @@ internal fun LibraryScreen(
var searchText by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) } var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) } var searching by remember { mutableStateOf(false) }
var readingNowCollapsed by remember { mutableStateOf(false) }
var myLibraryCollapsed by remember { mutableStateOf(false) }
var notInterestedCollapsed by remember { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() } val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
val searchActive = searchText.isNotBlank() val searchActive = searchText.isNotBlank()
val visibleItems = if (searchActive) searchResults else items val visibleItems = if (searchActive) searchResults else items
@ -268,112 +274,156 @@ internal fun LibraryScreen(
contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp), contentPadding = PaddingValues(bottom = if (activeScan != null) 88.dp else 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING fun itemActions(item: LibraryItem) = LibraryItemActions(
if (hasReadingNow) { onOpen = {
item(key = "section-reading") { scope.launch {
LibrarySectionHeader("reading now") busy = true
try {
val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
saveActiveReadingFileId(item.fileId)
AppState.Reader(
fileId = item.fileId,
book = book,
libraryItems = visibleItems,
scanPath = state.scanPath,
message = message,
)
}.getOrElse {
AppState.Library(visibleItems, state.scanPath, it.message ?: "Could not open book.")
}
onStateChange(next)
} finally {
busy = false
}
}
},
onMarkAsRead = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
message = "Marked ${item.title} as read."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onMarkAsUnread = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Marked ${item.title} as unread."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onRemoveMarks = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Removed marks from ${item.title}."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onNotInterested = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
message = "Marked ${item.title} as not interested."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onDelete = {
scope.launch {
busy = true
try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
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)
}
} finally {
busy = false
}
}
},
)
fun LazyListScope.libraryRows(sectionKey: String, sectionItems: List<LibraryItem>) {
itemsIndexed(sectionItems, key = { _, item -> "$sectionKey-${item.fileId}" }) { _, item ->
LibraryRow(
item = item,
coverCache = coverCache,
enabled = !busy,
actions = itemActions(item),
)
} }
} }
itemsIndexed(visibleItems, key = { _, item -> item.fileId }) { index, item ->
if ( if (searchActive) {
hasReadingNow && libraryRows("search", visibleItems)
item.readingStatus != BookReadingStatus.READING && } else {
(index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING) val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
val myLibrary = visibleItems.filter { it.readingStatus != BookReadingStatus.READING && it.readingStatus != BookReadingStatus.NOT_INTERESTED }
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
librarySection(
key = "reading",
title = "reading now",
count = readingNow.size,
collapsed = readingNowCollapsed,
onCollapsedChange = { readingNowCollapsed = it },
) { ) {
LibrarySectionHeader("my library") libraryRows("reading", readingNow)
}
librarySection(
key = "library",
title = "my library",
count = myLibrary.size,
collapsed = myLibraryCollapsed,
onCollapsedChange = { myLibraryCollapsed = it },
) {
libraryRows("library", myLibrary)
}
librarySection(
key = "not-interested",
title = "not interested",
count = notInterested.size,
collapsed = notInterestedCollapsed,
onCollapsedChange = { notInterestedCollapsed = it },
) {
libraryRows("not-interested", notInterested)
} }
LibraryRow(
item = item,
coverCache = coverCache,
enabled = !busy,
onOpen = {
scope.launch {
busy = true
try {
val next = runCatching {
val bytes = openLibraryBook(item.fileId) ?: error("Book file is not available.")
val book = Fb2Format.parse(bytes, item.storageUri ?: item.title)
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
saveActiveReadingFileId(item.fileId)
AppState.Reader(
fileId = item.fileId,
book = book,
libraryItems = visibleItems,
scanPath = state.scanPath,
message = message,
)
}.getOrElse {
AppState.Library(visibleItems, state.scanPath, it.message ?: "Could not open book.")
}
onStateChange(next)
} finally {
busy = false
}
}
},
onMarkAsRead = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READ)) {
message = "Marked ${item.title} as read."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onMarkAsUnread = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NEW)) {
message = "Marked ${item.title} as unread."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onNotInterested = {
scope.launch {
busy = true
try {
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.NOT_INTERESTED)) {
message = "Marked ${item.title} as not interesting."
refresh()
} else {
message = "Could not update ${item.title}."
}
} finally {
busy = false
}
}
},
onDelete = {
scope.launch {
busy = true
try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
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)
}
} finally {
busy = false
}
}
},
)
} }
if (!searchActive && !endReached) { if (!searchActive && !endReached) {
item(key = "load-more") { item(key = "load-more") {
@ -482,15 +532,56 @@ private fun EmptySearchPane(modifier: Modifier = Modifier) {
} }
} }
private fun LazyListScope.librarySection(
key: String,
title: String,
count: Int,
collapsed: Boolean,
onCollapsedChange: (Boolean) -> Unit,
content: LazyListScope.() -> Unit,
) {
if (count == 0) return
item(key = "section-$key") {
LibrarySectionHeader(
text = title,
count = count,
collapsed = collapsed,
onToggle = { onCollapsedChange(!collapsed) },
)
}
if (!collapsed) {
content()
}
}
@Composable @Composable
private fun LibrarySectionHeader(text: String) { private fun LibrarySectionHeader(
Text( text: String,
text, count: Int,
style = MaterialTheme.typography.labelSmall, collapsed: Boolean,
color = MaterialTheme.colorScheme.outline, onToggle: () -> Unit,
textAlign = TextAlign.Center, ) {
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp), Row(
) modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(top = 10.dp, bottom = 4.dp, start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(
if (collapsed) Icons.AutoMirrored.Filled.KeyboardArrowRight else Icons.Filled.KeyboardArrowDown,
contentDescription = if (collapsed) "Expand $text" else "Collapse $text",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(18.dp),
)
Text(
"$text ($count)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.Center,
)
}
} }
@Composable @Composable
@ -528,11 +619,7 @@ private fun LibraryRow(
item: LibraryItem, item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>, coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean, enabled: Boolean,
onOpen: () -> Unit, actions: LibraryItemActions,
onMarkAsRead: () -> Unit,
onMarkAsUnread: () -> Unit,
onNotInterested: () -> Unit,
onDelete: () -> Unit,
) { ) {
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
@ -540,7 +627,7 @@ private fun LibraryRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled, onClick = onOpen) .clickable(enabled = enabled, onClick = actions.onOpen)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -575,7 +662,7 @@ private fun LibraryRow(
text = { Text("Open") }, text = { Text("Open") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onOpen() actions.onOpen()
}, },
) )
HorizontalDivider() HorizontalDivider()
@ -584,7 +671,7 @@ private fun LibraryRow(
text = { Text("Mark as read") }, text = { Text("Mark as read") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onMarkAsRead() actions.onMarkAsRead()
}, },
) )
} }
@ -593,15 +680,24 @@ private fun LibraryRow(
text = { Text("Mark as unread") }, text = { Text("Mark as unread") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onMarkAsUnread() actions.onMarkAsUnread()
},
)
}
if (item.readingStatus != BookReadingStatus.NEW) {
DropdownMenuItem(
text = { Text("Remove marks") },
onClick = {
menuOpen = false
actions.onRemoveMarks()
}, },
) )
} }
DropdownMenuItem( DropdownMenuItem(
text = { Text("Not interesting") }, text = { Text("Not interested") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onNotInterested() actions.onNotInterested()
}, },
) )
HorizontalDivider() HorizontalDivider()
@ -609,7 +705,7 @@ private fun LibraryRow(
text = { Text("Delete") }, text = { Text("Delete") },
onClick = { onClick = {
menuOpen = false menuOpen = false
onDelete() actions.onDelete()
}, },
) )
} }
@ -618,6 +714,15 @@ private fun LibraryRow(
} }
} }
private data class LibraryItemActions(
val onOpen: () -> Unit,
val onMarkAsRead: () -> Unit,
val onMarkAsUnread: () -> Unit,
val onRemoveMarks: () -> Unit,
val onNotInterested: () -> Unit,
val onDelete: () -> Unit,
)
@Composable @Composable
private fun LibraryCover( private fun LibraryCover(
item: LibraryItem, item: LibraryItem,

View File

@ -698,7 +698,11 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
LEFT JOIN books b ON b.id = f.book_id LEFT JOIN books b ON b.id = f.book_id
WHERE f.duplicate_of_file_id IS NULL WHERE f.duplicate_of_file_id IS NULL
ORDER BY ORDER BY
CASE WHEN f.reading_status = 'READING' THEN 0 ELSE 1 END, CASE
WHEN f.reading_status = 'READING' THEN 0
WHEN f.reading_status = 'NOT_INTERESTED' THEN 2
ELSE 1
END,
CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST, CASE WHEN f.reading_status = 'READING' THEN f.last_read_at END DESC NULLS LAST,
LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)), LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)),
f.id f.id

View File

@ -158,8 +158,8 @@ class H2LibraryDatabaseTest {
} }
@Test @Test
fun listsReadingBooksFirstThenSortsByTitle() { fun listsReadingBooksFirstNotInterestedLastThenSortsByTitle() {
val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstThenSortsByTitle") val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstNotInterestedLastThenSortsByTitle")
val now = 1_700_000_000_000L val now = 1_700_000_000_000L
db.transaction { db.transaction {
@ -168,6 +168,7 @@ class H2LibraryDatabaseTest {
Triple("book-alpha", "Alpha", BookReadingStatus.READ), Triple("book-alpha", "Alpha", BookReadingStatus.READ),
Triple("book-gamma", "Gamma", BookReadingStatus.READING), Triple("book-gamma", "Gamma", BookReadingStatus.READING),
Triple("book-aardvark", "Aardvark", BookReadingStatus.READING), Triple("book-aardvark", "Aardvark", BookReadingStatus.READING),
Triple("book-omega", "Omega", BookReadingStatus.NOT_INTERESTED),
).forEachIndexed { index, (bookId, title, status) -> ).forEachIndexed { index, (bookId, title, status) ->
books.upsert( books.upsert(
BookRecord( BookRecord(
@ -194,7 +195,7 @@ class H2LibraryDatabaseTest {
} }
assertEquals( assertEquals(
listOf("Aardvark", "Gamma", "Alpha", "Beta"), listOf("Aardvark", "Gamma", "Alpha", "Beta", "Omega"),
db.files.listLibraryFiles().map { it.title }, db.files.listLibraryFiles().map { it.title },
) )
db.close() db.close()