[新年快乐] 修复了一些问题,重写了目录页面(可能有bug)
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user