Add library categories and favorites

This commit is contained in:
Sergey Chernov 2026-05-23 13:14:16 +03:00
parent d749352333
commit 02bde7e644
9 changed files with 263 additions and 21 deletions

View File

@ -337,6 +337,12 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
} }
} }
actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.updateFavorite(fileId, favorite)
}
}
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) { actual suspend fun shareLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
runCatching { runCatching {
val shareFile = openLibraryDatabase().useLibrary { db -> val shareFile = openLibraryDatabase().useLibrary { db ->
@ -698,6 +704,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
storageUri = storageUri, storageUri = storageUri,
lastSeenAt = lastSeenAt, lastSeenAt = lastSeenAt,
readingStatus = readingStatus, readingStatus = readingStatus,
favorite = favorite,
lastReadAt = lastReadAt, lastReadAt = lastReadAt,
importedAt = importedAt, importedAt = importedAt,
) )

View File

@ -16,6 +16,7 @@ data class LibraryItem(
val storageUri: String?, val storageUri: String?,
val lastSeenAt: Long?, val lastSeenAt: Long?,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW, val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val favorite: Boolean = false,
val lastReadAt: Long? = null, val lastReadAt: Long? = null,
val importedAt: Long? = null, val importedAt: Long? = null,
val coverImage: ByteArray? = 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 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 shareLibraryBookFile(fileId: String): Boolean
expect suspend fun viewLibraryBookFile(fileId: String): Boolean expect suspend fun viewLibraryBookFile(fileId: String): Boolean

View File

@ -25,6 +25,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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.Favorite
import androidx.compose.material.icons.filled.KeyboardArrowDown 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.Search 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType 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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -97,10 +100,14 @@ internal fun LibraryScreen(
var settingsMenuOpen by remember { mutableStateOf(false) } var settingsMenuOpen by remember { mutableStateOf(false) }
var autoScanDownloads by remember { mutableStateOf(true) } var autoScanDownloads by remember { mutableStateOf(true) }
var autoScanSettingLoaded by remember { mutableStateOf(false) } var autoScanSettingLoaded by remember { mutableStateOf(false) }
var backgroundDownloadsRescanStarted by remember { mutableStateOf(false) }
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 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 recentlyAddedCollapsed by remember { mutableStateOf(false) }
var myLibraryCollapsed by remember { mutableStateOf(false) } var myLibraryCollapsed by remember { mutableStateOf(false) }
var notInterestedCollapsed 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) val updatedItem = loadLibraryItem(item.fileId) ?: item.copy(readingStatus = status)
items = items.replaceLibraryItem(updatedItem) items = items.replaceLibraryItem(updatedItem)
searchResults = searchResults.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 message = successMessage
if (searchActive) { if (searchActive) {
searching = true searching = true
@ -246,10 +278,18 @@ internal fun LibraryScreen(
autoScanSettingLoaded = true autoScanSettingLoaded = true
} }
LaunchedEffect(autoScanSettingLoaded, autoScanDownloads) { LaunchedEffect(autoScanSettingLoaded, autoScanDownloads, activeScan == null) {
if (!autoScanSettingLoaded || !autoScanDownloads || activeScan != null) return@LaunchedEffect if (
!autoScanSettingLoaded ||
!autoScanDownloads ||
backgroundDownloadsRescanStarted ||
activeScan != null
) {
return@LaunchedEffect
}
if (loadDownloadsWasScanned()) { if (loadDownloadsWasScanned()) {
downloadsScanPath()?.let { path -> downloadsScanPath()?.let { path ->
backgroundDownloadsRescanStarted = true
message = "Scanning Downloads..." message = "Scanning Downloads..."
onStartScan(path) onStartScan(path)
} }
@ -260,7 +300,7 @@ internal fun LibraryScreen(
if (activeScan != null) { if (activeScan != null) {
wasScanning = true wasScanning = true
while (true) { while (true) {
delay(5_000) delay(2_000)
loadPage(reset = true) loadPage(reset = true)
loadRecentlyAdded() loadRecentlyAdded()
} }
@ -364,7 +404,9 @@ internal fun LibraryScreen(
readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem) readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem)
coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId) 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) val readingItem = loadLibraryItem(item.fileId)
?: item.copy(readingStatus = BookReadingStatus.READING) ?: item.copy(readingStatus = BookReadingStatus.READING)
items = items.replaceLibraryItem(readingItem) 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 = { onNotInterested = {
scope.launch { scope.launch {
busy = true 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 = { onDelete = {
val previousItems = items val previousItems = items
val previousSearchResults = searchResults val previousSearchResults = searchResults
@ -473,6 +536,9 @@ internal fun LibraryScreen(
libraryRows("search", visibleItems) libraryRows("search", visibleItems)
} else { } else {
val readingNow = visibleItems.filter { it.readingStatus == BookReadingStatus.READING } 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 { val recentlyAdded = recentlyAddedItems.filter {
it.fileId !in hiddenFileIds && it.readingStatus == BookReadingStatus.NEW it.fileId !in hiddenFileIds && it.readingStatus == BookReadingStatus.NEW
} }
@ -480,6 +546,8 @@ internal fun LibraryScreen(
val myLibrary = visibleItems.filter { val myLibrary = visibleItems.filter {
it.fileId !in recentlyAddedIds && it.fileId !in recentlyAddedIds &&
it.readingStatus != BookReadingStatus.READING && it.readingStatus != BookReadingStatus.READING &&
it.readingStatus != BookReadingStatus.TO_READ &&
it.readingStatus != BookReadingStatus.READ &&
it.readingStatus != BookReadingStatus.NOT_INTERESTED it.readingStatus != BookReadingStatus.NOT_INTERESTED
} }
val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED } val notInterested = visibleItems.filter { it.readingStatus == BookReadingStatus.NOT_INTERESTED }
@ -494,6 +562,26 @@ internal fun LibraryScreen(
) { ) {
libraryRows("reading", readingNow) 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( librarySection(
key = "recently-added", key = "recently-added",
title = "recently added", title = "recently added",
@ -514,6 +602,16 @@ internal fun LibraryScreen(
) { ) {
libraryRows("library", myLibrary) 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( librarySection(
key = "not-interested", key = "not-interested",
title = "not interested", title = "not interested",
@ -723,6 +821,8 @@ private fun LibraryRow(
actions: LibraryItemActions, actions: LibraryItemActions,
) { ) {
var menuOpen by remember { mutableStateOf(false) } 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()) { Card(shape = RoundedCornerShape(6.dp), colors = quietCardColors(), modifier = Modifier.fillMaxWidth()) {
Row( Row(
@ -735,13 +835,27 @@ private fun LibraryRow(
) { ) {
LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f)) LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text( Text(
item.title, item.title,
style = MaterialTheme.typography.titleMedium, style = titleStyle,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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( Text(
item.authors.joinToString().ifBlank { "Unknown author" }, item.authors.joinToString().ifBlank { "Unknown author" },
style = MaterialTheme.typography.bodyMedium, 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) { if (item.readingStatus != BookReadingStatus.NEW) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Remove marks") }, text = { Text(if (item.readingStatus == BookReadingStatus.TO_READ) "Remove to read" else "Remove marks") },
onClick = { onClick = {
menuOpen = false menuOpen = false
actions.onRemoveMarks() actions.onRemoveMarks()
}, },
) )
} }
DropdownMenuItem(
text = { Text(if (item.favorite) "Remove favorite" else "Add favorite") },
onClick = {
menuOpen = false
actions.onFavoriteChange(!item.favorite)
},
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Not interested") }, text = { Text("Not interested") },
onClick = { onClick = {
@ -822,7 +952,9 @@ private data class LibraryItemActions(
val onMarkAsRead: () -> Unit, val onMarkAsRead: () -> Unit,
val onMarkAsUnread: () -> Unit, val onMarkAsUnread: () -> Unit,
val onRemoveMarks: () -> Unit, val onRemoveMarks: () -> Unit,
val onMarkToRead: () -> Unit,
val onNotInterested: () -> Unit, val onNotInterested: () -> Unit,
val onFavoriteChange: (Boolean) -> Unit,
val onDelete: () -> Unit, val onDelete: () -> Unit,
) )
@ -866,6 +998,7 @@ private fun LibraryCover(
private fun LibraryItem.libraryMetadataLine(): String = private fun LibraryItem.libraryMetadataLine(): String =
listOfNotNull( listOfNotNull(
"Favorite".takeIf { favorite },
readingStatus.displayLabel, readingStatus.displayLabel,
lastReadAt?.formatLastRead(), lastReadAt?.formatLastRead(),
date?.yearOrRaw(), date?.yearOrRaw(),
@ -877,6 +1010,7 @@ private fun LibraryItem.libraryMetadataLine(): String =
private val BookReadingStatus.displayLabel: String private val BookReadingStatus.displayLabel: String
get() = when (this) { get() = when (this) {
BookReadingStatus.NEW -> "New" BookReadingStatus.NEW -> "New"
BookReadingStatus.TO_READ -> "To read"
BookReadingStatus.READING -> "Reading" BookReadingStatus.READING -> "Reading"
BookReadingStatus.READ -> "Read" BookReadingStatus.READ -> "Read"
BookReadingStatus.NOT_INTERESTED -> "Not interested" BookReadingStatus.NOT_INTERESTED -> "Not interested"

View File

@ -81,6 +81,7 @@ internal fun BookView(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var restored by remember(fileId) { mutableStateOf(false) } var restored by remember(fileId) { mutableStateOf(false) }
var markedRead 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 readAloudPanelVisible by remember(fileId) { mutableStateOf(false) }
var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) }
val readAloudState by ReadAloudPlatform.state.collectAsState() val readAloudState by ReadAloudPlatform.state.collectAsState()
@ -102,6 +103,7 @@ internal fun BookView(
fun setReadingStatus(status: BookReadingStatus, successMessage: String) { fun setReadingStatus(status: BookReadingStatus, successMessage: String) {
scope.launch { scope.launch {
if (markLibraryReadingStatus(fileId, status)) { if (markLibraryReadingStatus(fileId, status)) {
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = status)
if (status == BookReadingStatus.READ) markedRead = true if (status == BookReadingStatus.READ) markedRead = true
if (status == BookReadingStatus.NEW) markedRead = false if (status == BookReadingStatus.NEW) markedRead = false
showMessage(successMessage) showMessage(successMessage)
@ -112,7 +114,12 @@ internal fun BookView(
} }
LaunchedEffect(fileId) { 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) { DisposableEffect(fileId) {
@ -144,10 +151,12 @@ internal fun BookView(
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
layoutInfo.totalItemsCount > 0 && lastVisible >= layoutInfo.totalItemsCount - 1 layoutInfo.totalItemsCount > 0 && lastVisible >= layoutInfo.totalItemsCount - 1
} }
.filter { restored && it && !markedRead } .filter { restored && it && !markedRead && libraryItem?.readingStatus != BookReadingStatus.TO_READ }
.collect { .collect {
markedRead = true 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 = { onMarkAsRead = {
setReadingStatus(BookReadingStatus.READ, "Marked as read.") setReadingStatus(BookReadingStatus.READ, "Marked as read.")
}, },
onMarkToRead = {
setReadingStatus(BookReadingStatus.TO_READ, "Marked to read.")
},
onNotInterested = { onNotInterested = {
setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.") setReadingStatus(BookReadingStatus.NOT_INTERESTED, "Marked as not interested.")
}, },
onClearMarks = { onClearMarks = {
setReadingStatus(BookReadingStatus.NEW, "Cleared marks.") 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, showShareAction = showShareAction,
onShare = { onShare = {
scope.launch { scope.launch {
@ -290,8 +314,12 @@ private fun CompactReaderTopBar(
onThemeToggle: () -> Unit, onThemeToggle: () -> Unit,
onBookInfo: () -> Unit, onBookInfo: () -> Unit,
onMarkAsRead: () -> Unit, onMarkAsRead: () -> Unit,
onMarkToRead: () -> Unit,
onNotInterested: () -> Unit, onNotInterested: () -> Unit,
onClearMarks: () -> Unit, onClearMarks: () -> Unit,
readingStatus: BookReadingStatus?,
favorite: Boolean,
onFavoriteChange: (Boolean) -> Unit,
showShareAction: Boolean, showShareAction: Boolean,
onShare: () -> Unit, onShare: () -> Unit,
showViewFileAction: Boolean, showViewFileAction: Boolean,
@ -345,6 +373,23 @@ private fun CompactReaderTopBar(
onMarkAsRead() 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( DropdownMenuItem(
text = { Text("Not interested") }, text = { Text("Not interested") },
onClick = { onClick = {
@ -359,6 +404,13 @@ private fun CompactReaderTopBar(
onClearMarks() onClearMarks()
}, },
) )
DropdownMenuItem(
text = { Text(if (favorite) "Remove favorite" else "Add favorite") },
onClick = {
menuOpen = false
onFavoriteChange(!favorite)
},
)
if (showShareAction || showViewFileAction) { if (showShareAction || showViewFileAction) {
HorizontalDivider() HorizontalDivider()
} }

View File

@ -285,6 +285,12 @@ actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingS
} }
} }
actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.updateFavorite(fileId, favorite)
}
}
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) { actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Dispatchers.IO) {
@ -495,6 +501,7 @@ private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
storageUri = storageUri, storageUri = storageUri,
lastSeenAt = lastSeenAt, lastSeenAt = lastSeenAt,
readingStatus = readingStatus, readingStatus = readingStatus,
favorite = favorite,
lastReadAt = lastReadAt, lastReadAt = lastReadAt,
importedAt = importedAt, importedAt = importedAt,
) )

View File

@ -57,6 +57,8 @@ actual suspend fun clearLibraryReadingPosition(fileId: String) = Unit
actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false actual suspend fun markLibraryReadingStatus(fileId: String, status: BookReadingStatus): Boolean = false
actual suspend fun markLibraryFavorite(fileId: String, favorite: Boolean): Boolean = false
actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false actual suspend fun shareLibraryBookFile(fileId: String): Boolean = false
actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false

View File

@ -17,6 +17,7 @@ enum class BookImportPolicy {
enum class BookReadingStatus { enum class BookReadingStatus {
NEW, NEW,
TO_READ,
READING, READING,
READ, READ,
NOT_INTERESTED, NOT_INTERESTED,
@ -109,6 +110,7 @@ data class BookFileRecord(
val lastModifiedMillis: Long? = null, val lastModifiedMillis: Long? = null,
val lastSeenAt: Long? = null, val lastSeenAt: Long? = null,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW, val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val favorite: Boolean = false,
val lastReadAt: Long? = null, val lastReadAt: Long? = null,
val createdAt: Long, val createdAt: Long,
val updatedAt: Long, val updatedAt: Long,
@ -128,6 +130,7 @@ data class LibraryFileRecord(
val storageUri: String? = null, val storageUri: String? = null,
val lastSeenAt: Long? = null, val lastSeenAt: Long? = null,
val readingStatus: BookReadingStatus = BookReadingStatus.NEW, val readingStatus: BookReadingStatus = BookReadingStatus.NEW,
val favorite: Boolean = false,
val lastReadAt: Long? = null, val lastReadAt: Long? = null,
val importedAt: Long, val importedAt: Long,
) )
@ -210,6 +213,7 @@ interface BookFileRepository {
fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord> fun list(limit: Int = 500, offset: Int = 0): List<BookFileRecord>
fun listForBook(bookId: String): List<BookFileRecord> fun listForBook(bookId: String): List<BookFileRecord>
fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean fun updateReadingStatus(id: String, status: BookReadingStatus): Boolean
fun updateFavorite(id: String, favorite: Boolean): Boolean
fun touchLastReadAt(id: String, lastReadAt: Long): Boolean fun touchLastReadAt(id: String, lastReadAt: Long): Boolean
fun delete(id: String): Boolean fun delete(id: String): Boolean
} }

View File

@ -213,6 +213,7 @@ private fun migrate(connection: Connection) {
last_modified_millis BIGINT, last_modified_millis BIGINT,
last_seen_at BIGINT, last_seen_at BIGINT,
reading_status VARCHAR NOT NULL DEFAULT 'NEW', reading_status VARCHAR NOT NULL DEFAULT 'NEW',
favorite BOOLEAN NOT NULL DEFAULT FALSE,
last_read_at BIGINT, last_read_at BIGINT,
imported_at BIGINT NOT NULL, imported_at BIGINT NOT NULL,
created_at BIGINT NOT NULL, created_at BIGINT NOT NULL,
@ -225,6 +226,7 @@ private fun migrate(connection: Connection) {
) )
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR") statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS duplicate_of_file_id VARCHAR")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'") statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS reading_status VARCHAR NOT NULL DEFAULT 'NEW'")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS favorite BOOLEAN NOT NULL DEFAULT FALSE")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT") statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS last_read_at BIGINT")
statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS imported_at BIGINT") statement.execute("ALTER TABLE book_files ADD COLUMN IF NOT EXISTS imported_at BIGINT")
connection.prepareStatement("UPDATE book_files SET imported_at = ? WHERE imported_at IS NULL").use { update -> connection.prepareStatement("UPDATE book_files SET imported_at = ? WHERE imported_at IS NULL").use { update ->
@ -487,9 +489,9 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
MERGE INTO book_files( MERGE INTO book_files(
id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format, id, book_id, body_id, body_cluster_id, duplicate_of_file_id, raw_sha256, format,
mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id, mime_type, size_bytes, original_filename, storage_kind, storage_uri, content_object_id,
last_modified_millis, last_seen_at, reading_status, last_read_at, imported_at, created_at, updated_at last_modified_millis, last_seen_at, reading_status, favorite, last_read_at, imported_at, created_at, updated_at
) )
KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) KEY(id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent() """.trimIndent()
).use { statement -> ).use { statement ->
statement.setString(1, file.id) statement.setString(1, file.id)
@ -508,10 +510,11 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
statement.setLongOrNull(14, file.lastModifiedMillis) statement.setLongOrNull(14, file.lastModifiedMillis)
statement.setLongOrNull(15, file.lastSeenAt) statement.setLongOrNull(15, file.lastSeenAt)
statement.setString(16, file.readingStatus.name) statement.setString(16, file.readingStatus.name)
statement.setLongOrNull(17, file.lastReadAt) statement.setBoolean(17, file.favorite)
statement.setLong(18, file.importedAt) statement.setLongOrNull(18, file.lastReadAt)
statement.setLong(19, file.createdAt) statement.setLong(19, file.importedAt)
statement.setLong(20, file.updatedAt) statement.setLong(20, file.createdAt)
statement.setLong(21, file.updatedAt)
statement.executeUpdate() statement.executeUpdate()
} }
} }
@ -536,6 +539,7 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri, f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at, f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status, f.reading_status AS reading_status,
f.favorite AS favorite,
f.last_read_at AS last_read_at, f.last_read_at AS last_read_at,
f.imported_at AS imported_at f.imported_at AS imported_at
FROM book_files f FROM book_files f
@ -692,6 +696,7 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri, f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at, f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status, f.reading_status AS reading_status,
f.favorite AS favorite,
f.last_read_at AS last_read_at, f.last_read_at AS last_read_at,
f.imported_at AS imported_at f.imported_at AS imported_at
FROM book_files f FROM book_files f
@ -732,6 +737,7 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri, f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at, f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status, f.reading_status AS reading_status,
f.favorite AS favorite,
f.last_read_at AS last_read_at, f.last_read_at AS last_read_at,
f.imported_at AS imported_at f.imported_at AS imported_at
FROM book_files f FROM book_files f
@ -771,6 +777,7 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
f.storage_uri AS storage_uri, f.storage_uri AS storage_uri,
f.last_seen_at AS last_seen_at, f.last_seen_at AS last_seen_at,
f.reading_status AS reading_status, f.reading_status AS reading_status,
f.favorite AS favorite,
f.last_read_at AS last_read_at, f.last_read_at AS last_read_at,
f.imported_at AS imported_at f.imported_at AS imported_at
FROM book_files f FROM book_files f
@ -847,6 +854,24 @@ private class JdbcBookFileRepository(private val connection: Connection) : BookF
} }
} }
override fun updateFavorite(id: String, favorite: Boolean): Boolean {
val now = System.currentTimeMillis()
return connection.prepareStatement(
"""
UPDATE book_files
SET favorite = ?, updated_at = ?
WHERE id = ?
OR body_cluster_id = (SELECT body_cluster_id FROM book_files WHERE id = ? AND body_cluster_id IS NOT NULL)
""".trimIndent()
).use { statement ->
statement.setBoolean(1, favorite)
statement.setLong(2, now)
statement.setString(3, id)
statement.setString(4, id)
statement.executeUpdate() > 0
}
}
override fun touchLastReadAt(id: String, lastReadAt: Long): Boolean { override fun touchLastReadAt(id: String, lastReadAt: Long): Boolean {
return connection.prepareStatement( return connection.prepareStatement(
""" """
@ -1085,6 +1110,7 @@ private fun ResultSet.toLibraryFileRecord() = LibraryFileRecord(
storageUri = getString("storage_uri"), storageUri = getString("storage_uri"),
lastSeenAt = getLongOrNull("last_seen_at"), lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"), readingStatus = getReadingStatus("reading_status"),
favorite = getBoolean("favorite"),
lastReadAt = getLongOrNull("last_read_at"), lastReadAt = getLongOrNull("last_read_at"),
importedAt = getLong("imported_at"), importedAt = getLong("imported_at"),
) )
@ -1122,6 +1148,7 @@ private fun ResultSet.toBookFileRecord() = BookFileRecord(
lastModifiedMillis = getLongOrNull("last_modified_millis"), lastModifiedMillis = getLongOrNull("last_modified_millis"),
lastSeenAt = getLongOrNull("last_seen_at"), lastSeenAt = getLongOrNull("last_seen_at"),
readingStatus = getReadingStatus("reading_status"), readingStatus = getReadingStatus("reading_status"),
favorite = getBoolean("favorite"),
lastReadAt = getLongOrNull("last_read_at"), lastReadAt = getLongOrNull("last_read_at"),
importedAt = getLong("imported_at"), importedAt = getLong("imported_at"),
createdAt = getLong("created_at"), createdAt = getLong("created_at"),

View File

@ -122,6 +122,9 @@ class H2LibraryDatabaseTest {
assertEquals("2024", db.files.getLibraryFile("file-1")?.date) assertEquals("2024", db.files.getLibraryFile("file-1")?.date)
assertEquals("image/jpeg", db.books.getCover("book-1")?.mimeType) assertEquals("image/jpeg", db.books.getCover("book-1")?.mimeType)
assertEquals(BookReadingStatus.NEW, db.files.getLibraryFile("file-1")?.readingStatus) assertEquals(BookReadingStatus.NEW, db.files.getLibraryFile("file-1")?.readingStatus)
assertEquals(false, db.files.getLibraryFile("file-1")?.favorite)
assertEquals(true, db.files.updateFavorite("file-1", true))
assertEquals(true, db.files.getLibraryFile("file-1")?.favorite)
assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.READING)) assertEquals(true, db.files.updateReadingStatus("file-1", BookReadingStatus.READING))
assertEquals(BookReadingStatus.READING, db.files.getLibraryFile("file-1")?.readingStatus) assertEquals(BookReadingStatus.READING, db.files.getLibraryFile("file-1")?.readingStatus)
assertEquals("body-1", db.bodies.findByExactTextHash("text-sha", 1)?.id) assertEquals("body-1", db.bodies.findByExactTextHash("text-sha", 1)?.id)
@ -299,6 +302,9 @@ class H2LibraryDatabaseTest {
assertEquals(true, db.files.updateReadingStatus("file-zip", BookReadingStatus.READING)) assertEquals(true, db.files.updateReadingStatus("file-zip", BookReadingStatus.READING))
assertEquals(BookReadingStatus.READING, db.files.get("file-fb2")?.readingStatus) assertEquals(BookReadingStatus.READING, db.files.get("file-fb2")?.readingStatus)
assertEquals(BookReadingStatus.READING, db.files.get("file-zip")?.readingStatus) assertEquals(BookReadingStatus.READING, db.files.get("file-zip")?.readingStatus)
assertEquals(true, db.files.updateFavorite("file-zip", true))
assertEquals(true, db.files.get("file-fb2")?.favorite)
assertEquals(true, db.files.get("file-zip")?.favorite)
db.close() db.close()
} }