From 96ac87b4f805115ba181b8e87c801e49f1593083 Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Wed, 18 Feb 2026 01:36:44 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=B9=B4=E5=BF=AB=E4=B9=90]=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9B=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E5=86=99=E4=BA=86=E7=9B=AE=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=88=E5=8F=AF=E8=83=BD=E6=9C=89bug=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/data/dao/BookChapterDao.kt | 4 + .../java/io/legado/app/data/entities/Book.kt | 11 - .../main/java/io/legado/app/di/appModule.kt | 2 + .../app/help/bookmark/BookmarkExporter.kt | 50 + .../java/io/legado/app/model/CacheBook.kt | 51 + .../app/ui/book/bookmark/AllBookmarkScreen.kt | 247 +---- .../app/ui/book/read/ReadBookActivity.kt | 30 +- .../legado/app/ui/book/toc/BookmarkAdapter.kt | 57 -- .../app/ui/book/toc/BookmarkFragment.kt | 99 -- .../app/ui/book/toc/ChapterListAdapter.kt | 434 --------- .../app/ui/book/toc/ChapterListFragment.kt | 432 --------- .../legado/app/ui/book/toc/DownloadState.kt | 8 + .../io/legado/app/ui/book/toc/TocActivity.kt | 400 +------- .../io/legado/app/ui/book/toc/TocScreen.kt | 918 ++++++++++++++++++ .../io/legado/app/ui/book/toc/TocViewModel.kt | 521 ++++++++-- .../app/ui/book/toc/rule/TxtRuleScreen.kt | 25 +- .../ui/book/toc/rule/TxtTocRuleActivity.kt | 15 +- .../app/ui/config/readConfig/ReadConfig.kt | 18 + .../app/ui/replace/ReplaceRuleScreen.kt | 2 +- .../legado/app/ui/widget/CollapsibleHeader.kt | 92 ++ .../app/ui/widget/components/AnimatedText.kt | 112 ++- .../components/bookmark/BookmarkEditSheet.kt | 137 +++ .../components/bookmark/BookmarkItem.kt | 79 ++ .../components/button/SmallIconButton.kt | 46 +- .../widget/components/divider/PillDivider.kt | 39 + .../components/divider/PillHeaderDivider.kt | 58 ++ .../menuItem/RoundDropdownMenuItem.kt | 29 + .../components/rules/RuleListScaffold.kt | 87 +- .../components/swipe/SwipeActionContainer.kt | 2 +- .../components/topbar/DynamicTopAppBar.kt | 136 +++ .../legado/app/utils/eventBus/FlowEventBus.kt | 18 +- .../main/res/layout/activity_chapter_list.xml | 5 + 32 files changed, 2300 insertions(+), 1864 deletions(-) create mode 100644 app/src/main/java/io/legado/app/help/bookmark/BookmarkExporter.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt delete mode 100644 app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt create mode 100644 app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/CollapsibleHeader.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkItem.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/divider/PillDivider.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/divider/PillHeaderDivider.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt create mode 100644 app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt diff --git a/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt b/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt index 1454353d2..59c88b2ad 100644 --- a/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/BookChapterDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import io.legado.app.data.entities.BookChapter +import kotlinx.coroutines.flow.Flow @Dao interface BookChapterDao { @@ -19,6 +20,9 @@ interface BookChapterDao { @Query("select * from chapters where bookUrl = :bookUrl order by `index`") fun getChapterList(bookUrl: String): List + @Query("select * from chapters where bookUrl = :bookUrl order by `index`") + fun getChapterListFlow(bookUrl: String): Flow> + @Query("select * from chapters where bookUrl = :bookUrl and `index` >= :start and `index` <= :end order by `index`") fun getChapterList(bookUrl: String, start: Int, end: Int): List diff --git a/app/src/main/java/io/legado/app/data/entities/Book.kt b/app/src/main/java/io/legado/app/data/entities/Book.kt index 4e5e42757..e752ea55d 100644 --- a/app/src/main/java/io/legado/app/data/entities/Book.kt +++ b/app/src/main/java/io/legado/app/data/entities/Book.kt @@ -121,17 +121,6 @@ data class Book( var syncTime: Long = 0L ) : Parcelable, BaseBook { - override fun equals(other: Any?): Boolean { - if (other is Book) { - return other.bookUrl == bookUrl - } - return false - } - - override fun hashCode(): Int { - return bookUrl.hashCode() - } - @delegate:Transient @delegate:Ignore @IgnoredOnParcel diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index a187d154a..e04c41b68 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -12,6 +12,7 @@ import io.legado.app.ui.book.bookmark.AllBookmarkViewModel import io.legado.app.ui.book.explore.ExploreShowViewModel import io.legado.app.ui.book.readRecord.ReadRecordViewModel import io.legado.app.ui.book.searchContent.SearchContentViewModel +import io.legado.app.ui.book.toc.TocViewModel import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel import io.legado.app.ui.config.otherConfig.OtherConfigViewModel import io.legado.app.ui.dict.rule.DictRuleViewModel @@ -45,6 +46,7 @@ val appModule = module { viewModelOf(::AllBookmarkViewModel) viewModelOf(::TxtTocRuleViewModel) viewModelOf(::OtherConfigViewModel) + viewModelOf(::TocViewModel) viewModel { (route: ReplaceEditRoute) -> ReplaceEditViewModel( diff --git a/app/src/main/java/io/legado/app/help/bookmark/BookmarkExporter.kt b/app/src/main/java/io/legado/app/help/bookmark/BookmarkExporter.kt new file mode 100644 index 000000000..5d16a290d --- /dev/null +++ b/app/src/main/java/io/legado/app/help/bookmark/BookmarkExporter.kt @@ -0,0 +1,50 @@ +package io.legado.app.help.bookmark + +import android.content.Context +import android.net.Uri +import io.legado.app.data.entities.Bookmark +import io.legado.app.utils.GSON +import io.legado.app.utils.writeToOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object BookmarkExporter { + + fun formatToMarkdown(bookName: String, author: String?, bookmarks: List): String { + return buildString { + append("# $bookName\n") + if (!author.isNullOrBlank()) append("作者:$author\n") + append("\n---\n\n") + + bookmarks.forEach { + append("#### ${it.chapterName}\n") + if (it.bookText.isNotEmpty()) { + val quotedText = it.bookText.replace("\n", "\n> ") + append("> $quotedText\n\n") + } + if (it.content.isNotBlank()) { + append("${it.content}\n\n") + } + append("---\n\n") + } + } + } + + suspend fun exportToUri( + context: Context, + fileUri: Uri, + bookmarks: List, + isMd: Boolean, + bookName: String = "", + author: String? = "" + ) = withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(fileUri)?.use { outputStream -> + if (isMd) { + val content = formatToMarkdown(bookName, author, bookmarks) + outputStream.write(content.toByteArray()) + } else { + GSON.writeToOutputStream(outputStream, bookmarks) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/model/CacheBook.kt b/app/src/main/java/io/legado/app/model/CacheBook.kt index 28c24a2d6..7f0692639 100644 --- a/app/src/main/java/io/legado/app/model/CacheBook.kt +++ b/app/src/main/java/io/legado/app/model/CacheBook.kt @@ -26,7 +26,10 @@ import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -41,6 +44,27 @@ import kotlin.coroutines.CoroutineContext object CacheBook { + private val _cacheSuccessFlow = MutableSharedFlow(extraBufferCapacity = 64) + val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow() + + private val _downloadSummaryFlow = MutableStateFlow("") + + val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow() + + private val _downloadingIndicesFlow = + MutableSharedFlow>>(extraBufferCapacity = 64) + + val downloadingIndicesFlow = _downloadingIndicesFlow.asSharedFlow() + + private val _downloadErrorFlow = + MutableSharedFlow>>(extraBufferCapacity = 64) + + val downloadErrorFlow = _downloadErrorFlow.asSharedFlow() + + private fun updateSummary() { + _downloadSummaryFlow.value = downloadSummary + } + val cacheBookMap = ConcurrentHashMap() private val workingState = MutableStateFlow(true) private val mutex = Mutex() @@ -155,12 +179,14 @@ object CacheBook { } }.onStart { postEvent(EventBus.UP_DOWNLOAD_STATE, "") + updateSummary() }.onEachParallel(AppConfig.threadCount) { coroutineScope { it.download(this, context) } }.onCompletion { postEvent(EventBus.UP_DOWNLOAD_STATE, "") + updateSummary() }.collect() } @@ -202,6 +228,22 @@ object CacheBook { class CacheBookModel(var bookSource: BookSource, var book: Book) { + private fun notifyDownloadSetChanged() { + _downloadingIndicesFlow.tryEmit(book.bookUrl to onDownloadSet.toSet()) + } + + private fun notifyErrorChanged() { + val errorIndices = errorDownloadMap + .filterKeys { it.startsWith(book.bookUrl) } + .mapNotNull { + val chapterIndex = it.key.substringAfterLast("_").toIntOrNull() + chapterIndex + } + .toSet() + + _downloadErrorFlow.tryEmit(book.bookUrl to errorIndices) + } + private val waitDownloadSet = linkedSetOf() private val onDownloadSet = linkedSetOf() private val tasks = CompositeCoroutine() @@ -267,6 +309,8 @@ object CacheBook { onDownloadSet.remove(chapter.index) successDownloadSet.add(chapter.primaryStr()) errorDownloadMap.remove(chapter.primaryStr()) + notifyDownloadSetChanged() + _cacheSuccessFlow.tryEmit(chapter) } @Synchronized @@ -297,12 +341,15 @@ object CacheBook { private fun onError(chapter: BookChapter, error: Throwable) { onPreError(chapter, error) onPostError(chapter, error) + notifyDownloadSetChanged() + notifyErrorChanged() } @Synchronized private fun onCancel(index: Int) { onDownloadSet.remove(index) if (!isStopped) waitDownloadSet.add(index) + notifyDownloadSetChanged() } @Synchronized @@ -311,6 +358,8 @@ object CacheBook { cacheBookMap.remove(book.bookUrl) } postEvent(EventBus.UP_DOWNLOAD, book.bookUrl) + notifyDownloadSetChanged() + updateSummary() } /** @@ -336,6 +385,7 @@ object CacheBook { if (chapter.isVolume) { /** 修正下载计数 */ postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter)) + _cacheSuccessFlow.tryEmit(chapter) waitDownloadSet.remove(chapterIndex) return } @@ -345,6 +395,7 @@ object CacheBook { } waitDownloadSet.remove(chapterIndex) onDownloadSet.add(chapterIndex) + notifyDownloadSetChanged() if (BookHelp.hasContent(book, chapter)) { Coroutine.async(scope, context, executeContext = context) { BookHelp.getContent(book, chapter)?.let { diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt index d97a7b372..9edd64367 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt @@ -6,48 +6,31 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.UnfoldLess import androidx.compose.material.icons.filled.UnfoldMore -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -55,23 +38,19 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import io.legado.app.data.entities.Bookmark +import io.legado.app.ui.widget.CollapsibleHeader import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.SearchBarSection +import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet +import io.legado.app.ui.widget.components.bookmark.BookmarkItem import io.legado.app.ui.widget.components.button.SmallTopBarButton import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import io.legado.app.ui.widget.components.lazylist.Scroller -import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, @@ -225,12 +204,10 @@ fun AllBookmarkScreen( val isCollapsed = collapsedGroups.contains(headerKey.toString()) stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") { - BookAuthorHeader( - modifier = Modifier - .animateItem() - .fillMaxWidth(), - bookTitle = headerKey.bookName, - bookAuthor = headerKey.bookAuthor, + CollapsibleHeader( + modifier = Modifier.animateItem(), + title = headerKey.bookName, + subtitle = headerKey.bookAuthor, isCollapsed = isCollapsed, onToggle = { viewModel.toggleGroupCollapse(headerKey) } ) @@ -242,13 +219,18 @@ fun AllBookmarkScreen( key = { it.id } ) { bookmarkUi -> BookmarkItem( - bookmark = bookmarkUi, + bookmark = bookmarkUi.rawBookmark, modifier = Modifier .animateItem() .fillMaxWidth(), + isDur = false, onClick = { editingBookmark = bookmarkUi.rawBookmark showBottomSheet = true + }, + onLongClick = { + editingBookmark = bookmarkUi.rawBookmark + showBottomSheet = true } ) } @@ -278,205 +260,4 @@ fun AllBookmarkScreen( ) } } -} - -@Composable -fun BookAuthorHeader( - modifier: Modifier = Modifier, - bookTitle: String, - bookAuthor: String, - isCollapsed: Boolean, - onToggle: () -> Unit -) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - ) { - Row( - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - - Column( - modifier = Modifier.weight(1f) - ) { - - Text( - text = bookTitle, - style = MaterialTheme.typography.titleSmall.copy( - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Text( - text = bookAuthor, - style = MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - val rotation by animateFloatAsState( - targetValue = if (isCollapsed) 0f else 180f, - label = "arrowRotation" - ) - - Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = if (isCollapsed) "展开书签" else "折叠书签", - modifier = Modifier.rotate(rotation), - tint = MaterialTheme.colorScheme.primary - ) - } - } -} - -@Composable -fun BookmarkItem( - bookmark: BookmarkItemUi, - modifier: Modifier, - onClick: () -> Unit) { - Column( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Text( - text = bookmark.chapterName, - style = MaterialTheme.typography.bodySmall, - color = Color.Gray - ) - if (bookmark.bookText.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = bookmark.bookText, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - if (bookmark.content.isNotEmpty()) { - Text( - text = bookmark.content, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun BookmarkEditSheet( - bookmark: Bookmark, - onDismiss: () -> Unit, - onSave: (Bookmark) -> Unit, - onDelete: (Bookmark) -> Unit -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - var showDeleteConfirmDialog by remember { mutableStateOf(false) } - var bookText by remember { mutableStateOf(bookmark.bookText) } - var content by remember { mutableStateOf(bookmark.content) } - - GlassModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .navigationBarsPadding() - ) { - Text( - text = bookmark.chapterName, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 16.dp) - ) - - OutlinedTextField( - value = bookText, - onValueChange = { bookText = it }, - label = { Text("原文") }, - modifier = Modifier.fillMaxWidth(), - maxLines = 10 - ) - - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = content, - onValueChange = { content = it }, - label = { Text("摘要/笔记") }, - modifier = Modifier.fillMaxWidth(), - maxLines = 5 - ) - - Spacer(modifier = Modifier.height(24.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = { showDeleteConfirmDialog = true }, - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - modifier = Modifier.weight(1f) - ) { - Text("删除") - } - - Button( - onClick = { - val newBookmark = bookmark.apply { - this.bookText = bookText - this.content = content - } - onSave(newBookmark) - }, - modifier = Modifier.weight(1f) - ) { - Text("保存") - } - } - Spacer(modifier = Modifier.height(16.dp)) - } - } - - if (showDeleteConfirmDialog) { - AlertDialog( - onDismissRequest = { showDeleteConfirmDialog = false }, - title = { Text("确认删除") }, - text = { Text("你确定要删除这条书签吗?") }, - confirmButton = { - TextButton( - onClick = { - showDeleteConfirmDialog = false - onDelete(bookmark) - }, - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text("删除") - } - }, - dismissButton = { - TextButton( - onClick = { showDeleteConfirmDialog = false } - ) { - Text("取消") - } - } - ) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt index 0a1c97a00..b86ab3008 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt @@ -100,7 +100,7 @@ import io.legado.app.ui.book.searchContent.SearchContentActivity import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.ui.book.source.edit.BookSourceEditActivity import io.legado.app.ui.book.toc.TocActivityResult -import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog +import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity import io.legado.app.ui.browser.WebViewActivity import io.legado.app.ui.dict.DictDialog import io.legado.app.ui.login.SourceLoginActivity @@ -164,7 +164,6 @@ class ReadBookActivity : BaseReadBookActivity(), ReadBook.CallBack, AutoReadDialog.CallBack, ToolButtonConfigDialog.CallBack, - TxtTocRuleDialog.CallBack, ColorPickerDialogListener, FontConfigDialog.CallBack, FontSelectDialog.CallBack, @@ -191,6 +190,18 @@ class ReadBookActivity : BaseReadBookActivity(), } } + private val txtTocRuleLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.getStringExtra("tocRegex")?.let { rule -> + ReadBook.book?.let { + it.tocUrl = rule + loadChapterList(it) + } + } + } + } + private val searchContentActivity = registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) { val data = it.data ?: return@registerForActivityResult @@ -670,9 +681,11 @@ class ReadBookActivity : BaseReadBookActivity(), } R.id.menu_log -> showDialogFragment() - R.id.menu_toc_regex -> showDialogFragment( - TxtTocRuleDialog(ReadBook.book?.tocUrl) - ) + R.id.menu_toc_regex -> { + val intent = Intent(this, TxtTocRuleActivity::class.java) + intent.putExtra("tocRegex", ReadBook.book?.tocUrl) + txtTocRuleLauncher.launch(intent) + } R.id.menu_reverse_content -> ReadBook.book?.let { viewModel.reverseContent(it) @@ -1699,13 +1712,6 @@ class ReadBookActivity : BaseReadBookActivity(), */ override fun onDialogDismissed(dialogId: Int) = Unit - override fun onTocRegexDialogResult(tocRegex: String) { - ReadBook.book?.let { - it.tocUrl = tocRegex - loadChapterList(it) - } - } - private fun sureSyncProgress(progress: BookProgress) { alert(R.string.get_book_progress) { setMessage(R.string.current_progress_exceeds_cloud) diff --git a/app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt deleted file mode 100644 index e87865915..000000000 --- a/app/src/main/java/io/legado/app/ui/book/toc/BookmarkAdapter.kt +++ /dev/null @@ -1,57 +0,0 @@ -package io.legado.app.ui.book.toc - -import android.content.Context -import android.view.ViewGroup -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.base.adapter.RecyclerAdapter -import io.legado.app.data.entities.Bookmark -import io.legado.app.databinding.ItemBookmarkBinding -import io.legado.app.utils.gone -import splitties.views.onClick -import splitties.views.onLongClick - -class BookmarkAdapter(context: Context, val callback: Callback) : - RecyclerAdapter(context) { - - override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding { - return ItemBookmarkBinding.inflate(inflater, parent, false) - } - - override fun convert( - holder: ItemViewHolder, - binding: ItemBookmarkBinding, - item: Bookmark, - payloads: MutableList - ) { - binding.tvChapterName.text = item.chapterName - binding.tvBookText.gone(item.bookText.isEmpty()) - binding.tvBookText.text = item.bookText - binding.tvContent.gone(item.content.isEmpty()) - binding.tvContent.text = item.content - } - - override fun registerListener(holder: ItemViewHolder, binding: ItemBookmarkBinding) { - binding.root.onLongClick { - getItem(holder.layoutPosition)?.let { bookmark -> - callback.onLongClick(bookmark, holder.layoutPosition) - } - } - binding.root.onClick { - getItem(holder.layoutPosition)?.let { bookmark -> - callback.onClick(bookmark) - } - } - binding.btnLocate.onClick { - getItem(holder.layoutPosition)?.let { bookmark -> - callback.onLocate(bookmark) - } - } - } - - interface Callback { - fun onClick(bookmark: Bookmark) - fun onLongClick(bookmark: Bookmark, pos: Int) - fun onLocate(bookmark: Bookmark) - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt b/app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt deleted file mode 100644 index a77fdf570..000000000 --- a/app/src/main/java/io/legado/app/ui/book/toc/BookmarkFragment.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.legado.app.ui.book.toc - -import android.app.Activity -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import io.legado.app.R -import io.legado.app.base.VMBaseFragment -import io.legado.app.constant.AppLog -import io.legado.app.data.appDb -import io.legado.app.data.entities.Bookmark -import io.legado.app.databinding.FragmentBookmarkBinding -import io.legado.app.ui.book.bookmark.BookmarkDialog -import io.legado.app.ui.widget.recycler.UpLinearLayoutManager -import io.legado.app.utils.applyNavigationBarPadding -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - - -class BookmarkFragment : VMBaseFragment(R.layout.fragment_bookmark), - BookmarkAdapter.Callback, - TocViewModel.BookmarkCallBack { - override val viewModel by activityViewModels() - private val binding by viewBinding(FragmentBookmarkBinding::bind) - private val mLayoutManager by lazy { UpLinearLayoutManager(requireContext()) } - private val adapter by lazy { BookmarkAdapter(requireContext(), this) } - private var durChapterIndex = 0 - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) { - viewModel.bookMarkCallBack = this - initRecyclerView() - viewModel.bookData.observe(this) { - durChapterIndex = it.durChapterIndex - upBookmark(null) - } - } - - private fun initRecyclerView() { - //binding.recyclerView.setEdgeEffectColor(primaryColor) - binding.recyclerView.layoutManager = mLayoutManager - binding.recyclerView.adapter = adapter - binding.recyclerView.applyNavigationBarPadding() - binding.tvEmptyMsg.visible(false) - } - - override fun upBookmark(searchKey: String?) { - val book = viewModel.bookData.value ?: return - lifecycleScope.launch { - val flow = when { - searchKey.isNullOrBlank() -> appDb.bookmarkDao.flowByBook(book.name, book.author) - else -> appDb.bookmarkDao.flowSearch(book.name, book.author, searchKey) - } - - flow.catch { - AppLog.put("目录界面获取书签数据失败\n${it.localizedMessage}", it) - }.flowOn(IO).collect { list -> - adapter.setItems(list) - binding.tvEmptyMsg.visible(list.isEmpty()) - var scrollPos = 0 - withContext(Dispatchers.Default) { - adapter.getItems().forEachIndexed { index, bookmark -> - if (bookmark.chapterIndex >= durChapterIndex) return@withContext - scrollPos = index - } - } - mLayoutManager.scrollToPositionWithOffset(scrollPos, 0) - } - } - } - - - override fun onClick(bookmark: Bookmark) { - activity?.run { - setResult(Activity.RESULT_OK, Intent().apply { - putExtra("index", bookmark.chapterIndex) - putExtra("chapterPos", bookmark.chapterPos) - }) - finish() - } - } - - override fun onLongClick(bookmark: Bookmark, pos: Int) { - showDialogFragment(BookmarkDialog(bookmark, pos)) - } - - override fun onLocate(bookmark: Bookmark) { - (activity as? TocActivity)?.locateToChapter(bookmark.chapterIndex) - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt b/app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt deleted file mode 100644 index fa057a942..000000000 --- a/app/src/main/java/io/legado/app/ui/book/toc/ChapterListAdapter.kt +++ /dev/null @@ -1,434 +0,0 @@ -package io.legado.app.ui.book.toc - -//import io.legado.app.lib.theme.accentColor -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import io.legado.app.R -import io.legado.app.base.adapter.DiffRecyclerAdapter -import io.legado.app.base.adapter.ItemViewHolder -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookChapter -import io.legado.app.databinding.ItemChapterListBinding -import io.legado.app.help.book.ContentProcessor -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.theme.ThemeUtils -import io.legado.app.utils.gone -import io.legado.app.utils.themeColor -import io.legado.app.utils.visible -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.concurrent.ConcurrentHashMap - -class ChapterListAdapter( - context: Context, - val callback: Callback -) : DiffRecyclerAdapter(context) { - - val cacheFileNames = hashSetOf() - private val displayTitleMap = ConcurrentHashMap() - private val selectedIndices = LinkedHashSet() - - private val handler = Handler(Looper.getMainLooper()) - private var upDisplayTileJob: Job? = null - private val collapsedVolumes = mutableSetOf() // 保存折叠的卷index - private var visibleItems = mutableListOf() // 当前显示 - - override val diffItemCallback: DiffUtil.ItemCallback - get() = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: BookChapter, newItem: BookChapter): Boolean { - return oldItem.index == newItem.index - } - - override fun areContentsTheSame(oldItem: BookChapter, newItem: BookChapter): Boolean { - return oldItem.bookUrl == newItem.bookUrl - && oldItem.url == newItem.url - && oldItem.isVip == newItem.isVip - && oldItem.isPay == newItem.isPay - && oldItem.title == newItem.title - && oldItem.tag == newItem.tag - && oldItem.wordCount == newItem.wordCount - && oldItem.isVolume == newItem.isVolume - } - } - - override fun onCurrentListChanged() { - super.onCurrentListChanged() - rebuildVisibleItems() - callback.onListChanged() - } - - fun clearDisplayTitle() { - upDisplayTileJob?.cancel() - displayTitleMap.clear() - } - - fun upDisplayTitles(startIndex: Int) { - upDisplayTileJob?.cancel() - upDisplayTileJob = callback.scope.launch(Dispatchers.Default) { - val book = callback.book ?: return@launch - val replaceRules = ContentProcessor.get(book.name, book.origin).getTitleReplaceRules() - val useReplace = AppConfig.tocUiUseReplace && book.getUseReplaceRule() - val items = getItems() - val indices = ((startIndex until items.size) + (startIndex downTo 0)) - .filter { it in items.indices } - - for (i in indices) { - val item = items[i] - val key = item.url - if (displayTitleMap[key] == null) { - ensureActive() - val displayTitle = item.getDisplayTitle(replaceRules, useReplace) - displayTitleMap[key] = displayTitle - ensureActive() - withContext(Dispatchers.Main) { - notifyItemChanged(i, true) - } - } - } - } - } - - fun isInSelectionMode(): Boolean = selectedIndices.isNotEmpty() - - fun toggleSelection(chapter: BookChapter, position: Int? = null) { - val pos = position ?: getItems().indexOfFirst { it.index == chapter.index } - if (pos == -1) return - - if (selectedIndices.contains(chapter.index)) { - selectedIndices.remove(chapter.index) - } else { - selectedIndices.add(chapter.index) - } - notifyItemChanged(pos, true) - callback.onSelectionModeChanged(isInSelectionMode()) - } - - fun getSelectedChapters(): List = - getItems().filter { selectedIndices.contains(it.index) } - - fun clearSelection() { - if (selectedIndices.isNotEmpty()) { - val prevSelected = selectedIndices.toList() - selectedIndices.clear() - prevSelected.forEach { index -> - val pos = getItems().indexOfFirst { it.index == index } - if (pos != -1) notifyItemChanged(pos, true) - } - callback.onSelectionModeChanged(false) - } - } - - fun selectAll() { - val items = getItems() - val changedIndices = items.map { it.index }.filter { !selectedIndices.contains(it) } - selectedIndices.clear() - selectedIndices.addAll(items.map { it.index }) - changedIndices.forEach { index -> - val pos = items.indexOfFirst { it.index == index } - if (pos != -1) notifyItemChanged(pos, true) - } - callback.onSelectionModeChanged(true) - } - - fun invertSelection() { - val items = getItems() - val newSelected = items.map { it.index }.toSet() - selectedIndices - val changedIndices = (selectedIndices + newSelected) // 之前选中 + 新选中 = 改变的项 - selectedIndices.clear() - selectedIndices.addAll(newSelected) - changedIndices.forEach { index -> - val pos = items.indexOfFirst { it.index == index } - if (pos != -1) notifyItemChanged(pos, true) - } - callback.onSelectionModeChanged(selectedIndices.isNotEmpty()) - } - - fun selectFrom() { - val items = getItems() - val startPos = selectedIndices.lastOrNull()?.let { lastIndex -> - items.indexOfFirst { it.index == lastIndex } - } ?: return - - if (startPos !in items.indices) return - - val changedIndices = mutableListOf() - for (i in startPos until items.size) { - val chapterIndex = items[i].index - if (!selectedIndices.contains(chapterIndex)) { - selectedIndices.add(chapterIndex) - changedIndices.add(i) - } - } - - changedIndices.forEach { notifyItemChanged(it, true) } - callback.onSelectionModeChanged(isInSelectionMode()) - } - - private fun rebuildVisibleItems() { - val all = getItems() - visibleItems.clear() - var currentVolumeCollapsed = false - for (item in all) { - if (item.isVolume) { - visibleItems.add(item) - currentVolumeCollapsed = collapsedVolumes.contains(item.index) - } else if (!currentVolumeCollapsed) { - visibleItems.add(item) - } - } - } - - fun toggleVolume(volume: BookChapter) { - if (!volume.isVolume) return - - val all = getItems() - val volumeIndex = volume.index - val startIndex = all.indexOf(volume) - if (startIndex == -1) return - - val visiblePos = visibleItems.indexOf(volume) - if (visiblePos == -1) return - - if (collapsedVolumes.contains(volumeIndex)) { - collapsedVolumes.remove(volumeIndex) - - val toAdd = mutableListOf() - for (i in startIndex + 1 until all.size) { - val next = all[i] - if (next.isVolume) break - toAdd.add(next) - } - - if (toAdd.isNotEmpty()) { - val insertPos = visiblePos + 1 - visibleItems.addAll(insertPos, toAdd) - notifyItemRangeInserted(insertPos, toAdd.size) - } - - notifyItemChanged(visiblePos) - - } else { - collapsedVolumes.add(volumeIndex) - - val toRemove = mutableListOf() - for (i in startIndex + 1 until all.size) { - val next = all[i] - if (next.isVolume) break - toRemove.add(next) - } - - if (toRemove.isNotEmpty()) { - val removeStart = visiblePos + 1 - visibleItems.removeAll(toRemove) - notifyItemRangeRemoved(removeStart, toRemove.size) - } - - notifyItemChanged(visiblePos) - } - } - - fun expandAllVolumes() { - val all = getItems() - var insertedOffset = 0 - - val collapsedCopy = collapsedVolumes.toList() - collapsedVolumes.clear() - - for (volumeIndex in collapsedCopy) { - val volume = all.firstOrNull { it.isVolume && it.index == volumeIndex } ?: continue - val start = all.indexOf(volume) - if (start == -1) continue - - val visiblePos = visibleItems.indexOf(volume) - if (visiblePos == -1) continue - - val children = mutableListOf() - for (i in start + 1 until all.size) { - val next = all[i] - if (next.isVolume) break - children.add(next) - } - - if (children.isNotEmpty()) { - val insertPos = visiblePos + 1 - visibleItems.addAll(insertPos, children) - notifyItemRangeInserted(insertPos, children.size) - notifyItemChanged(visiblePos) - insertedOffset += children.size - } - } - } - - fun collapseAllVolumes() { - val all = getItems() - - for (volume in all.filter { it.isVolume }) { - val visiblePos = visibleItems.indexOf(volume) - if (visiblePos == -1) continue - - val volumeIndex = volume.index - collapsedVolumes.add(volumeIndex) - - val toRemove = mutableListOf() - for (i in visiblePos + 1 until visibleItems.size) { - val next = visibleItems[i] - if (next.isVolume) break - toRemove.add(next) - } - - if (toRemove.isNotEmpty()) { - val removeStart = visiblePos + 1 - visibleItems.removeAll(toRemove) - notifyItemRangeRemoved(removeStart, toRemove.size) - notifyItemChanged(visiblePos) - } - } - } - - fun getAllVolumes(): List { - return getItems().filter { it.isVolume }.map { it.title } - } - - fun getVolumeStartPosition(volumePosition: Int): Int { - val volumes = getItems().filter { it.isVolume } - val volume = volumes.getOrNull(volumePosition) ?: return -1 - return getItems().indexOf(volume) - } - - fun areAllVolumesExpanded(): Boolean { - return getItems().filter { it.isVolume }.all { !collapsedVolumes.contains(it.index) } - } - - private fun getDisplayTitle(chapter: BookChapter): String = - displayTitleMap[chapter.url] ?: chapter.title - - override fun getItemCount(): Int = visibleItems.size - - override fun getItem(position: Int): BookChapter? = - visibleItems.getOrNull(position) - - override fun getViewBinding(parent: ViewGroup): ItemChapterListBinding = - ItemChapterListBinding.inflate(inflater, parent, false) - - override fun convert( - holder: ItemViewHolder, - binding: ItemChapterListBinding, - item: BookChapter, - payloads: MutableList - ) { - val isDur = callback.durChapterIndex() == item.index - val cached = item.isVolume || cacheFileNames.contains(item.getFileName()) - val isSelected = selectedIndices.contains(item.index) - val isCollapsed = collapsedVolumes.contains(item.index) - - binding.run { - if (payloads.isEmpty()) { - tvChapterName.text = getDisplayTitle(item) - tvChapterItem.foreground = - ThemeUtils.resolveDrawable(context, android.R.attr.selectableItemBackground) - - ivChecked.isVisible = !callback.isLocalBook - - if (!item.tag.isNullOrEmpty() && !item.isVolume) { - tvTag.text = item.tag - tvTag.visible() - } else tvTag.gone() - - if (AppConfig.tocCountWords && !item.wordCount.isNullOrEmpty() && !item.isVolume) { - tvWordCount.text = item.wordCount - tvWordCount.visible() - } else tvWordCount.gone() - - if (item.isVip && !item.isPay) ivLocked.visible() else ivLocked.gone() - } else { - tvChapterName.text = getDisplayTitle(item) - } - upHasCache(binding, cached, isDur) - - when { - item.isVolume -> { - ivVolume.visible() - ivVolume.isChecked = isCollapsed - tvChapterName.textSize = 12f - tvChapterName.setTextColor(context.themeColor(com.google.android.material.R.attr.colorSecondary)) - tvChapterItem.setBackgroundColor(context.themeColor(com.google.android.material.R.attr.colorSurface)) - } - - isDur -> { - ivVolume.gone() - tvChapterName.textSize = 14f - tvChapterName.setTextColor(context.themeColor(androidx.appcompat.R.attr.colorPrimary)) - tvChapterItem.setBackgroundColor(context.themeColor(com.google.android.material.R.attr.colorSurfaceContainer)) - } - - else -> { - ivVolume.gone() - tvChapterName.textSize = 14f - tvChapterName.setTextColor(context.themeColor(com.google.android.material.R.attr.colorOnSurface)) - tvChapterItem.setBackgroundColor(context.themeColor(com.google.android.material.R.attr.colorSurface)) - } - } - - if (isSelected) { - tvChapterItem.setBackgroundColor( - context.themeColor(com.google.android.material.R.attr.colorSurfaceContainerHighest) - ) - } - } - } - - - override fun registerListener(holder: ItemViewHolder, binding: ItemChapterListBinding) { - holder.itemView.setOnClickListener { - val pos = holder.bindingAdapterPosition - if (pos == RecyclerView.NO_POSITION) return@setOnClickListener - val item = getItem(pos) ?: return@setOnClickListener - - if (item.isVolume) { - toggleVolume(item) - return@setOnClickListener - } - - if (isInSelectionMode()) { - toggleSelection(item, pos) - } else { - callback.openChapter(item) - } - } - - holder.itemView.setOnLongClickListener { - val pos = holder.bindingAdapterPosition - if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener true - val item = getItem(pos) ?: return@setOnLongClickListener true - - toggleSelection(item, pos) - true - } - } - - private fun upHasCache(binding: ItemChapterListBinding, cached: Boolean, isDur: Boolean = false) = binding.apply { - ivChecked.setImageResource( - if (cached) R.drawable.ic_download_done - else if (isDur) R.drawable.ic_locate - else R.drawable.ic_outline_cloud_24) - } - - interface Callback { - val scope: CoroutineScope - val book: Book? - val isLocalBook: Boolean - fun openChapter(bookChapter: BookChapter) - fun durChapterIndex(): Int - fun onListChanged() - fun onSelectionModeChanged(enabled: Boolean) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt b/app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt deleted file mode 100644 index f02515c63..000000000 --- a/app/src/main/java/io/legado/app/ui/book/toc/ChapterListFragment.kt +++ /dev/null @@ -1,432 +0,0 @@ -package io.legado.app.ui.book.toc - -import android.animation.ArgbEvaluator -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import android.app.Activity.RESULT_OK -import android.content.Intent -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Rect -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.transition.TransitionManager -import android.view.View -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toDrawable -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.interpolator.view.animation.FastOutSlowInInterpolator -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.transition.platform.MaterialFadeThrough -import io.legado.app.R -import io.legado.app.base.VMBaseFragment -import io.legado.app.constant.EventBus -import io.legado.app.data.appDb -import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookChapter -import io.legado.app.databinding.FragmentChapterListBinding -import io.legado.app.help.book.BookHelp -import io.legado.app.help.book.isLocal -import io.legado.app.help.book.simulatedTotalChapterNum -import io.legado.app.model.CacheBook -import io.legado.app.ui.widget.recycler.UpLinearLayoutManager -import io.legado.app.utils.VibrationUtils -import io.legado.app.utils.applyNavigationBarPadding -import io.legado.app.utils.dpToPx -import io.legado.app.utils.gone -import io.legado.app.utils.observeEvent -import io.legado.app.utils.postEvent -import io.legado.app.utils.themeColor -import io.legado.app.utils.toastOnUi -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.math.min - -class ChapterListFragment : VMBaseFragment(R.layout.fragment_chapter_list), - ChapterListAdapter.Callback, - TocViewModel.ChapterListCallBack { - override val viewModel by activityViewModels() - private val binding by viewBinding(FragmentChapterListBinding::bind) - private val mLayoutManager by lazy { UpLinearLayoutManager(requireContext()) } - private val adapter by lazy { ChapterListAdapter(requireContext(), this) } - private var durChapterIndex = 0 - private var swipeHelper: ItemTouchHelper? = null - - override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) = binding.run { - viewModel.chapterListCallBack = this@ChapterListFragment - initRecyclerView() - initView() - viewModel.bookData.observe(this@ChapterListFragment) { - initBook(it) - initSwipeIfNeed(it.isLocal) - } - } - - private fun initRecyclerView() { - binding.recyclerView.layoutManager = mLayoutManager - binding.recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() { - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State - ) { - if (parent.getChildAdapterPosition(view) == state.itemCount - 1) { - outRect.bottom = 80.dpToPx() - } - } - }) - - binding.recyclerView.adapter = adapter - } - - private fun initSwipeIfNeed(isLocalBook: Boolean) { - binding.btnDownloadSelected.isEnabled = !isLocalBook - swipeHelper?.attachToRecyclerView(null) - if (!isLocalBook){ - val swipeCallback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.RIGHT) { - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean = false - - override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float = 0.3f - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val position = viewHolder.bindingAdapterPosition - val chapter = adapter.getItem(position) ?: return - val book = book ?: return - - toastOnUi("开始下载: ${adapter.getItem(position)?.title}") - CacheBook.start(requireContext(), book, listOf(chapter.index)) - VibrationUtils.vibratePattern(requireContext(), longArrayOf(0, 50, 30, 50), -1) - adapter.notifyItemChanged(position) - } - - override fun onChildDraw( - c: Canvas, - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - dX: Float, - dY: Float, - actionState: Int, - isCurrentlyActive: Boolean - ) { - if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && dX > 0) { - val itemView = viewHolder.itemView - - val interpolator = FastOutSlowInInterpolator() - val dampedDx = - interpolator.getInterpolation(min(1f, dX / itemView.width)) * itemView.width - - // 背景 - val background = - requireContext().themeColor(com.google.android.material.R.attr.colorSecondary) - .toDrawable() - background.setBounds( - itemView.left, - itemView.top, - itemView.left + dampedDx.toInt(), - itemView.bottom - ) - background.draw(c) - - // 图标 - val icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_download)!! - val iconMargin = (itemView.height - icon.intrinsicHeight) / 2 - val iconTop = itemView.top + iconMargin - val iconBottom = iconTop + icon.intrinsicHeight - val iconLeft = 12.dpToPx() - val iconRight = iconLeft + icon.intrinsicWidth - icon.setTint(requireContext().themeColor(com.google.android.material.R.attr.colorOnSecondary)) - icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) - icon.draw(c) - - super.onChildDraw( - c, - recyclerView, - viewHolder, - dampedDx, - dY, - actionState, - isCurrentlyActive - ) - } else { - super.onChildDraw( - c, - recyclerView, - viewHolder, - dX, - dY, - actionState, - isCurrentlyActive - ) - } - } - } - val itemTouchHelper = ItemTouchHelper(swipeCallback) - itemTouchHelper.attachToRecyclerView(binding.recyclerView) - } - } - - private fun initView() = binding.run { - llBase.applyNavigationBarPadding() - btnChapterTop.setOnClickListener { - mLayoutManager.scrollToPositionWithOffset(0, 0) - } - btnChapterBottom.setOnClickListener { - if (adapter.itemCount > 0) { - mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0) - } - } - btnLocate.setOnClickListener { - mLayoutManager.scrollToPositionWithOffset(durChapterIndex, 0) - } - btnSelectAll.setOnClickListener { - adapter.selectAll() - toastOnUi("已全选 ${adapter.itemCount} 个章节") - } - btnInvertSelection.setOnClickListener { - adapter.invertSelection() - toastOnUi("已反选") - } - btnFromSelection.setOnClickListener { - adapter.selectFrom() - toastOnUi("已选择之后的章节") - } - - btnDownloadSelected.setOnClickListener { - val chapters = adapter.getSelectedChapters() - if (chapters.isNotEmpty()) { - val book = book ?: return@setOnClickListener - val indices = chapters.map { it.index } - CacheBook.start(requireContext(), book, indices) - toastOnUi("开始下载 ${chapters.size} 个章节") - adapter.clearSelection() - } else { - toastOnUi("未选择章节") - } - } - btnExit.setOnClickListener { - adapter.clearSelection() - } - btnBookmarkSelection.setOnClickListener { - addSelectedChaptersBookmarkDirectly() - toastOnUi("已添加书签") - } - } - - @SuppressLint("SetTextI18n") - private fun initBook(book: Book) { - lifecycleScope.launch { - upChapterList(null) - durChapterIndex = book.durChapterIndex - binding.tvCurrentChapterInfo.text = - "${book.durChapterTitle}" - binding.tvCurrentChapterAll.text = - "${book.durChapterIndex + 1}/${book.simulatedTotalChapterNum()}" - initCacheFileNames(book) - } - } - - private fun initCacheFileNames(book: Book) { - lifecycleScope.launch(IO) { - adapter.cacheFileNames.addAll(BookHelp.getChapterFiles(book)) - withContext(Main) { - adapter.notifyItemRangeChanged(0, adapter.itemCount, true) - } - } - } - - fun addSelectedChaptersBookmarkDirectly() { - val book = viewModel.bookData.value ?: return - val selectedChapters = adapter.getSelectedChapters() - if (selectedChapters.isEmpty()) return - - lifecycleScope.launch { - withContext(IO) { - selectedChapters.forEach { chapter -> - val page = chapter.title - val bookmark = book.createBookMark().apply { - chapterIndex = chapter.index - chapterPos = 0 - chapterName = page - // 不设置 content 或 bookText,或者只存标题 - } - appDb.bookmarkDao.insert(bookmark) - } - } - - adapter.clearSelection() - } - } - - fun areAllVolumesExpanded(): Boolean = adapter.areAllVolumesExpanded() - - fun toggleAllVolumes() { - val allExpanded = adapter.areAllVolumesExpanded() - if (allExpanded) { - adapter.collapseAllVolumes() - toastOnUi("已收起所有卷") - } else { - adapter.expandAllVolumes() - toastOnUi("已展开所有卷") - } - } - - fun isInSelectionMode(): Boolean = adapter.isInSelectionMode() - - fun toggleChapterSelection() { - if (adapter.isInSelectionMode()) { - adapter.clearSelection() - toastOnUi("已取消选择") - } else { - adapter.selectAll() - toastOnUi("已全选 ${adapter.itemCount} 个章节") - } - } - - fun getAllVolumes(): List { - return adapter.getAllVolumes() - } - - fun scrollToVolume(volumeIndex: Int) { - val volumes = getAllVolumes() - val volumeName = volumes.getOrNull(volumeIndex) ?: "未知卷" - - val position = adapter.getVolumeStartPosition(volumeIndex) - if (position >= 0) { - (binding.recyclerView.layoutManager as? LinearLayoutManager) - ?.scrollToPositionWithOffset(position, 0) - - toastOnUi("已跳转到:$volumeName") - } - } - - fun scrollToChapter(chapterIndex: Int) { - val pos = adapter.getItems().indexOfFirst { it.index == chapterIndex } - if (pos != -1) { - mLayoutManager.scrollToPositionWithOffset(pos, 0) - - binding.recyclerView.post { - val holder = binding.recyclerView.findViewHolderForAdapterPosition(pos) - holder?.itemView?.let { view -> - flashHighlight(view) - } - } - } - } - - private fun flashHighlight(view: View) { - val highlightColor = context?.themeColor(com.google.android.material.R.attr.colorSurfaceContainerHighest) - val originalColor = (view.background as? ColorDrawable)?.color ?: Color.TRANSPARENT - val anim = ValueAnimator.ofObject(ArgbEvaluator(), originalColor, highlightColor, originalColor).apply { - duration = 500 - repeatCount = 1 - repeatMode = ValueAnimator.RESTART - addUpdateListener { animator -> - val color = animator.animatedValue as Int - view.setBackgroundColor(color) - } - } - anim.start() - } - - - override fun onSelectionModeChanged(enabled: Boolean) { - TransitionManager.beginDelayedTransition(binding.coordinatorLayout, MaterialFadeThrough()) - if (enabled) { - binding.llBase.gone() - binding.floatingToolbarBottom.visible() - } else { - binding.llBase.visible() - binding.floatingToolbarBottom.gone() - } - } - - override fun observeLiveBus() { - observeEvent>(EventBus.SAVE_CONTENT) { (book, chapter) -> - viewModel.bookData.value?.bookUrl?.let { bookUrl -> - if (book.bookUrl == viewModel.bookData.value?.bookUrl) { - adapter.cacheFileNames.add(chapter.getFileName()) - val index = adapter.getItems().indexOfFirst { it.index == chapter.index } - if (index >= 0) { - adapter.notifyItemChanged(index, true) - } - } - } - } - } - - override fun upChapterList(searchKey: String?) { - lifecycleScope.launch { - withContext(IO) { - val end = (book?.simulatedTotalChapterNum() ?: Int.MAX_VALUE) - 1 - when { - searchKey.isNullOrBlank() -> - appDb.bookChapterDao.getChapterList(viewModel.bookUrl, 0, end) - - else -> appDb.bookChapterDao.search(viewModel.bookUrl, searchKey, 0, end) - } - }.let { - adapter.setItems(it) - } - postEvent(EventBus.UP_TOC,true) - } - } - - override fun onListChanged() { - lifecycleScope.launch { - val scrollPos = adapter.getItems() - .indexOfLast { it.index < durChapterIndex } - .coerceAtLeast(0) - mLayoutManager.scrollToPositionWithOffset(scrollPos, 0) - adapter.upDisplayTitles(scrollPos) - } - } - - override fun clearDisplayTitle() { - adapter.clearDisplayTitle() - adapter.upDisplayTitles(mLayoutManager.findFirstVisibleItemPosition()) - } - - override fun upAdapter() { - adapter.notifyItemRangeChanged(0, adapter.itemCount) - } - - override val scope: CoroutineScope - get() = lifecycleScope - - override val book: Book? - get() = viewModel.bookData.value - - override val isLocalBook: Boolean - get() = viewModel.bookData.value?.isLocal == true - - override fun durChapterIndex(): Int { - return durChapterIndex - } - - override fun openChapter(bookChapter: BookChapter) { - activity?.run { - setResult( - RESULT_OK, Intent() - .putExtra("index", bookChapter.index) - .putExtra("chapterChanged", bookChapter.index != durChapterIndex) - ) - finish() - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt b/app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt new file mode 100644 index 000000000..0c2dbeb4b --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/DownloadState.kt @@ -0,0 +1,8 @@ +package io.legado.app.ui.book.toc + +enum class DownloadState { + NONE, + DOWNLOADING, + SUCCESS, + ERROR +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt index 6c9faa8b0..1943441f2 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt @@ -2,392 +2,40 @@ package io.legado.app.ui.book.toc -import android.annotation.SuppressLint import android.content.Intent -import android.graphics.Color -import android.graphics.Typeface -import android.os.Bundle -import android.text.Spannable -import android.text.SpannableString -import android.text.style.AbsoluteSizeSpan -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.SearchView -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentPagerAdapter -import com.google.android.material.tabs.TabLayout -import io.legado.app.R -import io.legado.app.base.VMBaseActivity -import io.legado.app.constant.EventBus -import io.legado.app.data.entities.Book -import io.legado.app.databinding.ActivityChapterListBinding -import io.legado.app.databinding.DialogDownloadChoiceBinding -import io.legado.app.help.book.isLocal -import io.legado.app.help.book.isLocalTxt -import io.legado.app.help.config.AppConfig -import io.legado.app.lib.dialogs.alert -import io.legado.app.model.CacheBook -import io.legado.app.model.ReadBook -import io.legado.app.ui.about.AppLogDialog -import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog -import io.legado.app.ui.file.HandleFileContract -import io.legado.app.ui.replace.ReplaceEditRoute +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import io.legado.app.base.BaseComposeActivity import io.legado.app.ui.replace.ReplaceRuleActivity -import io.legado.app.ui.widget.dialog.WaitDialog -import io.legado.app.utils.gone -import io.legado.app.utils.observeEvent -import io.legado.app.utils.showDialogFragment -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.utils.visible /** * 目录 */ -class TocActivity : VMBaseActivity(), - TxtTocRuleDialog.CallBack { +class TocActivity : BaseComposeActivity() { - override val binding by viewBinding(ActivityChapterListBinding::inflate) - override val viewModel by viewModels() - - private lateinit var tabLayout: TabLayout - private var menu: Menu? = null - private var searchView: SearchView? = null - private val waitDialog by lazy { WaitDialog(this) } - private var isLocalBook : Boolean = false - private val exportDir = registerForActivityResult(HandleFileContract()) { - it.uri?.let { uri -> - when (it.requestCode) { - 1 -> viewModel.saveBookmark(uri) - 2 -> viewModel.saveBookmarkMd(uri) - } - } - } - private val replaceActivity = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - viewModel.replaceRuleChanged() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setSupportActionBar(binding.topBar) - tabLayout = binding.tabLayout - binding.viewPager.adapter = TabFragmentPageAdapter() - tabLayout.setupWithViewPager(binding.viewPager) - viewModel.bookData.observe(this) { book -> - menu?.setGroupVisible(R.id.menu_group_text, book.isLocalTxt) - supportActionBar?.title = book.name - binding.topBar.menu.findItem(R.id.menu_download)?.isVisible = !book.isLocal - } - intent.getStringExtra("bookUrl")?.let { - viewModel.initBook(it) - } - setupTabListener() - setupMoreMenuButton() + private val bookUrl: String? by lazy { + intent.getStringExtra("bookUrl") } - private fun setupTabListener() { - tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { - override fun onTabSelected(tab: TabLayout.Tab) { - updateButtonVisibility(tab.position) - } - override fun onTabUnselected(tab: TabLayout.Tab) {} + @Composable + override fun Content() { + val context = LocalContext.current - override fun onTabReselected(tab: TabLayout.Tab) { - if (tab.position == 0) { - doReverseToc() + TocScreen( + onBackClick = { finish() }, + bookUrl = bookUrl, + onChapterClick = { index -> + val data = Intent().apply { + putExtra("index", index) } - } - }) + setResult(RESULT_OK, data) + finish() + }, + onOpenReplaceRule = { editRoute -> + val intent = ReplaceRuleActivity.startIntent(context, editRoute) + context.startActivity(intent) + }, + onBookmarkClick = { _, _ -> /* 书签点击 */ }, + ) } - - private fun doReverseToc() { - viewModel.reverseToc { - viewModel.chapterListCallBack?.upChapterList(searchView?.query?.toString()) - setResult(RESULT_OK, Intent().apply { - putExtra("index", it.durChapterIndex) - putExtra("chapterPos", 0) - }) - } - } - - private fun updateButtonVisibility(tabPosition: Int) { - binding.btnChapterMenu.isEnabled = tabPosition == 0 - } - - private fun setupMoreMenuButton() { - val tag = "android:switcher:${binding.viewPager.id}:0" - val chapterFragment = supportFragmentManager.findFragmentByTag(tag) as? ChapterListFragment - val volumes = chapterFragment?.getAllVolumes().orEmpty() - binding.btnChapterMenu.isVisible = volumes.isNotEmpty() - binding.btnChapterMenu.setOnClickListener { view -> - showChapterActionsMenu(view) - } - } - - private fun showChapterActionsMenu(anchor: View) { - val popup = PopupMenu(this, anchor) - popup.menuInflater.inflate(R.menu.menu_chapter_actions, popup.menu) - val tag = "android:switcher:${binding.viewPager.id}:0" - val chapterFragment = supportFragmentManager.findFragmentByTag(tag) as? ChapterListFragment - val volumes = chapterFragment?.getAllVolumes().orEmpty() - volumes.forEachIndexed { index, volumeName -> - popup.menu.add(R.id.action_jump_to_volume_section, index, Menu.NONE, volumeName) - } - val titleItem = popup.menu.findItem(R.id.action_jump_to_volume_section) - val s = SpannableString(titleItem.title) - s.setSpan(StyleSpan(Typeface.BOLD), 0, s.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE) - s.setSpan(ForegroundColorSpan(Color.GRAY), 0, s.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE) - s.setSpan(AbsoluteSizeSpan(12, true), 0, s.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE) - titleItem.title = s - val toggleSelectionItem = popup.menu.findItem(R.id.action_toggle_selection) - toggleSelectionItem.title = if (chapterFragment?.isInSelectionMode() == true) { - getString(R.string.cancel_select) - } else { - getString(R.string.select_all) - } - - val toggleVolumesItem = popup.menu.findItem(R.id.action_toggle_volumes) - toggleVolumesItem.title = if (chapterFragment?.areAllVolumesExpanded() == true) { - getString(R.string.coll_volume) - } else { - getString(R.string.expand_volume) - } - popup.setOnMenuItemClickListener { item -> - when (item.itemId) { - R.id.action_toggle_volumes -> { - chapterFragment?.toggleAllVolumes() - true - } - R.id.action_toggle_selection -> { - chapterFragment?.toggleChapterSelection() - true - } - else -> { - if (item.groupId == R.id.action_jump_to_volume_section) { - val index = item.itemId - chapterFragment?.scrollToVolume(index) - true - } else false - } - } - } - popup.setOnDismissListener { - binding.btnChapterMenu.isChecked = false - } - popup.show() - } - - override fun observeLiveBus() { - observeEvent(EventBus.UP_TOC) { - setupMoreMenuButton() - } - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.book_toc, menu) - this.menu = menu - val search = menu.findItem(R.id.menu_search) - searchView = (search.actionView as SearchView).apply { - //applyTint(primaryTextColor) - maxWidth = resources.displayMetrics.widthPixels - onActionViewCollapsed() - setOnCloseListener { - tabLayout.visible() - false - } - setOnSearchClickListener { tabLayout.gone() } - setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - viewModel.searchKey = query - return false - } - - override fun onQueryTextChange(newText: String): Boolean { - viewModel.searchKey = newText - if (tabLayout.selectedTabPosition == 1) { - viewModel.startBookmarkSearch(newText) - } else { - viewModel.startChapterListSearch(newText) - } - return false - } - }) - setOnQueryTextFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - searchView?.isIconified = true - } - } - } - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { - if (tabLayout.selectedTabPosition == 1) { - menu.setGroupVisible(R.id.menu_group_bookmark, true) - menu.setGroupVisible(R.id.menu_group_toc, false) - menu.setGroupVisible(R.id.menu_group_text, false) - } else { - menu.setGroupVisible(R.id.menu_group_bookmark, false) - menu.setGroupVisible(R.id.menu_group_toc, true) - menu.setGroupVisible(R.id.menu_group_text, viewModel.bookData.value?.isLocalTxt == true) - } - menu.findItem(R.id.menu_use_replace)?.isChecked = - AppConfig.tocUiUseReplace - menu.findItem(R.id.menu_load_word_count)?.isChecked = - AppConfig.tocCountWords - menu.findItem(R.id.menu_split_long_chapter)?.isChecked = - viewModel.bookData.value?.getSplitLongChapter() == true - return super.onMenuOpened(featureId, menu) - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_replace -> { - val scopes = arrayListOf() - viewModel.bookData.value?.name?.let { scopes.add(it) } - viewModel.bookSource?.bookSourceUrl?.let { scopes.add(it) } - - val editRoute = ReplaceEditRoute( - id = -1, - pattern = "text", - scope = scopes.joinToString(";"), - isScopeTitle = true, - isScopeContent = false - ) - - replaceActivity.launch( - ReplaceRuleActivity.startIntent(this, editRoute) - ) - return true - } - R.id.menu_replace_show -> { - replaceActivity.launch(Intent(this, ReplaceRuleActivity::class.java)) - return true - } - R.id.menu_toc_regex -> showDialogFragment( - TxtTocRuleDialog(viewModel.bookData.value?.tocUrl) - ) - - R.id.menu_download -> showDownloadDialog() - - R.id.menu_split_long_chapter -> { - viewModel.bookData.value?.let { book -> - item.isChecked = !item.isChecked - book.setSplitLongChapter(item.isChecked) - upBookAndToc(book) - } - } - - R.id.menu_reverse_toc -> doReverseToc() - - R.id.menu_use_replace -> { - AppConfig.tocUiUseReplace = !item.isChecked - viewModel.chapterListCallBack?.clearDisplayTitle() - viewModel.chapterListCallBack?.upChapterList(searchView?.query?.toString()) - } - - R.id.menu_load_word_count -> { - AppConfig.tocCountWords = !item.isChecked - viewModel.upChapterListAdapter() - } - - R.id.menu_export_bookmark -> exportDir.launch { - requestCode = 1 - } - - R.id.menu_export_md -> exportDir.launch { - requestCode = 2 - } - - R.id.menu_log -> showDialogFragment() - } - return super.onCompatOptionsItemSelected(item) - } - - override fun onTocRegexDialogResult(tocRegex: String) { - viewModel.bookData.value?.let { book -> - book.tocUrl = tocRegex - upBookAndToc(book) - } - } - - private fun upBookAndToc(book: Book) { - waitDialog.show() - viewModel.upBookTocRule(book) { - waitDialog.dismiss() - if (ReadBook.book == book) { - if (it == null) { - ReadBook.upMsg(null) - } else { - ReadBook.upMsg("LoadTocError:${it.localizedMessage}") - } - } - } - } - - - fun locateToChapter(index: Int) { - val tag = "android:switcher:${binding.viewPager.id}:0" - val chapterFragment = supportFragmentManager.findFragmentByTag(tag) as? ChapterListFragment - chapterFragment?.scrollToChapter(index) - - binding.viewPager.currentItem = 0 - } - - - @SuppressLint("SetTextI18n") - fun showDownloadDialog() { - (viewModel.bookData.value ?: ReadBook.book)?.let { book -> - alert(titleResource = R.string.offline_cache) { - val alertBinding = DialogDownloadChoiceBinding.inflate(layoutInflater).apply { - editStart.setText((book.durChapterIndex + 1).toString()) - editEnd.setText(book.totalChapterNum.toString()) - } - customView { alertBinding.root } - okButton { - alertBinding.run { - val start = editStart.text!!.toString().toIntOrNull() ?: 0 - val end = editEnd.text!!.toString().toIntOrNull() ?: book.totalChapterNum - val indices = (start - 1..end - 1).toList() - CacheBook.start(this@TocActivity, book, indices) - } - } - cancelButton() - } - } - } - - @Suppress("DEPRECATION") - private inner class TabFragmentPageAdapter : - FragmentPagerAdapter(supportFragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { - - override fun getItem(position: Int): Fragment { - return when (position) { - 1 -> BookmarkFragment() - else -> ChapterListFragment() - } - } - - override fun getCount(): Int { - return 2 - } - - override fun getPageTitle(position: Int): CharSequence { - return when (position) { - 1 -> getString(R.string.bookmark) - else -> getString(R.string.chapter_list) - } - } - - } - } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt new file mode 100644 index 000000000..3e9379233 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt @@ -0,0 +1,918 @@ +package io.legado.app.ui.book.toc + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.automirrored.filled.MenuOpen +import androidx.compose.material.icons.filled.BookmarkAdd +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.DownloadForOffline +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.VerticalAlignBottom +import androidx.compose.material.icons.filled.VerticalAlignTop +import androidx.compose.material.icons.rounded.LocationOn +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButtonMenu +import androidx.compose.material3.FloatingActionButtonMenuItem +import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleFloatingActionButton +import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.animateFloatingActionButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.Bookmark +import io.legado.app.help.book.isLocal +import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity +import io.legado.app.ui.replace.ReplaceEditRoute +import io.legado.app.ui.widget.CollapsibleHeader +import io.legado.app.ui.widget.components.ActionItem +import io.legado.app.ui.widget.components.SelectionBottomBar +import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet +import io.legado.app.ui.widget.components.bookmark.BookmarkItem +import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton +import io.legado.app.ui.widget.components.divider.PillDivider +import io.legado.app.ui.widget.components.divider.PillHeaderDivider +import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn +import io.legado.app.ui.widget.components.lazylist.Scroller +import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem +import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun TocScreen( + viewModel: TocViewModel = koinViewModel(), + bookUrl: String?, + onBackClick: () -> Unit, + onChapterClick: (Int) -> Unit, + onOpenReplaceRule: (ReplaceEditRoute?) -> Unit, + onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit, +) { + val context = LocalContext.current + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val book by viewModel.bookState.collectAsStateWithLifecycle() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + val pagerState = rememberPagerState { 2 } + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + val isSelectionMode = state.selectedIds.isNotEmpty() + + val hasVolumes = remember(state.items) { state.items.any { it.chapter.isVolume } } + var showVolumeMenu by remember { mutableStateOf(false) } + + var fabMenuExpanded by rememberSaveable { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + var editingBookmark by remember { mutableStateOf(null) } + + val useReplace = viewModel.useReplace + val showWordCount = viewModel.showWordCount + + val subtitle = remember(pagerState.currentPage, book?.durChapterTitle) { + when (pagerState.currentPage) { + 0 -> book?.durChapterTitle + 1 -> "书签管理" + else -> null + } + } + + val isOnTocPage = pagerState.currentPage == 0 + var isFabVisible by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(listState, isOnTocPage, isSelectionMode) { + + if (!isOnTocPage || isSelectionMode) { + isFabVisible = false + return@LaunchedEffect + } + + var previousIndex = listState.firstVisibleItemIndex + var previousOffset = listState.firstVisibleItemScrollOffset + + snapshotFlow { + listState.firstVisibleItemIndex to + listState.firstVisibleItemScrollOffset + } + .collect { (index, offset) -> + + val scrollingDown = + index > previousIndex || + (index == previousIndex && offset > previousOffset) + + val scrollingUp = + index < previousIndex || + (index == previousIndex && offset < previousOffset) + + when { + scrollingDown -> isFabVisible = false + scrollingUp -> isFabVisible = true + } + + previousIndex = index + previousOffset = offset + } + } + + val fabItems = listOf( + Triple(Icons.Default.LocationOn, "定位至当前阅读") { + scope.launch { + val target = state.items.indexOfFirst { it.chapter.index == book?.durChapterIndex } + if (target != -1) listState.animateScrollToItem(target) + } + }, + Triple(Icons.Default.VerticalAlignTop, "移至顶部") { + scope.launch { listState.animateScrollToItem(0) } + }, + Triple(Icons.Default.VerticalAlignBottom, "移至底部") { + scope.launch { listState.animateScrollToItem(state.items.size) } + }, + Triple(Icons.Default.DownloadForOffline, "下载全部") { + viewModel.downloadAll() + } + ) + + val selectionSecondaryActions = remember(state.selectedIds) { + listOf( + ActionItem( + text = "反选", + icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, + onClick = { viewModel.invertSelection() } + ), + ActionItem( + text = "选择后续", + icon = { Icon(Icons.Default.ExpandMore, contentDescription = null) }, + onClick = { viewModel.selectFromLast() } + ), + ActionItem( + text = "添加书签", + icon = { Icon(Icons.Default.BookmarkAdd, contentDescription = null) }, + onClick = { /* TODO: viewModel.addBookmarksForSelected() */ } + ) + ) + } + + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("*/*") + ) { uri: Uri? -> + uri?.let { + val isActuallyMd = it.toString().endsWith(".md", ignoreCase = true) + viewModel.exportCurrentBookBookmarks(it, isActuallyMd) + } + } + + val tocRegexLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val newRegex = result.data?.getStringExtra("tocRegex") + viewModel.saveTocRegex(newRegex ?: "") + } + } + + LaunchedEffect(isFabVisible) { + if (!isFabVisible && fabMenuExpanded) { + fabMenuExpanded = false + } + } + + LaunchedEffect(bookUrl) { + bookUrl?.let { viewModel.initBook(it) } + } + + BackHandler(enabled = isSelectionMode) { + viewModel.clearSelection() + } + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + DynamicTopAppBar( + title = book?.name ?: "", + subtitle = subtitle, + state = state, + scrollBehavior = scrollBehavior, + onBackClick = onBackClick, + onSearchToggle = { viewModel.setSearchMode(it) }, + onSearchQueryChange = { viewModel.setSearchKey(it) }, + searchPlaceholder = "搜索章节...", + onClearSelection = { viewModel.clearSelection() }, + dropDownMenuContent = { dismiss -> + when (pagerState.currentPage) { + 0 -> { + RoundDropdownMenuItem( + text = "使用替换规则", + trailingIcon = { + Checkbox(checked = useReplace, onCheckedChange = null) + }, + onClick = { viewModel.toggleUseReplace() } + ) + RoundDropdownMenuItem( + text = "显示字数", + trailingIcon = { + Checkbox(checked = showWordCount, onCheckedChange = null) + }, + onClick = { viewModel.toggleShowWordCount() } + ) + RoundDropdownMenuItem( + text = "反转目录", + onClick = { viewModel.reverseToc() } + ) + PillDivider() + RoundDropdownMenuItem( + text = "替换规则", + onClick = { + onOpenReplaceRule(null) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "新建替换规则", + onClick = { + val scopes = mutableListOf() + book?.name?.let { scopes.add(it) } + book?.origin?.let { scopes.add(it) } + + val editRoute = ReplaceEditRoute( + id = -1, + pattern = "", + scope = scopes.joinToString(";"), + isScopeTitle = true, + isScopeContent = false + ) + onOpenReplaceRule(editRoute) + dismiss() + } + ) + if (book?.isLocal == true) { + PillHeaderDivider(title = "本地书籍选项") + RoundDropdownMenuItem( + text = "本地书籍目录规则", + onClick = { + val intent = + Intent(context, TxtTocRuleActivity::class.java).apply { + putExtra("tocRegex", book?.tocUrl) + } + tocRegexLauncher.launch(intent) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "拆分超长章节", + trailingIcon = { + Checkbox( + checked = viewModel.isSplitLongChapter, + onCheckedChange = null + ) + }, + onClick = { + viewModel.toggleSplitLongChapter() + dismiss() + } + ) + } + } + + else -> { + RoundDropdownMenuItem( + text = "导出书签为JSON", + onClick = { + val dateFormat = SimpleDateFormat( + "yyyyMMdd_HHmm", + Locale.getDefault() + ).format(Date()) + val initialName = "${book?.name ?: "书签"}_$dateFormat.json" + exportLauncher.launch(initialName) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "导出书签为MarkDown", + onClick = { + val dateFormat = SimpleDateFormat( + "yyyyMMdd_HHmm", + Locale.getDefault() + ).format(Date()) + val initialName = "${book?.name ?: "书签"}_$dateFormat.md" + exportLauncher.launch(initialName) + dismiss() + } + ) + } + } + }, + bottomContent = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + PrimaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + edgePadding = 0.dp, + divider = {}, + modifier = Modifier.weight(1f) + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { + Text( + text = "目录", + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { + Text( + text = "书签", + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + + if (pagerState.currentPage == 0 && hasVolumes) { + Box( + modifier = Modifier + .padding(end = 16.dp) + ) { + SmallOutlinedIconToggleButton( + checked = showVolumeMenu, + onCheckedChange = { showVolumeMenu = it }, + icon = Icons.AutoMirrored.Filled.FormatListBulleted, + contentDescription = "卷管理" + ) + DropdownMenu( + expanded = showVolumeMenu, + onDismissRequest = { showVolumeMenu = false } + ) { + DropdownMenuItem( + text = { Text("展开所有卷") }, + onClick = { + viewModel.expandAllVolumes(); showVolumeMenu = false + } + ) + DropdownMenuItem( + text = { Text("收起所有卷") }, + onClick = { + viewModel.collapseAllVolumes(); showVolumeMenu = false + } + ) + HorizontalDivider() + val volumeItems = state.items.filter { it.chapter.isVolume } + if (volumeItems.isNotEmpty()) { + Text( + "快速跳转", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding( + horizontal = 12.dp, + vertical = 8.dp + ), + color = MaterialTheme.colorScheme.primary + ) + volumeItems.forEach { uiItem -> + DropdownMenuItem( + text = { + Text( + uiItem.displayTitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + onClick = { + scope.launch { + val targetIndex = + state.items.indexOfFirst { it.chapter.index == uiItem.chapter.index } + + if (targetIndex != -1) { + listState.animateScrollToItem( + targetIndex + ) + } + } + showVolumeMenu = false + } + ) + } + } + } + } + } + } + } + ) + }, + floatingActionButton = { + FloatingActionButtonMenu( + modifier = Modifier + .offset(x = 16.dp, y = 16.dp), + expanded = fabMenuExpanded, + button = { + ToggleFloatingActionButton( + modifier = Modifier + .animateFloatingActionButton( + visible = isFabVisible, + alignment = Alignment.BottomEnd, + ) + .focusRequester(focusRequester), + checked = fabMenuExpanded, + onCheckedChange = { fabMenuExpanded = !fabMenuExpanded }, + ) { + val imageVector by remember { + derivedStateOf { + if (checkedProgress > 0.5f) Icons.Filled.Close else Icons.AutoMirrored.Filled.MenuOpen + } + } + Icon( + imageVector = imageVector, + contentDescription = "Menu", + modifier = Modifier.animateIcon({ checkedProgress }), + ) + } + } + ) { + fabItems.forEach { (icon, label, action) -> + FloatingActionButtonMenuItem( + onClick = { + action() + fabMenuExpanded = false + }, + icon = { Icon(icon, contentDescription = null) }, + text = { Text(text = label) } + ) + } + } + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = isSelectionMode, + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = -ScreenOffset) + .padding(bottom = 16.dp) + .zIndex(1f), + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut() + ) { + SelectionBottomBar( + onSelectAll = { viewModel.selectAll() }, + onSelectInvert = { viewModel.invertSelection() }, + primaryAction = ActionItem( + text = "下载已选 (${state.selectedIds.size})", + icon = { Icon(Icons.Default.Download, null) }, + onClick = { viewModel.downloadSelected() } + ), + secondaryActions = selectionSecondaryActions + ) + } + + Column(modifier = Modifier.padding(padding)) { + HorizontalPager(state = pagerState) { page -> + when (page) { + 0 -> ChapterListContent( + viewModel = viewModel, + listState = listState, + onChapterClick = onChapterClick, + contentPadding = PaddingValues(bottom = if (isSelectionMode) 80.dp else 0.dp) + ) + + 1 -> BookmarkListContent( + viewModel = viewModel, + onBookmarkClick = onBookmarkClick, + onBookmarkLongClick = { bookmark -> + editingBookmark = bookmark + }, + contentPadding = PaddingValues(bottom = if (isSelectionMode) 80.dp else 0.dp) + ) + } + } + } + } + + if (editingBookmark != null) { + BookmarkEditSheet( + bookmark = editingBookmark!!, + onDismiss = { editingBookmark = null }, + onSave = { updatedBookmark -> + viewModel.updateBookmark(updatedBookmark) + editingBookmark = null + }, + onDelete = { bookmarkToDelete -> + viewModel.deleteBookmark(bookmarkToDelete) + editingBookmark = null + } + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChapterListContent( + viewModel: TocViewModel, + listState: LazyListState, + onChapterClick: (Int) -> Unit, + contentPadding: PaddingValues +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val book by viewModel.bookState.collectAsStateWithLifecycle() + val collapsedVolumes by viewModel.collapsedVolumes.collectAsStateWithLifecycle() + + FastScrollLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding + ) { + state.items.forEach { uiItem -> + val chapter = uiItem.chapter + + if (chapter.isVolume) { + stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${chapter.index}") { + CollapsibleHeader( + modifier = Modifier.animateItem(), + title = uiItem.displayTitle, + isCollapsed = collapsedVolumes.contains(chapter.title), + onToggle = { viewModel.toggleVolume(chapter.title) } + ) + } + } else { + item(key = chapter.index) { + + val isSelected = state.selectedIds.contains(chapter.index) + + val downloadState = when { + chapter.index in state.downloadingIndices -> DownloadState.DOWNLOADING + chapter.index in state.errorIndices -> DownloadState.ERROR + chapter.getFileName() in state.cachedFiles -> DownloadState.SUCCESS + else -> DownloadState.NONE + } + + ChapterItem( + modifier = Modifier + .animateItem() + .fillMaxWidth(), + title = uiItem.displayTitle, + chapter = chapter, + isDur = book?.durChapterIndex == chapter.index, + isSelected = isSelected, + downloadState = downloadState, + showWordCount = viewModel.showWordCount, + onClick = { + if (state.selectedIds.isNotEmpty()) + viewModel.toggleSelection(chapter.index) + else + onChapterClick(chapter.index) + }, + onLongClick = { + viewModel.toggleSelection(chapter.index) + } + ) + + /*SwipeActionContainer( + modifier = Modifier + .animateItem() + .fillMaxWidth(), + startAction = SwipeAction( + icon = Icons.Default.Download, + background = MaterialTheme.colorScheme.secondary, + hapticFeedback = true, + onSwipe = { + viewModel.downloadChapter(chapter.index) + } + ) + ) { + ChapterItem( + modifier = Modifier + .animateItem() + .fillMaxWidth(), + title = uiItem.displayTitle, + chapter = chapter, + isDur = book?.durChapterIndex == chapter.index, + isSelected = isSelected, + downloadState = downloadState, + showWordCount = viewModel.showWordCount, + onClick = { + if (state.selectedIds.isNotEmpty()) + viewModel.toggleSelection(chapter.index) + else + onChapterClick(chapter.index) + }, + onLongClick = { + viewModel.toggleSelection(chapter.index) + } + ) + }*/ + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChapterItem( + modifier: Modifier = Modifier, + title: String, + chapter: BookChapter, + isDur: Boolean, + isSelected: Boolean, + downloadState: DownloadState, + showWordCount: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + val backgroundColor by animateColorAsState( + targetValue = when { + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + isDur -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) + else -> Color.Transparent + }, label = "BgColor" + ) + + val textColor by animateColorAsState( + targetValue = when { + isSelected -> MaterialTheme.colorScheme.onSurface + isDur -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, label = "BgColor" + ) + + val detailColor by animateColorAsState( + targetValue = when { + isSelected -> MaterialTheme.colorScheme.onSurfaceVariant + isDur -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, label = "BgColor" + ) + + Surface( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ), + color = backgroundColor + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (chapter.isVip && !chapter.isPay) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(14.dp) + .padding(end = 4.dp) + ) + } + + Text( + text = title, + style = MaterialTheme.typography.bodyMediumEmphasized.copy(fontWeight = FontWeight.Medium), + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (!chapter.tag.isNullOrEmpty()) { + Text( + text = chapter.tag!!, + style = MaterialTheme.typography.labelSmallEmphasized, + color = detailColor.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + if (showWordCount && !chapter.wordCount.isNullOrEmpty()) { + Text( + text = "字数:${chapter.wordCount}", + style = MaterialTheme.typography.labelSmallEmphasized, + color = detailColor.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + StatusIcon(isDur = isDur, downloadState = downloadState) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BookmarkListContent( + viewModel: TocViewModel, + onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit, + onBookmarkLongClick: (Bookmark) -> Unit, + contentPadding: PaddingValues +) { + val bookmarks by viewModel.bookmarks.collectAsStateWithLifecycle() + val book by viewModel.bookState.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + LaunchedEffect(bookmarks, book?.durChapterIndex) { + if (bookmarks.isNotEmpty() && book != null) { + val durIndex = book!!.durChapterIndex + var scrollPos = 0 + for ((index, bookmark) in bookmarks.withIndex()) { + if (bookmark.chapterIndex >= durIndex) break + scrollPos = index + } + listState.scrollToItem(scrollPos) + } + } + + if (bookmarks.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "暂无书签", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding + ) { + items( + items = bookmarks, + key = { it.time } + ) { bookmark -> + BookmarkItem( + bookmark = bookmark, + isDur = book?.durChapterIndex == bookmark.chapterIndex, + onClick = { + onBookmarkClick(bookmark.chapterIndex, bookmark.chapterPos) + }, + onLongClick = { + onBookmarkLongClick(bookmark) + } + ) + } + } + } +} + +@Composable +private fun StatusIcon( + isDur: Boolean, + downloadState: DownloadState +) { + val targetState = when { + isDur -> "DUR" + downloadState == DownloadState.DOWNLOADING -> "LOADING" + downloadState == DownloadState.SUCCESS -> "SUCCESS" + downloadState == DownloadState.ERROR -> "ERROR" + else -> "NONE" + } + + AnimatedContent( + targetState = targetState, + transitionSpec = { + (fadeIn(tween(200)) + scaleIn(initialScale = 0.8f)) togetherWith + (fadeOut(tween(150)) + scaleOut(targetScale = 0.8f)) + }, + label = "StatusIconAnim" + ) { state -> + + when (state) { + + "DUR" -> { + Icon( + imageVector = Icons.Rounded.LocationOn, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.secondary + ) + } + + "LOADING" -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.secondary + ) + } + + "SUCCESS" -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.secondary + ) + } + + "ERROR" -> { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.error + ) + } + + else -> { + + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt index 3e43e6217..fd5671e93 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -1,148 +1,475 @@ package io.legado.app.ui.book.toc - import android.app.Application import android.net.Uri -import androidx.lifecycle.MutableLiveData -import io.legado.app.R -import io.legado.app.base.BaseViewModel -import io.legado.app.constant.AppLog +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.viewModelScope +import io.legado.app.base.BaseRuleViewModel import io.legado.app.data.appDb import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookSource -import io.legado.app.exception.NoStackTraceException +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.Bookmark +import io.legado.app.help.book.BookHelp import io.legado.app.help.book.ContentProcessor +import io.legado.app.help.bookmark.BookmarkExporter +import io.legado.app.model.CacheBook import io.legado.app.model.ReadBook import io.legado.app.model.localBook.LocalBook -import io.legado.app.utils.FileDoc -import io.legado.app.utils.GSON -import io.legado.app.utils.createFileIfNotExist -import io.legado.app.utils.openOutputStream +import io.legado.app.ui.config.readConfig.ReadConfig +import io.legado.app.ui.widget.components.importComponents.BaseImportUiState +import io.legado.app.ui.widget.components.rules.RuleActionState +import io.legado.app.ui.widget.components.rules.SelectableItem import io.legado.app.utils.toastOnUi -import io.legado.app.utils.writeText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch -class TocViewModel(application: Application) : BaseViewModel(application) { - var bookUrl: String = "" - var bookSource: BookSource? = null - var bookData = MutableLiveData() - var chapterListCallBack: ChapterListCallBack? = null - var bookMarkCallBack: BookmarkCallBack? = null - var searchKey: String? = null - fun replaceRuleChanged() { - execute { - ReadBook.book?.let { - ContentProcessor.get(it.name, it.origin).upReplaceRules() - ReadBook.loadContent(resetPageOffset = false) +data class TocUiItem( + val chapter: BookChapter, + val displayTitle: String, + override val id: Int = chapter.index +) : SelectableItem + +data class TocActionState( + override val items: List = emptyList(), + override val selectedIds: Set = emptySet(), + override val searchKey: String = "", + override val isSearch: Boolean = false, + override val isUploading: Boolean = false, + val downloadingIndices: Set = emptySet(), + val errorIndices: Set = emptySet(), + val cachedFiles: Set = emptySet(), + val downloadSummary: String = "" +) : RuleActionState + +@OptIn(ExperimentalCoroutinesApi::class) +class TocViewModel(application: Application) : + BaseRuleViewModel, Int, TocActionState>( + application, + initialState = TocActionState() + ) { + + private val _book = MutableStateFlow(null) + val bookState = _book.asStateFlow() + val isSplitLongChapter: Boolean + get() = _book.value?.getSplitLongChapter() ?: false + + private val _collapsedVolumes = MutableStateFlow>(emptySet()) + val collapsedVolumes = _collapsedVolumes.asStateFlow() + + private val _cacheFileNames = MutableStateFlow>(emptySet()) + val cacheFileNames = _cacheFileNames.asStateFlow() + + private val _isReverse = MutableStateFlow(false) + + private val _downloadingIndices = MutableStateFlow>(emptySet()) + private val _downloadSummary = MutableStateFlow("") + + private val _errorIndices = MutableStateFlow>(emptySet()) + val errorIndices = _errorIndices.asStateFlow() + + private val _bookmarks = MutableStateFlow>(emptyList()) + val bookmarks = _bookmarks.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + private val dbChapterListFlow = _book + .filterNotNull() + .map { it.bookUrl } + .distinctUntilChanged() + .flatMapLatest { url -> + appDb.bookChapterDao.getChapterListFlow(url) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val processedChaptersFlow: Flow>> = combine( + dbChapterListFlow, + _isReverse, + _book.filterNotNull(), + snapshotFlow { ReadConfig.tocUiUseReplace }, + snapshotFlow { ReadConfig.tocCountWords } + ) { originalList, isReverse, book, useReplace, showWordCount -> + + val processedList = if (isReverse) { + originalList.fold(mutableListOf>()) { acc, chapter -> + if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter)) + else acc.last().add(chapter) + acc + }.asReversed().flatMap { group -> + if (group.firstOrNull()?.isVolume == true) { + listOf(group.first()) + group.drop(1).asReversed() + } else group.asReversed() + } + } else { + originalList + } + + val replaceRules = if (useReplace && book.getUseReplaceRule()) { + ContentProcessor.get(book.name, book.origin).getTitleReplaceRules() + } else emptyList() + + processedList.map { chapter -> + chapter to chapter.getDisplayTitle(replaceRules, true) + } + } + .flowOn(Dispatchers.IO) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 1) + + private val downloadStateTrigger = + combine( + _downloadingIndices, + _errorIndices, + _cacheFileNames, + _downloadSummary + ) { _, _, _, _ -> Unit } + + override val rawDataFlow = + combine(processedChaptersFlow, _collapsedVolumes, downloadStateTrigger) { list, _, _ -> + list + } + + val useReplace get() = ReadConfig.tocUiUseReplace + val showWordCount get() = ReadConfig.tocCountWords + + fun toggleUseReplace() { + ReadConfig.tocUiUseReplace = !ReadConfig.tocUiUseReplace + } + + fun toggleShowWordCount() { + ReadConfig.tocCountWords = !ReadConfig.tocCountWords + } + + override fun filterData( + data: List>, + key: String + ): List> { + val collapsed = _collapsedVolumes.value + val isSearch = key.isNotBlank() + + return buildList { + var isCurrentVolumeCollapsed = false + + for (item in data) { + val (chapter, displayTitle) = item + + if (chapter.isVolume) { + isCurrentVolumeCollapsed = collapsed.contains(chapter.title) + } else if (isCurrentVolumeCollapsed && !isSearch) { + continue + } + + if (!isSearch || displayTitle.contains( + key, + ignoreCase = true + ) || chapter.isVolume + ) { + add(item) + } } } } - fun initBook(bookUrl: String) { - this.bookUrl = bookUrl - execute { - appDb.bookDao.getBook(bookUrl)?.let { - bookSource = appDb.bookSourceDao.getBookSource(it.origin) - bookData.postValue(it) + override fun composeUiState( + items: List, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState> + ): TocActionState { + + return TocActionState( + items = items, + selectedIds = selectedIds, + searchKey = _searchKey.value, + isSearch = isSearch, + isUploading = isUploading, + downloadingIndices = _downloadingIndices.value, + errorIndices = _errorIndices.value, + cachedFiles = _cacheFileNames.value, + downloadSummary = _downloadSummary.value + ) + } + + override fun Pair.toUiItem() = TocUiItem(first, second) + + override fun ruleItemToEntity(item: TocUiItem) = item.chapter to item.displayTitle + + override suspend fun generateJson(entities: List>) = "" + override fun parseImportRules(text: String): List> = emptyList() + override fun hasChanged( + newRule: Pair, + oldRule: Pair + ) = false + + override suspend fun findOldRule(newRule: Pair) = null + override fun saveImportedRules() {} + + init { + + viewModelScope.launch { + combine( + _book.filterNotNull(), + snapshotFlow { uiState.value.searchKey } + ) { book, searchKey -> + if (searchKey.isBlank()) { + appDb.bookmarkDao.flowByBook(book.name, book.author) + } else { + appDb.bookmarkDao.flowSearch(book.name, book.author, searchKey) + } + } + .flatMapLatest { it } + .catch { e -> + // AppLog.put("目录界面获取书签数据失败\n${e.localizedMessage}", e) + } + .flowOn(Dispatchers.IO) + .collect { list -> + _bookmarks.value = list + } + } + + viewModelScope.launch { + + launch { + CacheBook.downloadingIndicesFlow.collect { (url, set) -> + if (url == _book.value?.bookUrl) { + _downloadingIndices.value = set + } + } + } + + launch { + CacheBook.downloadErrorFlow.collect { (url, set) -> + if (url == _book.value?.bookUrl) { + _errorIndices.value = set + } + } + } + + launch { + CacheBook.downloadSummaryFlow.collect { + _downloadSummary.value = it + } + } + + launch { + CacheBook.cacheSuccessFlow.collect { chapter -> + if (chapter.bookUrl == _book.value?.bookUrl) { + _cacheFileNames.update { + it + chapter.getFileName() + } + } + } } } } - fun upBookTocRule(book: Book, complete: (Throwable?) -> Unit) { + fun initBook(bookUrl: String) = execute { + appDb.bookDao.getBook(bookUrl)?.let { book -> + _book.value = book + _isReverse.value = book.getReverseToc() + + viewModelScope.launch(Dispatchers.IO) { + val files = BookHelp.getChapterFiles(book).toSet() + _cacheFileNames.value = files + } + } + } + + fun saveTocRegex(newRegex: String) { + val book = _book.value ?: return + book.tocUrl = newRegex + upBookTocRule(book) { error -> + if (error != null) { + context.toastOnUi("更新目录规则失败: ${error.localizedMessage}") + } else { + context.toastOnUi("目录规则已更新") + if (ReadBook.book?.bookUrl == book.bookUrl) { + ReadBook.upMsg(null) + } + } + } + } + + fun toggleSplitLongChapter() { + val book = _book.value ?: return + val newState = !isSplitLongChapter + book.setSplitLongChapter(newState) + + upBookTocRule(book) { error -> + if (error != null) { + context.toastOnUi("设置失败: ${error.localizedMessage}") + } else { + context.toastOnUi(if (newState) "已开启长章节拆分" else "已关闭长章节拆分") + } + } + } + + private fun upBookTocRule(book: Book, complete: (Throwable?) -> Unit) { + _isUploading.value = true execute { appDb.bookDao.update(book) - LocalBook.getChapterList(book).let { + LocalBook.getChapterList(book).let { chapters -> appDb.bookChapterDao.delByBook(book.bookUrl) - appDb.bookChapterDao.insert(*it.toTypedArray()) + appDb.bookChapterDao.insert(*chapters.toTypedArray()) appDb.bookDao.update(book) ReadBook.onChapterListUpdated(book) - bookData.postValue(book) + + _book.value = book } }.onSuccess { + _isUploading.value = false complete.invoke(null) }.onError { + _isUploading.value = false complete.invoke(it) } } - fun reverseToc(success: (book: Book) -> Unit) { - execute { - bookData.value?.apply { - setReverseToc(!getReverseToc()) - val toc = appDb.bookChapterDao.getChapterList(bookUrl) - val newToc = toc.reversed() - newToc.forEachIndexed { index, bookChapter -> - bookChapter.index = index - } - appDb.bookChapterDao.insert(*newToc.toTypedArray()) - } - }.onSuccess { - it?.let(success) + fun reverseToc() = execute { + val currentBook = _book.value ?: return@execute + + val newReverseState = !_isReverse.value + _isReverse.value = newReverseState + + val newBook = currentBook.copy().apply { + setReverseToc(newReverseState) + } + appDb.bookDao.update(newBook) + _book.value = newBook + } + + fun toggleVolume(volumeName: String) { + _collapsedVolumes.update { current -> + if (current.contains(volumeName)) current - volumeName else current + volumeName } } - fun startChapterListSearch(newText: String?) { - chapterListCallBack?.upChapterList(newText) + fun expandAllVolumes() { + _collapsedVolumes.value = emptySet() } - fun startBookmarkSearch(newText: String?) { - bookMarkCallBack?.upBookmark(newText) + fun collapseAllVolumes() = execute { + val bookUrl = _book.value?.bookUrl ?: return@execute + val volumes = appDb.bookChapterDao.getChapterList(bookUrl) + .filter { it.isVolume } + .map { it.title } + .toSet() + _collapsedVolumes.value = volumes } - fun upChapterListAdapter() { - chapterListCallBack?.upAdapter() + fun selectAll() { + setSelection(uiState.value.items.map { it.id }.toSet()) } - fun saveBookmark(treeUri: Uri) { - execute { - val book = bookData.value - ?: throw NoStackTraceException(context.getString(R.string.no_book)) - val fileName = "bookmark-${book.name} ${book.author}.json" - val doc = FileDoc.fromUri(treeUri, true) - doc.createFileIfNotExist(fileName).writeText( - GSON.toJson( - appDb.bookmarkDao.getByBook(book.name, book.author) + fun invertSelection() { + val allIds = uiState.value.items.map { it.id }.toSet() + setSelection(allIds - _selectedIds.value) + } + + fun clearSelection() { + setSelection(emptySet()) + } + + fun exportCurrentBookBookmarks(fileUri: Uri, isMd: Boolean) { + viewModelScope.launch { + try { + val book = _book.value ?: return@launch + val bookmarks = appDb.bookmarkDao.getByBook(book.name, book.author) + + if (bookmarks.isEmpty()) { + context.toastOnUi("没有可导出的书签") + return@launch + } + + BookmarkExporter.exportToUri( + context = getApplication(), + fileUri = fileUri, + bookmarks = bookmarks, + isMd = isMd, + bookName = book.name, + author = book.author ) - ) - }.onError { - AppLog.put("导出失败\n${it.localizedMessage}", it, true) - }.onSuccess { - context.toastOnUi("导出成功") - } - } - fun saveBookmarkMd(treeUri: Uri) { - execute { - val book = bookData.value - ?: throw NoStackTraceException(context.getString(R.string.no_book)) - val fileName = "bookmark-${book.name} ${book.author}.md" - val treeDoc = FileDoc.fromUri(treeUri, true) - val fileDoc = treeDoc.createFileIfNotExist(fileName) - .openOutputStream() - .getOrThrow() - fileDoc.use { outputStream -> - outputStream.write("## ${book.name} ${book.author}\n\n".toByteArray()) - appDb.bookmarkDao.getByBook(book.name, book.author).forEach { - outputStream.write("#### ${it.chapterName}\n\n".toByteArray()) - outputStream.write("###### 原文\n ${it.bookText}\n\n".toByteArray()) - outputStream.write("###### 摘要\n ${it.content}\n\n".toByteArray()) - } + context.toastOnUi("保存成功") + } catch (e: Exception) { + context.toastOnUi("保存失败: ${e.message}") } - }.onError { - AppLog.put("导出失败\n${it.localizedMessage}", it, true) - }.onSuccess { - context.toastOnUi("导出成功") } } - interface ChapterListCallBack { - fun upChapterList(searchKey: String?) - - fun clearDisplayTitle() - - fun upAdapter() + fun updateBookmark(bookmark: Bookmark) { + viewModelScope.launch(Dispatchers.IO) { + appDb.bookmarkDao.insert(bookmark) + } } - interface BookmarkCallBack { - fun upBookmark(searchKey: String?) + fun deleteBookmark(bookmark: Bookmark) { + viewModelScope.launch(Dispatchers.IO) { + appDb.bookmarkDao.delete(bookmark) + } + } + + /** + * 下载选中的章节 + */ + fun downloadSelected() { + val book = _book.value ?: return + val indices = uiState.value.selectedIds.toList() + if (indices.isEmpty()) return + CacheBook.start(getApplication(), book, indices) + getApplication().toastOnUi("开始下载 ${indices.size} 个章节") + clearSelection() + } + + /** + * 下载单个章节 + */ + fun downloadChapter(index: Int) { + val book = _book.value ?: return + CacheBook.start(getApplication(), book, listOf(index)) + getApplication().toastOnUi("开始下载章节") + } + + /** + * 下载所有章节 + */ + fun downloadAll() { + val book = _book.value ?: return + val cachedFiles = _cacheFileNames.value + + val targetIndices = uiState.value.items + .filter { !it.chapter.isVolume && it.chapter.getFileName() !in cachedFiles } + .map { it.id } + + if (targetIndices.isEmpty()) { + getApplication().toastOnUi("所有章节已缓存") + return + } + + CacheBook.start(getApplication(), book, targetIndices) + getApplication().toastOnUi("开始下载剩余 ${targetIndices.size} 个章节") + } + + fun selectFromLast() { + val currentItems = uiState.value.items + val maxSelectedId = _selectedIds.value.maxOrNull() ?: return + + val maxIndex = currentItems.indexOfFirst { it.id == maxSelectedId } + if (maxIndex == -1) return + + val idsToAppend = currentItems.drop(maxIndex + 1).map { it.id } + setSelection(_selectedIds.value + idsToAppend) } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt index 3a52ddd21..266646237 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt @@ -66,6 +66,8 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun TxtRuleScreen( viewModel: TxtTocRuleViewModel = koinViewModel(), + initialRule: String? = null, + onPickRule: ((String) -> Unit)? = null, onBackClick: () -> Unit ) { @@ -74,7 +76,8 @@ fun TxtRuleScreen( val rules = uiState.items val selectedIds = uiState.selectedIds - val inSelectionMode = selectedIds.isNotEmpty() + val isPickMode = onPickRule != null + val inSelectionMode = if (isPickMode) false else selectedIds.isNotEmpty() val listState = rememberLazyListState() val hapticFeedback = LocalHapticFeedback.current @@ -253,7 +256,7 @@ fun TxtRuleScreen( } RuleListScaffold( - title = "目录规则", + title = if (isPickMode) "选择目录规则" else "目录规则", state = uiState, onBackClick = { onBackClick() }, onSearchToggle = { active -> @@ -316,15 +319,29 @@ fun TxtRuleScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(rules, key = { it.id }) { item -> + + val isItemHighLighted = if (isPickMode) { + item.rule.rule == initialRule + } else { + selectedIds.contains(item.id) + } + ReorderableSelectionItem( state = reorderableState, key = item.id, title = item.name, subtitle = item.example, isEnabled = item.isEnabled, - isSelected = selectedIds.contains(item.id), + isSelected = isItemHighLighted, inSelectionMode = inSelectionMode, - onToggleSelection = { viewModel.toggleSelection(item.id) }, + onToggleSelection = { + if (isPickMode) { + onPickRule.invoke(item.rule.rule) + onBackClick() + } else { + viewModel.toggleSelection(item.id) + } + }, onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enable = enabled)) }, onClickEdit = { editingRule = item.rule; showEditSheet = true }, trailingAction = { diff --git a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt index a5cc74383..835e4489c 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt @@ -1,6 +1,7 @@ package io.legado.app.ui.book.toc.rule //import io.legado.app.lib.theme.primaryColor +import android.content.Intent import androidx.compose.runtime.Composable import io.legado.app.base.BaseComposeActivity import io.legado.app.ui.theme.AppTheme @@ -9,8 +10,20 @@ class TxtTocRuleActivity : BaseComposeActivity() { @Composable override fun Content() { + val initialRule = intent.getStringExtra("tocRegex") + AppTheme { - TxtRuleScreen(onBackClick = { finish() }) + TxtRuleScreen( + initialRule = initialRule, + onPickRule = { rule -> + val data = Intent().apply { + putExtra("tocRegex", rule) + } + setResult(RESULT_OK, data) + finish() + }, + onBackClick = { finish() } + ) } } diff --git a/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt new file mode 100644 index 000000000..42dfd0868 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt @@ -0,0 +1,18 @@ +package io.legado.app.ui.config.readConfig + +import io.legado.app.constant.PreferKey +import io.legado.app.ui.config.prefDelegate + +object ReadConfig { + + var tocUiUseReplace by prefDelegate( + PreferKey.tocUiUseReplace, + false + ) + + var tocCountWords by prefDelegate( + PreferKey.tocCountWords, + true + ) + +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 4218fce29..13335ba26 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -328,7 +328,7 @@ fun ReplaceRuleScreen( viewModel.delSelectionByIds(ids as Set) viewModel.setSelection(emptySet()) }, - stickySubContent = { + bottomContent = { if (tabItems.size > 1) { PrimaryScrollableTabRow( selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1) diff --git a/app/src/main/java/io/legado/app/ui/widget/CollapsibleHeader.kt b/app/src/main/java/io/legado/app/ui/widget/CollapsibleHeader.kt new file mode 100644 index 000000000..292fe1b7f --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/CollapsibleHeader.kt @@ -0,0 +1,92 @@ +package io.legado.app.ui.widget + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CollapsibleHeader( + modifier: Modifier = Modifier, + isCollapsed: Boolean, + onToggle: () -> Unit, + title: String, + subtitle: String? = null, + titleContent: (@Composable ColumnScope.() -> Unit)? = null +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 4.dp, end = 16.dp), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + onClick = onToggle + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + if (titleContent != null) { + titleContent() + } else { + Text( + text = title, + style = MaterialTheme.typography.bodySmallEmphasized.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmallEmphasized.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + val rotation by animateFloatAsState( + targetValue = if (isCollapsed) 0f else 180f, + label = "arrowRotation" + ) + + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = if (isCollapsed) "展开" else "折叠", + modifier = Modifier.rotate(rotation), + tint = MaterialTheme.colorScheme.primary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AnimatedText.kt b/app/src/main/java/io/legado/app/ui/widget/components/AnimatedText.kt index 968434933..1a71821d2 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AnimatedText.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/AnimatedText.kt @@ -1,6 +1,8 @@ package io.legado.app.ui.widget.components import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith @@ -9,11 +11,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -112,28 +109,72 @@ fun AnimatedTextLine( onTextLayout: ((TextLayoutResult) -> Unit)? = null, style: TextStyle = LocalTextStyle.current ) { - var currentText by remember { mutableStateOf(text) } - SideEffect { currentText = text } + AnimatedContent( + targetState = text, + transitionSpec = { + (slideInVertically { it }).togetherWith(slideOutVertically { -it }) + }, + label = "LineAnimation", + modifier = modifier + ) { targetText -> + Text( + text = targetText, + modifier = modifier, + style = style, + color = color, + softWrap = softWrap, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout + ) + } +} - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - AnimatedContent( - targetState = currentText, - transitionSpec = { - (slideInVertically(initialOffsetY = { it })).togetherWith( - slideOutVertically(targetOffsetY = { -it }) - ) - }, - label = "" - ) { text -> - Text( + +@Composable +fun AdaptiveAnimatedText( + text: String, + useCharMode: Boolean, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + onTextLayout: ((TextLayoutResult) -> Unit)? = null, + style: TextStyle = LocalTextStyle.current +) { + AnimatedContent( + targetState = useCharMode, + transitionSpec = { + (slideInVertically { it } + fadeIn()).togetherWith( + slideOutVertically { -it } + fadeOut() + ) + }, + label = "ModeSwitchAnimation", + modifier = modifier + ) { currentMode -> + if (currentMode) { + AnimatedText( text = text, - modifier = modifier, - style = style, color = color, - softWrap = softWrap, fontSize = fontSize, fontStyle = fontStyle, fontWeight = fontWeight, @@ -143,9 +184,30 @@ fun AnimatedTextLine( textAlign = textAlign, lineHeight = lineHeight, overflow = overflow, + softWrap = softWrap, maxLines = maxLines, minLines = minLines, - onTextLayout = onTextLayout + onTextLayout = onTextLayout, + style = style + ) + } else { + AnimatedTextLine( + text = text, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + minLines = minLines, + onTextLayout = onTextLayout, + style = style ) } } diff --git a/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt new file mode 100644 index 000000000..b62892247 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkEditSheet.kt @@ -0,0 +1,137 @@ +package io.legado.app.ui.widget.components.bookmark + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.Bookmark +import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookmarkEditSheet( + bookmark: Bookmark, + onDismiss: () -> Unit, + onSave: (Bookmark) -> Unit, + onDelete: (Bookmark) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var showDeleteConfirmDialog by remember { mutableStateOf(false) } + var bookText by remember { mutableStateOf(bookmark.bookText) } + var content by remember { mutableStateOf(bookmark.content) } + + GlassModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .navigationBarsPadding() + ) { + Text( + text = bookmark.chapterName, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = bookText, + onValueChange = { bookText = it }, + label = { Text("原文") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 10 + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = content, + onValueChange = { content = it }, + label = { Text("摘要/笔记") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 5 + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { showDeleteConfirmDialog = true }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.weight(1f) + ) { + Text("删除") + } + + Button( + onClick = { + val newBookmark = bookmark.apply { + this.bookText = bookText + this.content = content + } + onSave(newBookmark) + }, + modifier = Modifier.weight(1f) + ) { + Text("保存") + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + + if (showDeleteConfirmDialog) { + AlertDialog( + onDismissRequest = { showDeleteConfirmDialog = false }, + title = { Text("确认删除") }, + text = { Text("你确定要删除这条书签吗?") }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirmDialog = false + onDelete(bookmark) + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text("删除") + } + }, + dismissButton = { + TextButton( + onClick = { showDeleteConfirmDialog = false } + ) { + Text("取消") + } + } + ) + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkItem.kt new file mode 100644 index 000000000..aeb9f423a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/bookmark/BookmarkItem.kt @@ -0,0 +1,79 @@ +package io.legado.app.ui.widget.components.bookmark + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.data.entities.Bookmark + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BookmarkItem( + modifier: Modifier = Modifier, + bookmark: Bookmark, + isDur: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + val backgroundColor by animateColorAsState( + targetValue = if (isDur) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f) + else Color.Transparent, + label = "BgColor" + ) + + Surface( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ), + color = backgroundColor + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Text( + text = bookmark.chapterName, + style = MaterialTheme.typography.titleSmallEmphasized, + color = if (isDur) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary + ) + + if (bookmark.bookText.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = bookmark.bookText, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (bookmark.content.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = bookmark.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt index 21e9f5c79..d50a487ed 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt @@ -5,9 +5,12 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.IconToggleButtonShapes import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.OutlinedIconToggleButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp @@ -25,7 +28,7 @@ fun SmallIconButton( onClick = onClick, modifier = Modifier.size( IconButtonDefaults.extraSmallContainerSize( - IconButtonDefaults.IconButtonWidthOption.Narrow + IconButtonDefaults.IconButtonWidthOption.Uniform ) ), shape = IconButtonDefaults.extraSmallRoundShape, @@ -37,4 +40,45 @@ fun SmallIconButton( ) } } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SmallOutlinedIconToggleButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + icon: ImageVector, + modifier: Modifier = Modifier, + contentDescription: String? = null +) { + val defaultShape = IconButtonDefaults.extraSmallRoundShape + val pressedShape = IconButtonDefaults.extraSmallPressedShape + val checkedShape = IconButtonDefaults.extraSmallSelectedRoundShape + + val toggleShapes = remember(defaultShape, checkedShape) { + IconToggleButtonShapes( + shape = defaultShape, + pressedShape = pressedShape, + checkedShape = checkedShape + ) + } + + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + OutlinedIconToggleButton( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier.size( + IconButtonDefaults.extraSmallContainerSize( + IconButtonDefaults.IconButtonWidthOption.Uniform + ) + ), + shapes = toggleShapes + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(IconButtonDefaults.extraSmallIconSize), + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/divider/PillDivider.kt b/app/src/main/java/io/legado/app/ui/widget/components/divider/PillDivider.kt new file mode 100644 index 000000000..f97428813 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/divider/PillDivider.kt @@ -0,0 +1,39 @@ +package io.legado.app.ui.widget.components.divider + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +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.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun PillDivider( + modifier: Modifier = Modifier, + thickness: Dp = 2.dp, + widthFraction: Float = 0.2f, + color: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f) +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxWidth(widthFraction) + .height(thickness) + .clip(CircleShape) + .background(color) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/divider/PillHeaderDivider.kt b/app/src/main/java/io/legado/app/ui/widget/components/divider/PillHeaderDivider.kt new file mode 100644 index 000000000..f44574dc0 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/divider/PillHeaderDivider.kt @@ -0,0 +1,58 @@ +package io.legado.app.ui.widget.components.divider + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun PillHeaderDivider( + title: String, + modifier: Modifier = Modifier, + dotSize: Dp = 2.dp, + dotColor: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f), + titleColor: Color = MaterialTheme.colorScheme.primary +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(dotSize) + .clip(CircleShape) + .background(dotColor) + ) + + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + color = titleColor, + modifier = Modifier.padding(horizontal = 12.dp), + maxLines = 1 + ) + + Box( + modifier = Modifier + .size(dotSize) + .clip(CircleShape) + .background(dotColor) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt new file mode 100644 index 000000000..d37c966b8 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt @@ -0,0 +1,29 @@ +package io.legado.app.ui.widget.components.menuItem + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +fun RoundDropdownMenuItem( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit, + trailingIcon: @Composable (() -> Unit)? = null, +) { + DropdownMenuItem( + modifier = modifier + .padding(horizontal = 8.dp) + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.surface), + text = { Text(text) }, + trailingIcon = trailingIcon, + onClick = onClick, + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt index b1289ae71..0285b869e 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope @@ -14,21 +13,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.PlainTooltip @@ -57,11 +51,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import io.legado.app.R import io.legado.app.ui.widget.components.ActionItem -import io.legado.app.ui.widget.components.AnimatedText -import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar -import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SelectionBottomBar -import io.legado.app.ui.widget.components.button.SmallTopBarButton +import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -76,7 +67,7 @@ fun RuleListScaffold( searchTrailingIcon: @Composable (() -> Unit)? = null, searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null, topBarActions: @Composable RowScope.() -> Unit = {}, - stickySubContent: @Composable (ColumnScope.() -> Unit)? = null, + bottomContent: @Composable (ColumnScope.() -> Unit)? = null, dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {}, onClearSelection: () -> Unit, onSelectAll: () -> Unit, @@ -142,67 +133,19 @@ fun RuleListScaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - Column { - GlassMediumFlexibleTopAppBar( - title = { - val titleText = when { - state.isUploading -> "请稍后..." - state.selectedIds.isNotEmpty() -> "已选择 ${state.selectedIds.size}/${state.items.size}" - else -> title - } - AnimatedText(text = titleText) - }, - navigationIcon = { - SmallTopBarButton( - onClick = { - if (state.selectedIds.isNotEmpty()) onClearSelection() else onBackClick() - }, - imageVector = if (state.selectedIds.isNotEmpty()) { - Icons.Default.Close - } else { - Icons.AutoMirrored.Filled.ArrowBack - }, - contentDescription = if (state.selectedIds.isNotEmpty()) "取消选择" else "返回" - ) - }, - actions = { - if (!state.selectedIds.isNotEmpty()) { - IconButton(onClick = { onSearchToggle(!state.isSearch) }) { - Icon(Icons.Default.Search, null) - } - topBarActions() - Box { - IconButton(onClick = { showMenu = true }) { - Icon(Icons.Default.MoreVert, null) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }) { - dropDownMenuContent { showMenu = false } - } - } - } - }, - scrollBehavior = scrollBehavior - ) - - AnimatedVisibility(visible = state.isSearch && !state.selectedIds.isNotEmpty()) { - SearchBarSection( - query = state.searchKey, - onQueryChange = onSearchQueryChange, - placeholder = searchPlaceholder, - leadingIcon = { Icon(searchLeadingIcon, null) }, - trailingIcon = searchTrailingIcon, - dropdownMenu = searchDropdownMenu - ) - } - - AnimatedVisibility( - visible = stickySubContent != null - ) { - stickySubContent?.let { it() } - } - } + DynamicTopAppBar( + title = title, + state = state, + scrollBehavior = scrollBehavior, + onBackClick = onBackClick, + onSearchToggle = onSearchToggle, + onSearchQueryChange = onSearchQueryChange, + searchPlaceholder = searchPlaceholder, + onClearSelection = onClearSelection, + topBarActions = topBarActions, + dropDownMenuContent = dropDownMenuContent, + bottomContent = bottomContent + ) }, floatingActionButton = floatingActionButton, content = { paddingValues -> diff --git a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt index 477f90601..c7934267b 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt @@ -36,7 +36,7 @@ fun SwipeActionContainer( val haptic = LocalHapticFeedback.current val dismissState = rememberSwipeToDismissBoxState( positionalThreshold = { totalDistance -> - totalDistance * 0.7f + totalDistance * 0.6f } ) val isThresholdReached = diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt new file mode 100644 index 000000000..6ea9aba7e --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt @@ -0,0 +1,136 @@ +package io.legado.app.ui.widget.components.topbar + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.legado.app.ui.widget.components.AdaptiveAnimatedText +import io.legado.app.ui.widget.components.AnimatedTextLine +import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.SearchBarSection +import io.legado.app.ui.widget.components.button.SmallTopBarButton +import io.legado.app.ui.widget.components.rules.RuleActionState + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DynamicTopAppBar( + title: String, + subtitle: String? = null, + state: RuleActionState, + scrollBehavior: TopAppBarScrollBehavior, + onBackClick: () -> Unit, + onSearchToggle: (Boolean) -> Unit, + onSearchQueryChange: (String) -> Unit, + searchPlaceholder: String, + searchLeadingIcon: ImageVector = Icons.Default.Search, + searchTrailingIcon: @Composable (() -> Unit)? = null, + searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null, + onClearSelection: () -> Unit, + topBarActions: @Composable RowScope.() -> Unit = {}, + dropDownMenuContent: @Composable (ColumnScope.(dismiss: () -> Unit) -> Unit)? = null, + bottomContent: @Composable (ColumnScope.() -> Unit)? = null +) { + var showMenu by remember { mutableStateOf(false) } + val isSelecting = state.selectedIds.isNotEmpty() + + Column { + GlassMediumFlexibleTopAppBar( + title = { + val titleText = when { + state.isUploading -> "请稍后..." + isSelecting -> "已选择 ${state.selectedIds.size}/${state.items.size}" + else -> title + } + AdaptiveAnimatedText( + text = titleText, + useCharMode = isSelecting || state.isUploading, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + subtitle = subtitle?.let { + { AnimatedTextLine(text = it) } + }, + navigationIcon = { + SmallTopBarButton( + onClick = { if (isSelecting) onClearSelection() else onBackClick() }, + imageVector = if (isSelecting) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = if (isSelecting) "取消选择" else "返回" + ) + }, + actions = { + if (!isSelecting) { + IconButton(onClick = { onSearchToggle(!state.isSearch) }) { + Icon(Icons.Default.Search, null) + } + + topBarActions() + + dropDownMenuContent?.let { content -> + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, null) + } + DropdownMenu( + expanded = showMenu, + shape = MaterialTheme.shapes.medium, + shadowElevation = 4.dp, + onDismissRequest = { showMenu = false } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + content { showMenu = false } + } + } + } + } + } + }, + scrollBehavior = scrollBehavior + ) + + AnimatedVisibility( + visible = state.isSearch && !isSelecting, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + SearchBarSection( + query = state.searchKey, + onQueryChange = onSearchQueryChange, + placeholder = searchPlaceholder, + leadingIcon = { Icon(searchLeadingIcon, null) }, + trailingIcon = searchTrailingIcon, + dropdownMenu = searchDropdownMenu + ) + } + + bottomContent?.invoke(this) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBus.kt b/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBus.kt index bb79808f8..8ffaf0781 100644 --- a/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBus.kt +++ b/app/src/main/java/io/legado/app/utils/eventBus/FlowEventBus.kt @@ -1,31 +1,33 @@ package io.legado.app.utils.eventBus -import kotlinx.coroutines.MainScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap object FlowEventBus { - // 核心存储结构:Tag -> SharedFlow + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val events = ConcurrentHashMap>() - // 获取或创建对应 Tag 的 Flow @Suppress("UNCHECKED_CAST") fun with(tag: String): MutableSharedFlow { return events.getOrPut(tag) { - MutableSharedFlow( - replay = 1, // 相当于 LiveData 的粘性,确保新订阅者能收到最后一次通知 + MutableSharedFlow( + replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST ) } as MutableSharedFlow } - // 快捷发送 fun post(tag: String, value: Any) { - MainScope().launch { + scope.launch { with(tag).emit(value) } } -} \ No newline at end of file +} diff --git a/app/src/main/res/layout/activity_chapter_list.xml b/app/src/main/res/layout/activity_chapter_list.xml index 615f49c38..9be4fc705 100644 --- a/app/src/main/res/layout/activity_chapter_list.xml +++ b/app/src/main/res/layout/activity_chapter_list.xml @@ -28,6 +28,11 @@ android:layout_marginStart="16dp" android:layout_marginEnd="12dp"> +