diff --git a/app/src/main/java/io/legado/app/data/dao/BookDao.kt b/app/src/main/java/io/legado/app/data/dao/BookDao.kt index 1b3a20ff3..5da44d298 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookDao.kt @@ -12,6 +12,7 @@ import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookSource import io.legado.app.help.book.isNotShelf +import io.legado.app.ui.main.bookshelf.BookShelfItem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -51,6 +52,31 @@ interface BookDao { @Query("SELECT * FROM books order by durChapterTime desc") fun flowAll(): Flow> + @Query( + """ + SELECT + bookUrl, + name, + author, + coverUrl, + customCoverUrl, + durChapterTitle, + durChapterTime, + durChapterPos, + latestChapterTitle, + latestChapterTime, + totalChapterNum, + durChapterIndex, + type, + `group`, + `order`, + canUpdate + FROM books + ORDER BY durChapterTime DESC +""" + ) + fun flowBookShelf(): Flow> + @Query("SELECT * FROM books WHERE type & ${BookType.audio} > 0") fun flowAudio(): Flow> diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt index 7e9696cb8..f76449196 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt @@ -32,9 +32,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.legado.app.R -import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup -import io.legado.app.help.book.isLocal import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig import io.legado.app.ui.widget.components.cover.BookCover import io.legado.app.ui.widget.components.cover.BookCoverWithProgress @@ -197,7 +195,7 @@ fun BookshelfItem( @Composable fun BookGroupCover( - books: List, + books: List, coverPath: String? = null, modifier: Modifier = Modifier ) { @@ -289,7 +287,7 @@ fun BookGroupCover( @Composable fun BookGroupItemGrid( group: BookGroup, - previewBooks: List, + previewBooks: List, gridStyle: Int = 0, titleSmallFont: Boolean = false, titleCenter: Boolean = true, @@ -318,7 +316,7 @@ fun BookGroupItemGrid( @Composable fun BookGroupItemList( group: BookGroup, - previewBooks: List, + previewBooks: List, isCompact: Boolean = false, titleSmallFont: Boolean = false, titleCenter: Boolean = true, @@ -348,7 +346,7 @@ fun BookGroupItemList( @Composable fun BookItem( - book: Book, + book: BookShelfItem, layoutMode: Int, gridStyle: Int = 0, isCompact: Boolean = false, @@ -374,7 +372,7 @@ fun BookItem( isUpdating = isUpdating, modifier = modifier, badgeText = if (BookshelfConfig.showUnread && unreadCount > 0) unreadCount.toString() else null, - showBadgeDot = !BookshelfConfig.showUnread && BookshelfConfig.showUnreadNew && unreadCount > 0 && book.lastCheckCount > 0 + showBadgeDot = !BookshelfConfig.showUnread && BookshelfConfig.showUnreadNew && unreadCount > 0 ) }, title = book.name, diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt new file mode 100644 index 000000000..d95becea1 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt @@ -0,0 +1,55 @@ +package io.legado.app.ui.main.bookshelf + +import io.legado.app.constant.BookType +import io.legado.app.data.entities.Book +import kotlin.math.max + +data class BookShelfItem( + val bookUrl: String, + val name: String, + val author: String, + val coverUrl: String?, + val customCoverUrl: String?, + val durChapterTitle: String?, + val durChapterTime: Long, + val durChapterPos: Int, + val latestChapterTitle: String?, + val latestChapterTime: Long, + val totalChapterNum: Int, + val durChapterIndex: Int, + val type: Int, + val group: Long, + val order: Int, + val canUpdate: Boolean = true +) { + fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl + + val isLocal: Boolean get() = (type and BookType.local) > 0 + + val isAudio: Boolean get() = (type and BookType.audio) > 0 + + val isImage: Boolean get() = (type and BookType.image) > 0 + + fun getUnreadChapterNum() = max(totalChapterNum - durChapterIndex - 1, 0) +} + +fun Book.toBookShelfItem(): BookShelfItem { + return BookShelfItem( + bookUrl = bookUrl, + name = name, + author = author, + coverUrl = coverUrl, + customCoverUrl = customCoverUrl, + durChapterTitle = durChapterTitle, + durChapterTime = durChapterTime, + durChapterPos = durChapterPos, + latestChapterTitle = latestChapterTitle, + latestChapterTime = latestChapterTime, + totalChapterNum = totalChapterNum, + durChapterIndex = durChapterIndex, + type = type, + group = group, + order = order, + canUpdate = canUpdate + ) +} diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt index aeee948af..c02a4e5d2 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt @@ -11,14 +11,19 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FileOpen @@ -65,7 +70,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import io.legado.app.R import io.legado.app.base.BaseRuleEvent -import io.legado.app.data.entities.Book import io.legado.app.ui.about.AppLogSheet import io.legado.app.ui.book.cache.CacheActivity import io.legado.app.ui.book.import.local.ImportBookActivity @@ -74,10 +78,12 @@ import io.legado.app.ui.book.manage.BookshelfManageActivity import io.legado.app.ui.book.search.SearchActivity import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig import io.legado.app.ui.widget.components.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton import io.legado.app.ui.widget.components.filePicker.FilePickerSheet import io.legado.app.ui.widget.components.importComponents.SourceInputDialog import io.legado.app.ui.widget.components.lazylist.FastScrollLazyVerticalGrid import io.legado.app.ui.widget.components.list.ListScaffold +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.utils.readText import io.legado.app.utils.startActivity @@ -94,8 +100,8 @@ import org.koin.androidx.compose.koinViewModel @Composable fun BookshelfScreen( viewModel: BookshelfViewModel = koinViewModel(), - onBookClick: (Book) -> Unit, - onBookLongClick: (Book) -> Unit + onBookClick: (BookShelfItem) -> Unit, + onBookLongClick: (BookShelfItem) -> Unit ) { val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() @@ -107,6 +113,7 @@ fun BookshelfScreen( var showConfigSheet by remember { mutableStateOf(false) } var showGroupManageSheet by remember { mutableStateOf(false) } var showLogSheet by remember { mutableStateOf(false) } + var showGroupMenu by remember { mutableStateOf(false) } val clipboardManager = LocalClipboard.current val snackbarHostState = remember { SnackbarHostState() } @@ -309,29 +316,69 @@ fun BookshelfScreen( if (uiState.groups.isNotEmpty()) { val selectedTabIndex = pagerState.currentPage.coerceIn(0, uiState.groups.size - 1) - PrimaryScrollableTabRow( - selectedTabIndex = selectedTabIndex, - edgePadding = 0.dp, - divider = { }, - containerColor = GlassTopAppBarDefaults.containerColor(), - minTabWidth = 0.dp + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - uiState.groups.forEachIndexed { index, group -> - Tab( - selected = selectedTabIndex == index, - onClick = { - scope.launch { pagerState.animateScrollToPage(index) } - }, - text = { - Text( - text = group.groupName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + PrimaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 0.dp, + divider = { }, + containerColor = GlassTopAppBarDefaults.containerColor(), + minTabWidth = 0.dp, + modifier = Modifier.weight(1f) + ) { + uiState.groups.forEachIndexed { index, group -> + Tab( + selected = selectedTabIndex == index, + onClick = { + scope.launch { pagerState.animateScrollToPage(index) } + }, + text = { + Text( + text = group.groupName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + } + + if (BookshelfConfig.shouldShowExpandButton) { + Box(modifier = Modifier.padding(end = 16.dp)) { + SmallOutlinedIconToggleButton( + checked = showGroupMenu, + onCheckedChange = { showGroupMenu = it }, + icon = Icons.AutoMirrored.Filled.FormatListBulleted, + contentDescription = stringResource(R.string.group_manage) + ) + RoundDropdownMenu( + expanded = showGroupMenu, + onDismissRequest = { showGroupMenu = false } + ) { dismiss -> + uiState.groups.forEachIndexed { index, group -> + RoundDropdownMenuItem( + text = { Text(group.groupName) }, + onClick = { + scope.launch { pagerState.animateScrollToPage(index) } + dismiss() + }, + trailingIcon = { + if (selectedTabIndex == index) { + Icon( + Icons.Default.Check, + null, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } } - ) + } } } } @@ -533,12 +580,12 @@ fun BookshelfScreen( @Composable fun BookshelfPage( paddingValues: PaddingValues, - books: List, + books: List, uiState: BookshelfUiState, bookshelfLayoutMode: Int, bookshelfLayoutGrid: Int, - onBookClick: (Book) -> Unit, - onBookLongClick: (Book) -> Unit + onBookClick: (BookShelfItem) -> Unit, + onBookLongClick: (BookShelfItem) -> Unit ) { FastScrollLazyVerticalGrid( columns = GridCells.Fixed(bookshelfLayoutGrid.coerceAtLeast(1)), diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt index b9155af2a..757a8cd39 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt @@ -1,18 +1,17 @@ package io.legado.app.ui.main.bookshelf -import io.legado.app.data.entities.Book import io.legado.app.data.entities.BookGroup import io.legado.app.ui.widget.components.list.ListUiState data class BookshelfUiState( - override val items: List = emptyList(), + override val items: List = emptyList(), override val selectedIds: Set = emptySet(), override val searchKey: String = "", override val isSearch: Boolean = false, override val isLoading: Boolean = false, val groups: List = emptyList(), - val groupPreviews: Map> = emptyMap(), + val groupPreviews: Map> = emptyMap(), val selectedGroupIndex: Int = 0, val loadingText: String? = null, val updatingBooks: Set = emptySet() -) : ListUiState +) : ListUiState diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt index fe70a57f3..15b8c34fd 100644 --- a/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt @@ -22,7 +22,6 @@ import io.legado.app.data.repository.UploadRepository import io.legado.app.exception.NoStackTraceException import io.legado.app.help.book.BookHelp import io.legado.app.help.book.addType -import io.legado.app.help.book.isLocal import io.legado.app.help.book.isUpError import io.legado.app.help.book.removeType import io.legado.app.help.book.sync @@ -123,13 +122,15 @@ class BookshelfViewModel( val allGroupsFlow: StateFlow> = bookGroupRepository.flowAll() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - private val allBooksFlow = appDb.bookDao.flowAll().flowOn(Dispatchers.Default) + private val allBooksFlow = appDb.bookDao.flowBookShelf().flowOn(Dispatchers.Default) @OptIn(ExperimentalCoroutinesApi::class) val booksFlow = combine(groupIdFlow, refreshTrigger) { groupId, _ -> groupId } .flatMapLatest { groupId -> appDb.bookDao.flowByGroup(groupId).map { list -> - sortBooks(list, groupsFlow.value.find { it.groupId == groupId }) + sortBooks( + list.map { it.toBookShelfItem() }, + groupsFlow.value.find { it.groupId == groupId }) } }.flowOn(Dispatchers.Default) @@ -269,7 +270,7 @@ class BookshelfViewModel( upTocPool.close() } - private fun sortBooks(list: List, group: BookGroup?): List { + private fun sortBooks(list: List, group: BookGroup?): List { val bookSort = group?.getRealBookSort() ?: BookshelfConfig.bookshelfSort val isDescending = BookshelfConfig.bookshelfSortOrder == 1 @@ -304,7 +305,7 @@ class BookshelfViewModel( } @OptIn(ExperimentalCoroutinesApi::class) - fun getBooksFlow(groupId: Long): Flow> { + fun getBooksFlow(groupId: Long): Flow> { return combine( appDb.bookDao.flowByGroup(groupId), searchKeyFlow, @@ -312,10 +313,11 @@ class BookshelfViewModel( refreshTrigger ) { books, searchKey, groups, _ -> val group = groups.find { it.groupId == groupId } + val bookshelfItems = books.map { it.toBookShelfItem() } val filtered = if (searchKey.isEmpty()) { - books + bookshelfItems } else { - books.filter { + bookshelfItems.filter { it.name.contains(searchKey, true) || it.author.contains(searchKey, true) } } @@ -349,13 +351,11 @@ class BookshelfViewModel( } } - fun upToc(books: List) { + fun upToc(books: List) { execute(context = upTocPool) { - books.filter { - !it.isLocal && it.canUpdate - }.let { - addToWaitUp(it) - } + val bookUrls = books.filter { !it.isLocal && it.canUpdate }.map { it.bookUrl } + val fullBooks = bookUrls.mapNotNull { appDb.bookDao.getBook(it) } + addToWaitUp(fullBooks) } } @@ -562,17 +562,20 @@ class BookshelfViewModel( } } - fun exportToUri(uri: Uri, books: List) { + fun exportToUri(uri: Uri, items: List) { execute { context.contentResolver.openOutputStream(uri)?.use { out -> val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) writer.setIndent(" ") writer.beginArray() - books.forEach { + items.forEach { val bookMap = hashMapOf() bookMap["name"] = it.name bookMap["author"] = it.author - bookMap["intro"] = it.getDisplayIntro() + // intro is not in BookShelfItem, fetch from DB if needed or skip + // For now, let's keep it simple and skip intro or fetch it + val fullBook = appDb.bookDao.getBook(it.bookUrl) + bookMap["intro"] = fullBook?.getDisplayIntro() GSON.toJson(bookMap, bookMap::class.java, writer) } writer.endArray() @@ -585,14 +588,15 @@ class BookshelfViewModel( } } - fun uploadBookshelf(books: List) { + fun uploadBookshelf(items: List) { execute { val json = withContext(Dispatchers.Default) { - val list = books.map { + val list = items.map { val bookMap = hashMapOf() bookMap["name"] = it.name bookMap["author"] = it.author - bookMap["intro"] = it.getDisplayIntro() + val fullBook = appDb.bookDao.getBook(it.bookUrl) + bookMap["intro"] = fullBook?.getDisplayIntro() bookMap } GSON.toJson(list) @@ -619,9 +623,9 @@ class BookshelfViewModel( } } - fun exportBookshelf(books: List?, success: (file: File) -> Unit) { + fun exportBookshelf(items: List?, success: (file: File) -> Unit) { execute { - books?.let { + items?.let { val path = "${context.filesDir}/books.json" FileUtils.delete(path) val file = FileUtils.createFileWithReplace(path) @@ -629,11 +633,12 @@ class BookshelfViewModel( val writer = JsonWriter(OutputStreamWriter(out, "UTF-8")) writer.setIndent(" ") writer.beginArray() - books.forEach { + items.forEach { val bookMap = hashMapOf() bookMap["name"] = it.name bookMap["author"] = it.author - bookMap["intro"] = it.getDisplayIntro() + val fullBook = appDb.bookDao.getBook(it.bookUrl) + bookMap["intro"] = fullBook?.getDisplayIntro() GSON.toJson(bookMap, bookMap::class.java, writer) } writer.endArray() diff --git a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt index ee4ccfacc..690e46674 100644 --- a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt +++ b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt @@ -50,6 +50,7 @@ import io.legado.app.help.config.AppConfig import io.legado.app.ui.book.audio.AudioPlayActivity import io.legado.app.ui.book.manga.ReadMangaActivity import io.legado.app.ui.book.read.ReadBookActivity +import io.legado.app.ui.main.bookshelf.BookShelfItem import splitties.systemservices.clipboardManager import splitties.systemservices.connectivityManager import splitties.systemservices.uiModeManager @@ -80,6 +81,22 @@ fun Context.startActivityForBook( startActivity(intent) } +fun Context.startActivityForBook( + book: BookShelfItem, + configIntent: Intent.() -> Unit = {}, +) { + val cls = when { + book.isAudio -> AudioPlayActivity::class.java + !book.isLocal && book.isImage && AppConfig.showMangaUi -> ReadMangaActivity::class.java + else -> ReadBookActivity::class.java + } + val intent = Intent(this, cls) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra("bookUrl", book.bookUrl) + intent.apply(configIntent) + startActivity(intent) +} + inline fun Context.startService(configIntent: Intent.() -> Unit = {}) { startService(Intent(this, T::class.java).apply(configIntent)) } @@ -464,4 +481,3 @@ fun Context.themeColor(attr: Int): Int { typedValue.data } } -