[新年快乐] 修复了一些问题,重写了目录页面(可能有bug)

This commit is contained in:
HapeLee
2026-02-18 01:36:44 +08:00
parent 0b9ce9ddef
commit 96ac87b4f8
32 changed files with 2300 additions and 1864 deletions
@@ -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<BookChapter>
@Query("select * from chapters where bookUrl = :bookUrl order by `index`")
fun getChapterListFlow(bookUrl: String): Flow<List<BookChapter>>
@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<BookChapter>
@@ -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
@@ -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(
@@ -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<Bookmark>): 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<Bookmark>,
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)
}
}
}
}
@@ -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<BookChapter>(extraBufferCapacity = 64)
val cacheSuccessFlow = _cacheSuccessFlow.asSharedFlow()
private val _downloadSummaryFlow = MutableStateFlow("")
val downloadSummaryFlow = _downloadSummaryFlow.asStateFlow()
private val _downloadingIndicesFlow =
MutableSharedFlow<Pair<String, Set<Int>>>(extraBufferCapacity = 64)
val downloadingIndicesFlow = _downloadingIndicesFlow.asSharedFlow()
private val _downloadErrorFlow =
MutableSharedFlow<Pair<String, Set<Int>>>(extraBufferCapacity = 64)
val downloadErrorFlow = _downloadErrorFlow.asSharedFlow()
private fun updateSummary() {
_downloadSummaryFlow.value = downloadSummary
}
val cacheBookMap = ConcurrentHashMap<String, CacheBookModel>()
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<Int>()
private val onDownloadSet = linkedSetOf<Int>()
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 {
@@ -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("取消")
}
}
)
}
}
}
@@ -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<AppLogDialog>()
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)
@@ -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<Bookmark, ItemBookmarkBinding>(context) {
override fun getViewBinding(parent: ViewGroup): ItemBookmarkBinding {
return ItemBookmarkBinding.inflate(inflater, parent, false)
}
override fun convert(
holder: ItemViewHolder,
binding: ItemBookmarkBinding,
item: Bookmark,
payloads: MutableList<Any>
) {
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)
}
}
@@ -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<TocViewModel>(R.layout.fragment_bookmark),
BookmarkAdapter.Callback,
TocViewModel.BookmarkCallBack {
override val viewModel by activityViewModels<TocViewModel>()
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)
}
}
@@ -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<BookChapter, ItemChapterListBinding>(context) {
val cacheFileNames = hashSetOf<String>()
private val displayTitleMap = ConcurrentHashMap<String, String>()
private val selectedIndices = LinkedHashSet<Int>()
private val handler = Handler(Looper.getMainLooper())
private var upDisplayTileJob: Job? = null
private val collapsedVolumes = mutableSetOf<Int>() // 保存折叠的卷index
private var visibleItems = mutableListOf<BookChapter>() // 当前显示
override val diffItemCallback: DiffUtil.ItemCallback<BookChapter>
get() = object : DiffUtil.ItemCallback<BookChapter>() {
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<BookChapter> =
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<Int>()
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<BookChapter>()
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<BookChapter>()
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<BookChapter>()
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<BookChapter>()
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<String> {
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<Any>
) {
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)
}
}
@@ -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<TocViewModel>(R.layout.fragment_chapter_list),
ChapterListAdapter.Callback,
TocViewModel.ChapterListCallBack {
override val viewModel by activityViewModels<TocViewModel>()
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<String> {
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<Pair<Book, BookChapter>>(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()
}
}
}
@@ -0,0 +1,8 @@
package io.legado.app.ui.book.toc
enum class DownloadState {
NONE,
DOWNLOADING,
SUCCESS,
ERROR
}
@@ -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<ActivityChapterListBinding, TocViewModel>(),
TxtTocRuleDialog.CallBack {
class TocActivity : BaseComposeActivity() {
override val binding by viewBinding(ActivityChapterListBinding::inflate)
override val viewModel by viewModels<TocViewModel>()
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<Boolean>(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<String>()
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<AppLogDialog>()
}
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)
}
}
}
}
@@ -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<Bookmark?>(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<String>()
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 -> {
}
}
}
}
@@ -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<Book>()
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<Int>
data class TocActionState(
override val items: List<TocUiItem> = emptyList(),
override val selectedIds: Set<Int> = emptySet(),
override val searchKey: String = "",
override val isSearch: Boolean = false,
override val isUploading: Boolean = false,
val downloadingIndices: Set<Int> = emptySet(),
val errorIndices: Set<Int> = emptySet(),
val cachedFiles: Set<String> = emptySet(),
val downloadSummary: String = ""
) : RuleActionState<TocUiItem>
@OptIn(ExperimentalCoroutinesApi::class)
class TocViewModel(application: Application) :
BaseRuleViewModel<TocUiItem, Pair<BookChapter, String>, Int, TocActionState>(
application,
initialState = TocActionState()
) {
private val _book = MutableStateFlow<Book?>(null)
val bookState = _book.asStateFlow()
val isSplitLongChapter: Boolean
get() = _book.value?.getSplitLongChapter() ?: false
private val _collapsedVolumes = MutableStateFlow<Set<String>>(emptySet())
val collapsedVolumes = _collapsedVolumes.asStateFlow()
private val _cacheFileNames = MutableStateFlow<Set<String>>(emptySet())
val cacheFileNames = _cacheFileNames.asStateFlow()
private val _isReverse = MutableStateFlow(false)
private val _downloadingIndices = MutableStateFlow<Set<Int>>(emptySet())
private val _downloadSummary = MutableStateFlow("")
private val _errorIndices = MutableStateFlow<Set<Int>>(emptySet())
val errorIndices = _errorIndices.asStateFlow()
private val _bookmarks = MutableStateFlow<List<Bookmark>>(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<List<Pair<BookChapter, String>>> = combine(
dbChapterListFlow,
_isReverse,
_book.filterNotNull(),
snapshotFlow { ReadConfig.tocUiUseReplace },
snapshotFlow { ReadConfig.tocCountWords }
) { originalList, isReverse, book, useReplace, showWordCount ->
val processedList = if (isReverse) {
originalList.fold(mutableListOf<MutableList<BookChapter>>()) { 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<Pair<BookChapter, String>>,
key: String
): List<Pair<BookChapter, String>> {
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<TocUiItem>,
selectedIds: Set<Int>,
isSearch: Boolean,
isUploading: Boolean,
importState: BaseImportUiState<Pair<BookChapter, String>>
): 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<BookChapter, String>.toUiItem() = TocUiItem(first, second)
override fun ruleItemToEntity(item: TocUiItem) = item.chapter to item.displayTitle
override suspend fun generateJson(entities: List<Pair<BookChapter, String>>) = ""
override fun parseImportRules(text: String): List<Pair<BookChapter, String>> = emptyList()
override fun hasChanged(
newRule: Pair<BookChapter, String>,
oldRule: Pair<BookChapter, String>
) = false
override suspend fun findOldRule(newRule: Pair<BookChapter, String>) = 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<Application>().toastOnUi("开始下载 ${indices.size} 个章节")
clearSelection()
}
/**
* 下载单个章节
*/
fun downloadChapter(index: Int) {
val book = _book.value ?: return
CacheBook.start(getApplication(), book, listOf(index))
getApplication<Application>().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<Application>().toastOnUi("所有章节已缓存")
return
}
CacheBook.start(getApplication(), book, targetIndices)
getApplication<Application>().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)
}
}
@@ -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 = {
@@ -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() }
)
}
}
@@ -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
)
}
@@ -328,7 +328,7 @@ fun ReplaceRuleScreen(
viewModel.delSelectionByIds(ids as Set<Long>)
viewModel.setSelection(emptySet())
},
stickySubContent = {
bottomContent = {
if (tabItems.size > 1) {
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1)
@@ -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
)
}
}
}
@@ -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
)
}
}
@@ -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("取消")
}
}
)
}
}
@@ -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
)
}
}
}
}
@@ -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),
)
}
}
}
@@ -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)
)
}
}
@@ -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)
)
}
}
@@ -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,
)
}
@@ -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 <T> 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 <T> 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 ->
@@ -36,7 +36,7 @@ fun SwipeActionContainer(
val haptic = LocalHapticFeedback.current
val dismissState = rememberSwipeToDismissBoxState(
positionalThreshold = { totalDistance ->
totalDistance * 0.7f
totalDistance * 0.6f
}
)
val isThresholdReached =
@@ -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 <T> DynamicTopAppBar(
title: String,
subtitle: String? = null,
state: RuleActionState<T>,
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)
}
}
@@ -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<String, MutableSharedFlow<Any>>()
// 获取或创建对应 Tag 的 Flow
@Suppress("UNCHECKED_CAST")
fun <T> with(tag: String): MutableSharedFlow<T> {
return events.getOrPut(tag) {
MutableSharedFlow<Any>(
replay = 1, // 相当于 LiveData 的粘性,确保新订阅者能收到最后一次通知
MutableSharedFlow(
replay = 1,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
} as MutableSharedFlow<T>
}
// 快捷发送
fun post(tag: String, value: Any) {
MainScope().launch {
scope.launch {
with<Any>(tag).emit(value)
}
}
}
}
@@ -28,6 +28,11 @@
android:layout_marginStart="16dp"
android:layout_marginEnd="12dp">
<Button
android:id="@+id/test"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="0dp"