Add library categories and favorites

This commit is contained in:
sergeych committed 2026-05-23 13:14:16 +03:00
1 parent d749352333
commit 02bde7e644
9 files changed
+263 -21

No files matched your search

@@ -16,6 +16,7 @@ data class LibraryItem(
val storageUri: String?,
val lastSeenAt: Long?,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val favorite: Boolean = false,
val lastReadAt: Long? = null,
val importedAt: Long? = null,
val coverImage: ByteArray? = null,
@@ -124,6 +125,8 @@ expect suspend fun clearLibraryReadingPosition(fileId: String)
expect suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean
expect suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean
expect suspend fun shareLibraryBookFile(fileId: String): Boolean
expect suspend fun viewLibraryBookFile(fileId: String): Boolean
@@ -25,6 +25,7 @@ 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.Favorite
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
@@ -53,6 +54,7 @@ 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.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
@@ -60,6 +62,7 @@ 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.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
@@ -97,10 +100,14 @@ internal fun LibraryScreen(
var settingsMenuOpen by remember { mutableStateOf(false) }
var autoScanDownloads by remember { mutableStateOf(true) }
var autoScanSettingLoaded by remember { mutableStateOf(false) }
var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
var searchResults by remember { mutableStateOf<List<LibraryItem>>(emptyList()) }
var searching by remember { mutableStateOf(false) }
var readingNowCollapsed by remember { mutableStateOf(false) }
var favoritesCollapsed by remember { mutableStateOf(false) }
var toReadCollapsed by remember { mutableStateOf(false) }
var readCollapsed by remember { mutableStateOf(false) }
var recentlyAddedCollapsed by remember { mutableStateOf(false) }
var myLibraryCollapsed by remember { mutableStateOf(false) }
var notInterestedCollapsed by remember { mutableStateOf(false) }
@@ -171,6 +178,31 @@ internal fun LibraryScreen(
val updatedItem = loadLibraryItem(item.fileId) ?: item.copy(readingStatus = status)
items = items.replaceLibraryItem(updatedItem)
searchResults = searchResults.replaceLibraryItem(updatedItem)
recentlyAddedItems = recentlyAddedItems.replaceLibraryItem(updatedItem)
message = successMessage
if (searchActive) {
searching = true
searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false
} else {
loadPage(reset = true)
loadRecentlyAdded()
}
} else {
message = "Could not update ${item.title}."
}
}
suspend fun applyFavorite(
item: LibraryItem,
favorite: Boolean,
successMessage: String,
) {
if (markLibraryFavorite(item.fileId, favorite)) {
val updatedItem = loadLibraryItem(item.fileId) ?: item.copy(favorite = favorite)
items = items.replaceLibraryItem(updatedItem)
searchResults = searchResults.replaceLibraryItem(updatedItem)
recentlyAddedItems = recentlyAddedItems.replaceLibraryItem(updatedItem)
message = successMessage
if (searchActive) {
searching = true
@@ -246,10 +278,18 @@ internal fun LibraryScreen(
autoScanSettingLoaded = true
}
LaunchedEffect(autoScanSettingLoaded, autoScanDownloads) {
if (!autoScanSettingLoaded || !autoScanDownloads || activeScan != null) return@LaunchedEffect
LaunchedEffect(autoScanSettingLoaded, autoScanDownloads, activeScan == null) {
if (
!autoScanSettingLoaded ||
!autoScanDownloads ||
backgroundDownloadsRescanStarted ||
activeScan != null
) {
return@LaunchedEffect
}
if (loadDownloadsWasScanned()) {
downloadsScanPath()?.let { path ->
backgroundDownloadsRescanStarted = true
message = "Scanning Downloads..."
onStartScan(path)
}
@@ -260,7 +300,7 @@ internal fun LibraryScreen(
if (activeScan != null) {
wasScanning = true
while (true) {
delay(5_000)
delay(2_000)
loadPage(reset = true)
loadRecentlyAdded()
}
@@ -364,7 +404,9 @@ internal fun LibraryScreen(
readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem)
coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId)
}
if (markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)) {
if (item.readingStatus == BookReadingStatus.NEW &&
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
) {
val readingItem = loadLibraryItem(item.fileId)
?: item.copy(readingStatus = BookReadingStatus.READING)
items = items.replaceLibraryItem(readingItem)
@@ -418,6 +460,16 @@ internal fun LibraryScreen(
}
}
},
onMarkToRead = {
scope.launch {
busy = true
try {
applyReadingStatus(item, BookReadingStatus.TO_READ, "Marked ${item.title} to read.")
} finally {
busy = false
}
}
},
onNotInterested = {
scope.launch {
busy = true
@@ -428,6 +480,17 @@ internal fun LibraryScreen(
}
}
},
onFavoriteChange = { favorite ->
scope.launch {
busy = true
try {
val label = if (favorite) "Added ${item.title} to favorites." else "Removed ${item.title} from favorites."
applyFavorite(item, favorite, label)
} finally {
busy = false
}
}
},
onDelete = {
val previousItems = items
val previousSearchResults = searchResults
@@ -473,6 +536,9 @@ internal fun LibraryScreen(
libraryRows("search", visibleItems)
} else {
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING }
val favorites = visibleItems.filter { it.favorite }
val toRead = visibleItems.filter { it.readingStatus == BookReadingStatus.TO_READ }
val read = visibleItems.filter { it.readingStatus == BookReadingStatus.READ }
val recentlyAdded = recentlyAddedItems.filter {
it.fileId !in hiddenFileIds && it.readingStatus == BookReadingStatus.NEW
}
@@ -480,6 +546,8 @@ internal fun LibraryScreen(
val myLibrary = visibleItems.filter {
it.fileId !in recentlyAddedIds &&
it.readingStatus != BookReadingStatus.READING &&
it.readingStatus != BookReadingStatus.TO_READ &&
it.readingStatus != BookReadingStatus.READ &&
it.readingStatus != BookReadingStatus.NOT_INTERESTED
}
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
@@ -494,6 +562,26 @@ internal fun LibraryScreen(
) {
libraryRows("reading", readingNow)
}
librarySection(
key = "favorites",
title = "favorites",
itemCount = favorites.size,
displayCount = favorites.size.takeIf { endReached },
collapsed = favoritesCollapsed,
onCollapsedChange = { favoritesCollapsed = it },
) {
libraryRows("favorites", favorites)
}
librarySection(
key = "to-read",
title = "to read",
itemCount = toRead.size,
displayCount = toRead.size.takeIf { endReached },
collapsed = toReadCollapsed,
onCollapsedChange = { toReadCollapsed = it },
) {
libraryRows("to-read", toRead)
}
librarySection(
key = "recently-added",
title = "recently added",
@@ -514,6 +602,16 @@ internal fun LibraryScreen(
) {
libraryRows("library", myLibrary)
}
librarySection(
key = "read",
title = "read",
itemCount = read.size,
displayCount = read.size.takeIf { endReached },
collapsed = readCollapsed,
onCollapsedChange = { readCollapsed = it },
) {
libraryRows("read", read)
}
librarySection(
key = "not-interested",
title = "not interested",
@@ -723,6 +821,8 @@ private fun LibraryRow(
actions: LibraryItemActions,
) {
var menuOpen by remember { mutableStateOf(false) }
val titleStyle = MaterialTheme.typography.titleMedium
val favoriteIconSize = with(LocalDensity.current) { titleStyle.lineHeight.toDp() - 2.dp }
Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Row(
@@ -735,13 +835,27 @@ private fun LibraryRow(
) {
LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
item.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
item.title,
style = titleStyle,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (item.favorite) {
Icon(
Icons.Filled.Favorite,
contentDescription = "Favorite",
tint = Color(0xFFD32F2F),
modifier = Modifier.size(favoriteIconSize),
)
}
}
Text(
item.authors.joinToString().ifBlank { "Unknown author" },
style = MaterialTheme.typography.bodyMedium,
@@ -787,15 +901,31 @@ private fun LibraryRow(
},
)
}
if (item.readingStatus != BookReadingStatus.TO_READ) {
DropdownMenuItem(
text = { Text("Mark to read") },
onClick = {
menuOpen = false
actions.onMarkToRead()
},
)
}
if (item.readingStatus != BookReadingStatus.NEW) {
DropdownMenuItem(
text = { Text("Remove marks") },
text = { Text(if (item.readingStatus == BookReadingStatus.TO_READ) "Remove to read" else "Remove marks") },
onClick = {
menuOpen = false
actions.onRemoveMarks()
},
)
}
DropdownMenuItem(
text = { Text(if (item.favorite) "Remove favorite" else "Add favorite") },
onClick = {
menuOpen = false
actions.onFavoriteChange(!item.favorite)
},
)
DropdownMenuItem(
text = { Text("Not interested") },
onClick = {
@@ -822,7 +952,9 @@ private data class LibraryItemActions(
val onMarkAsRead: () -> Unit,
val onMarkAsUnread: () -> Unit,
val onRemoveMarks: () -> Unit,
val onMarkToRead: () -> Unit,
val onNotInterested: () -> Unit,
val onFavoriteChange: (Boolean) -> Unit,
val onDelete: () -> Unit,
)
@@ -866,6 +998,7 @@ private fun LibraryCover(
private fun LibraryItem.libraryMetadataLine(): String =
listOfNotNull(
"Favorite".takeIf { favorite },
readingStatus.displayLabel,
lastReadAt?.formatLastRead(),
date?.yearOrRaw(),
@@ -877,6 +1010,7 @@ private fun LibraryItem.libraryMetadataLine(): String =
private val BookReadingStatus.displayLabel: String
get() = when (this) {
BookReadingStatus.NEW -> "New"
BookReadingStatus.TO_READ -> "To read"
BookReadingStatus.READING -> "Reading"
BookReadingStatus.READ -> "Read"
BookReadingStatus.NOT_INTERESTED -> "Not interested"
@@ -81,6 +81,7 @@ internal fun BookView(
val snackbarHostState = remember { SnackbarHostState() }
var restored by remember(fileId) { mutableStateOf(false) }
var markedRead by remember(fileId) { mutableStateOf(false) }
var libraryItem by remember(fileId) { mutableStateOf<LibraryItem?>(null) }
var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
val readAloudState by ReadAloudPlatform.state.collectAsState()
@@ -102,6 +103,7 @@ internal fun BookView(
fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
scope.launch {
if (markLibraryReadingStatus(fileId, status)) {
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = status)
if (status == BookReadingStatus.READ) markedRead = true
if (status == BookReadingStatus.NEW) markedRead = false
showMessage(successMessage)
@@ -112,7 +114,12 @@ internal fun BookView(
}
LaunchedEffect(fileId) {
markLibraryReadingStatus(fileId, BookReadingStatus.READING)
val item = loadLibraryItem(fileId)
libraryItem = item
markedRead = item?.readingStatus == BookReadingStatus.READ
if (item?.readingStatus == BookReadingStatus.NEW && markLibraryReadingStatus(fileId, BookReadingStatus.READING)) {
libraryItem = loadLibraryItem(fileId) ?: item.copy(readingStatus = BookReadingStatus.READING)
}
}
DisposableEffect(fileId) {
@@ -144,10 +151,12 @@ internal fun BookView(
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
layoutInfo.totalItemsCount > 0 && lastVisible >= layoutInfo.totalItemsCount - 1
}
.filter { restored && it && !markedRead }
.filter { restored && it && !markedRead && libraryItem?.readingStatus != BookReadingStatus.TO_READ }
.collect {
markedRead = true
markLibraryReadingStatus(fileId, BookReadingStatus.READ)
if (markLibraryReadingStatus(fileId, BookReadingStatus.READ)) {
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = BookReadingStatus.READ)
}
}
}
@@ -179,12 +188,27 @@ internal fun BookView(
onMarkAsRead = {
setReadingStatus(BookReadingStatus.READ, "Marked as read.")
},
onMarkToRead = {
setReadingStatus(BookReadingStatus.TO_READ, "Marked to read.")
},
onNotInterested = {
setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.")
},
onClearMarks = {
setReadingStatus(BookReadingStatus.NEW, "Cleared marks.")
},
readingStatus = libraryItem?.readingStatus,
favorite = libraryItem?.favorite == true,
onFavoriteChange = { favorite ->
scope.launch {
if (markLibraryFavorite(fileId, favorite)) {
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(favorite = favorite)
showMessage(if (favorite) "Added to favorites." else "Removed from favorites.")
} else {
showMessage("Could not update book.")
}
}
},
showShareAction = showShareAction,
onShare = {
scope.launch {
@@ -290,8 +314,12 @@ private fun CompactReaderTopBar(
onThemeToggle: () -> Unit,
onBookInfo: () -> Unit,
onMarkAsRead: () -> Unit,
onMarkToRead: () -> Unit,
onNotInterested: () -> Unit,
onClearMarks: () -> Unit,
readingStatus: BookReadingStatus?,
favorite: Boolean,
onFavoriteChange: (Boolean) -> Unit,
showShareAction: Boolean,
onShare: () -> Unit,
showViewFileAction: Boolean,
@@ -345,6 +373,23 @@ private fun CompactReaderTopBar(
onMarkAsRead()
},
)
if (readingStatus == BookReadingStatus.TO_READ) {
DropdownMenuItem(
text = { Text("Remove to read") },
onClick = {
menuOpen = false
onClearMarks()
},
)
} else {
DropdownMenuItem(
text = { Text("Mark to read") },
onClick = {
menuOpen = false
onMarkToRead()
},
)
}
DropdownMenuItem(
text = { Text("Not interested") },
onClick = {
@@ -359,6 +404,13 @@ private fun CompactReaderTopBar(
onClearMarks()
},
)
DropdownMenuItem(
text = { Text(if (favorite) "Remove favorite" else "Add favorite") },
onClick = {
menuOpen = false
onFavoriteChange(!favorite)
},
)
if (showShareAction || showViewFileAction) {
HorizontalDivider()
}