diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt index afe486e..c98cfea 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryScreen.kt @@ -17,12 +17,15 @@ 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.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField 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.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search @@ -90,6 +93,9 @@ internal fun LibraryScreen( var searchText by remember { mutableStateOf("") } var searchResults by remember { mutableStateOf>(emptyList()) } 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() } val searchActive = searchText.isNotBlank() 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), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - val hasReadingNow = !searchActive && visibleItems.firstOrNull()?.readingStatus == BookReadingStatus.READING - if (hasReadingNow) { - item(key = "section-reading") { - LibrarySectionHeader("reading now") + fun itemActions(item: LibraryItem) = LibraryItemActions( + 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 + } + } + }, + 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) { + 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 ( - hasReadingNow && - item.readingStatus != BookReadingStatus.READING && - (index == 0 || visibleItems[index - 1].readingStatus == BookReadingStatus.READING) + + if (searchActive) { + libraryRows("search", visibleItems) + } else { + 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) { 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 -private fun LibrarySectionHeader(text: String) { - Text( - text, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp), - ) +private fun LibrarySectionHeader( + text: String, + count: Int, + collapsed: Boolean, + onToggle: () -> Unit, +) { + 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 @@ -528,11 +619,7 @@ private fun LibraryRow( item: LibraryItem, coverCache: MutableMap, enabled: Boolean, - onOpen: () -> Unit, - onMarkAsRead: () -> Unit, - onMarkAsUnread: () -> Unit, - onNotInterested: () -> Unit, - onDelete: () -> Unit, + actions: LibraryItemActions, ) { var menuOpen by remember { mutableStateOf(false) } @@ -540,7 +627,7 @@ private fun LibraryRow( Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = enabled, onClick = onOpen) + .clickable(enabled = enabled, onClick = actions.onOpen) .padding(horizontal = 8.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -575,7 +662,7 @@ private fun LibraryRow( text = { Text("Open") }, onClick = { menuOpen = false - onOpen() + actions.onOpen() }, ) HorizontalDivider() @@ -584,7 +671,7 @@ private fun LibraryRow( text = { Text("Mark as read") }, onClick = { menuOpen = false - onMarkAsRead() + actions.onMarkAsRead() }, ) } @@ -593,15 +680,24 @@ private fun LibraryRow( text = { Text("Mark as unread") }, onClick = { menuOpen = false - onMarkAsUnread() + actions.onMarkAsUnread() + }, + ) + } + if (item.readingStatus != BookReadingStatus.NEW) { + DropdownMenuItem( + text = { Text("Remove marks") }, + onClick = { + menuOpen = false + actions.onRemoveMarks() }, ) } DropdownMenuItem( - text = { Text("Not interesting") }, + text = { Text("Not interested") }, onClick = { menuOpen = false - onNotInterested() + actions.onNotInterested() }, ) HorizontalDivider() @@ -609,7 +705,7 @@ private fun LibraryRow( text = { Text("Delete") }, onClick = { 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 private fun LibraryCover( item: LibraryItem, diff --git a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt index 20b1733..a1f168f 100644 --- a/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt +++ b/shared/src/jdbcMain/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabase.kt @@ -698,7 +698,11 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF LEFT JOIN books b ON b.id = f.book_id WHERE f.duplicate_of_file_id IS NULL 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, LOWER(COALESCE(NULLIF(b.title, ''), NULLIF(f.original_filename, ''), f.id)), f.id diff --git a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt index 3faf0b0..bbaca71 100644 --- a/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt +++ b/shared/src/jvmTest/kotlin/net/sergeych/toread/storage/jdbc/H2LibraryDatabaseTest.kt @@ -158,8 +158,8 @@ class H2LibraryDatabaseTest { } @Test - fun listsReadingBooksFirstThenSortsByTitle() { - val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstThenSortsByTitle") + fun listsReadingBooksFirstNotInterestedLastThenSortsByTitle() { + val db = H2LibraryDatabase.openMemory("listsReadingBooksFirstNotInterestedLastThenSortsByTitle") val now = 1_700_000_000_000L db.transaction { @@ -168,6 +168,7 @@ class H2LibraryDatabaseTest { Triple("book-alpha", "Alpha", BookReadingStatus.READ), Triple("book-gamma", "Gamma", BookReadingStatus.READING), Triple("book-aardvark", "Aardvark", BookReadingStatus.READING), + Triple("book-omega", "Omega", BookReadingStatus.NOT_INTERESTED), ).forEachIndexed { index, (bookId, title, status) -> books.upsert( BookRecord( @@ -194,7 +195,7 @@ class H2LibraryDatabaseTest { } assertEquals( - listOf("Aardvark", "Gamma", "Alpha", "Beta"), + listOf("Aardvark", "Gamma", "Alpha", "Beta", "Omega"), db.files.listLibraryFiles().map { it.title }, ) db.close()