[新年快乐] 修复了一些问题,重写了目录页面(可能有bug)
This commit is contained in:
32 files changed
+2300
-1864
No files matched your search
@@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
import io.legado.app.data.entities.BookChapter
|
import io.legado.app.data.entities.BookChapter
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface BookChapterDao {
|
interface BookChapterDao {
|
||||||
@@ -19,6 +20,9 @@ interface BookChapterDao {
|
|||||||
@Query("select * from chapters where bookUrl = :bookUrl order by `index`")
|
@Query("select * from chapters where bookUrl = :bookUrl order by `index`")
|
||||||
fun getChapterList(bookUrl: String): List<BookChapter>
|
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`")
|
@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>
|
fun getChapterList(bookUrl: String, start: Int, end: Int): List<BookChapter>
|
||||||
|
|
||||||
|
|||||||
@@ -121,17 +121,6 @@ data class Book(
|
|||||||
var syncTime: Long = 0L
|
var syncTime: Long = 0L
|
||||||
) : Parcelable, BaseBook {
|
) : 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:Transient
|
||||||
@delegate:Ignore
|
@delegate:Ignore
|
||||||
@IgnoredOnParcel
|
@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.explore.ExploreShowViewModel
|
||||||
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
|
||||||
import io.legado.app.ui.book.searchContent.SearchContentViewModel
|
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.book.toc.rule.TxtTocRuleViewModel
|
||||||
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
|
import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
|
||||||
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
import io.legado.app.ui.dict.rule.DictRuleViewModel
|
||||||
@@ -45,6 +46,7 @@ val appModule = module {
|
|||||||
viewModelOf(::AllBookmarkViewModel)
|
viewModelOf(::AllBookmarkViewModel)
|
||||||
viewModelOf(::TxtTocRuleViewModel)
|
viewModelOf(::TxtTocRuleViewModel)
|
||||||
viewModelOf(::OtherConfigViewModel)
|
viewModelOf(::OtherConfigViewModel)
|
||||||
|
viewModelOf(::TocViewModel)
|
||||||
|
|
||||||
viewModel { (route: ReplaceEditRoute) ->
|
viewModel { (route: ReplaceEditRoute) ->
|
||||||
ReplaceEditViewModel(
|
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.coroutineScope
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
@@ -41,6 +44,27 @@ import kotlin.coroutines.CoroutineContext
|
|||||||
|
|
||||||
object CacheBook {
|
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>()
|
val cacheBookMap = ConcurrentHashMap<String, CacheBookModel>()
|
||||||
private val workingState = MutableStateFlow(true)
|
private val workingState = MutableStateFlow(true)
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
@@ -155,12 +179,14 @@ object CacheBook {
|
|||||||
}
|
}
|
||||||
}.onStart {
|
}.onStart {
|
||||||
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
|
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
|
||||||
|
updateSummary()
|
||||||
}.onEachParallel(AppConfig.threadCount) {
|
}.onEachParallel(AppConfig.threadCount) {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
it.download(this, context)
|
it.download(this, context)
|
||||||
}
|
}
|
||||||
}.onCompletion {
|
}.onCompletion {
|
||||||
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
|
postEvent(EventBus.UP_DOWNLOAD_STATE, "")
|
||||||
|
updateSummary()
|
||||||
}.collect()
|
}.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +228,22 @@ object CacheBook {
|
|||||||
|
|
||||||
class CacheBookModel(var bookSource: BookSource, var book: Book) {
|
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 waitDownloadSet = linkedSetOf<Int>()
|
||||||
private val onDownloadSet = linkedSetOf<Int>()
|
private val onDownloadSet = linkedSetOf<Int>()
|
||||||
private val tasks = CompositeCoroutine()
|
private val tasks = CompositeCoroutine()
|
||||||
@@ -267,6 +309,8 @@ object CacheBook {
|
|||||||
onDownloadSet.remove(chapter.index)
|
onDownloadSet.remove(chapter.index)
|
||||||
successDownloadSet.add(chapter.primaryStr())
|
successDownloadSet.add(chapter.primaryStr())
|
||||||
errorDownloadMap.remove(chapter.primaryStr())
|
errorDownloadMap.remove(chapter.primaryStr())
|
||||||
|
notifyDownloadSetChanged()
|
||||||
|
_cacheSuccessFlow.tryEmit(chapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -297,12 +341,15 @@ object CacheBook {
|
|||||||
private fun onError(chapter: BookChapter, error: Throwable) {
|
private fun onError(chapter: BookChapter, error: Throwable) {
|
||||||
onPreError(chapter, error)
|
onPreError(chapter, error)
|
||||||
onPostError(chapter, error)
|
onPostError(chapter, error)
|
||||||
|
notifyDownloadSetChanged()
|
||||||
|
notifyErrorChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun onCancel(index: Int) {
|
private fun onCancel(index: Int) {
|
||||||
onDownloadSet.remove(index)
|
onDownloadSet.remove(index)
|
||||||
if (!isStopped) waitDownloadSet.add(index)
|
if (!isStopped) waitDownloadSet.add(index)
|
||||||
|
notifyDownloadSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@@ -311,6 +358,8 @@ object CacheBook {
|
|||||||
cacheBookMap.remove(book.bookUrl)
|
cacheBookMap.remove(book.bookUrl)
|
||||||
}
|
}
|
||||||
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
postEvent(EventBus.UP_DOWNLOAD, book.bookUrl)
|
||||||
|
notifyDownloadSetChanged()
|
||||||
|
updateSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -336,6 +385,7 @@ object CacheBook {
|
|||||||
if (chapter.isVolume) {
|
if (chapter.isVolume) {
|
||||||
/** 修正下载计数 */
|
/** 修正下载计数 */
|
||||||
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
|
postEvent(EventBus.SAVE_CONTENT, Pair(book, chapter))
|
||||||
|
_cacheSuccessFlow.tryEmit(chapter)
|
||||||
waitDownloadSet.remove(chapterIndex)
|
waitDownloadSet.remove(chapterIndex)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -345,6 +395,7 @@ object CacheBook {
|
|||||||
}
|
}
|
||||||
waitDownloadSet.remove(chapterIndex)
|
waitDownloadSet.remove(chapterIndex)
|
||||||
onDownloadSet.add(chapterIndex)
|
onDownloadSet.add(chapterIndex)
|
||||||
|
notifyDownloadSetChanged()
|
||||||
if (BookHelp.hasContent(book, chapter)) {
|
if (BookHelp.hasContent(book, chapter)) {
|
||||||
Coroutine.async(scope, context, executeContext = context) {
|
Coroutine.async(scope, context, executeContext = context) {
|
||||||
BookHelp.getContent(book, chapter)?.let {
|
BookHelp.getContent(book, chapter)?.let {
|
||||||
|
|||||||
@@ -6,48 +6,31 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.layout.padding
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
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.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.UnfoldLess
|
import androidx.compose.material.icons.filled.UnfoldLess
|
||||||
import androidx.compose.material.icons.filled.UnfoldMore
|
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.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -55,23 +38,19 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.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.EmptyMessageView
|
||||||
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
||||||
import io.legado.app.ui.widget.components.SearchBarSection
|
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.button.SmallTopBarButton
|
||||||
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
|
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.lazylist.Scroller
|
||||||
import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
|
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
|
||||||
@@ -225,12 +204,10 @@ fun AllBookmarkScreen(
|
|||||||
val isCollapsed = collapsedGroups.contains(headerKey.toString())
|
val isCollapsed = collapsedGroups.contains(headerKey.toString())
|
||||||
|
|
||||||
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
|
stickyHeader(key = "${Scroller.STICKY_HEADER_KEY_PREFIX}${headerKey}") {
|
||||||
BookAuthorHeader(
|
CollapsibleHeader(
|
||||||
modifier = Modifier
|
modifier = Modifier.animateItem(),
|
||||||
.animateItem()
|
title = headerKey.bookName,
|
||||||
.fillMaxWidth(),
|
subtitle = headerKey.bookAuthor,
|
||||||
bookTitle = headerKey.bookName,
|
|
||||||
bookAuthor = headerKey.bookAuthor,
|
|
||||||
isCollapsed = isCollapsed,
|
isCollapsed = isCollapsed,
|
||||||
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
|
onToggle = { viewModel.toggleGroupCollapse(headerKey) }
|
||||||
)
|
)
|
||||||
@@ -242,13 +219,18 @@ fun AllBookmarkScreen(
|
|||||||
key = { it.id }
|
key = { it.id }
|
||||||
) { bookmarkUi ->
|
) { bookmarkUi ->
|
||||||
BookmarkItem(
|
BookmarkItem(
|
||||||
bookmark = bookmarkUi,
|
bookmark = bookmarkUi.rawBookmark,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.animateItem()
|
.animateItem()
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
|
isDur = false,
|
||||||
onClick = {
|
onClick = {
|
||||||
editingBookmark = bookmarkUi.rawBookmark
|
editingBookmark = bookmarkUi.rawBookmark
|
||||||
showBottomSheet = true
|
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.searchContent.SearchResult
|
||||||
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
|
||||||
import io.legado.app.ui.book.toc.TocActivityResult
|
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.browser.WebViewActivity
|
||||||
import io.legado.app.ui.dict.DictDialog
|
import io.legado.app.ui.dict.DictDialog
|
||||||
import io.legado.app.ui.login.SourceLoginActivity
|
import io.legado.app.ui.login.SourceLoginActivity
|
||||||
@@ -164,7 +164,6 @@ class ReadBookActivity : BaseReadBookActivity(),
|
|||||||
ReadBook.CallBack,
|
ReadBook.CallBack,
|
||||||
AutoReadDialog.CallBack,
|
AutoReadDialog.CallBack,
|
||||||
ToolButtonConfigDialog.CallBack,
|
ToolButtonConfigDialog.CallBack,
|
||||||
TxtTocRuleDialog.CallBack,
|
|
||||||
ColorPickerDialogListener,
|
ColorPickerDialogListener,
|
||||||
FontConfigDialog.CallBack,
|
FontConfigDialog.CallBack,
|
||||||
FontSelectDialog.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 =
|
private val searchContentActivity =
|
||||||
registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) {
|
registerForActivityResult(StartActivityContract(SearchContentActivity::class.java)) {
|
||||||
val data = it.data ?: return@registerForActivityResult
|
val data = it.data ?: return@registerForActivityResult
|
||||||
@@ -670,9 +681,11 @@ class ReadBookActivity : BaseReadBookActivity(),
|
|||||||
}
|
}
|
||||||
|
|
||||||
R.id.menu_log -> showDialogFragment<AppLogDialog>()
|
R.id.menu_log -> showDialogFragment<AppLogDialog>()
|
||||||
R.id.menu_toc_regex -> showDialogFragment(
|
R.id.menu_toc_regex -> {
|
||||||
TxtTocRuleDialog(ReadBook.book?.tocUrl)
|
val intent = Intent(this, TxtTocRuleActivity::class.java)
|
||||||
)
|
intent.putExtra("tocRegex", ReadBook.book?.tocUrl)
|
||||||
|
txtTocRuleLauncher.launch(intent)
|
||||||
|
}
|
||||||
|
|
||||||
R.id.menu_reverse_content -> ReadBook.book?.let {
|
R.id.menu_reverse_content -> ReadBook.book?.let {
|
||||||
viewModel.reverseContent(it)
|
viewModel.reverseContent(it)
|
||||||
@@ -1699,13 +1712,6 @@ class ReadBookActivity : BaseReadBookActivity(),
|
|||||||
*/
|
*/
|
||||||
override fun onDialogDismissed(dialogId: Int) = Unit
|
override fun onDialogDismissed(dialogId: Int) = Unit
|
||||||
|
|
||||||
override fun onTocRegexDialogResult(tocRegex: String) {
|
|
||||||
ReadBook.book?.let {
|
|
||||||
it.tocUrl = tocRegex
|
|
||||||
loadChapterList(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sureSyncProgress(progress: BookProgress) {
|
private fun sureSyncProgress(progress: BookProgress) {
|
||||||
alert(R.string.get_book_progress) {
|
alert(R.string.get_book_progress) {
|
||||||
setMessage(R.string.current_progress_exceeds_cloud)
|
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
|
package io.legado.app.ui.book.toc
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import androidx.compose.runtime.Composable
|
||||||
import android.graphics.Typeface
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import android.os.Bundle
|
import io.legado.app.base.BaseComposeActivity
|
||||||
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 io.legado.app.ui.replace.ReplaceRuleActivity
|
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>(),
|
class TocActivity : BaseComposeActivity() {
|
||||||
TxtTocRuleDialog.CallBack {
|
|
||||||
|
|
||||||
override val binding by viewBinding(ActivityChapterListBinding::inflate)
|
private val bookUrl: String? by lazy {
|
||||||
override val viewModel by viewModels<TocViewModel>()
|
intent.getStringExtra("bookUrl")
|
||||||
|
|
||||||
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 fun setupTabListener() {
|
@Composable
|
||||||
tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
|
override fun Content() {
|
||||||
override fun onTabSelected(tab: TabLayout.Tab) {
|
val context = LocalContext.current
|
||||||
updateButtonVisibility(tab.position)
|
|
||||||
}
|
|
||||||
override fun onTabUnselected(tab: TabLayout.Tab) {}
|
|
||||||
|
|
||||||
override fun onTabReselected(tab: TabLayout.Tab) {
|
TocScreen(
|
||||||
if (tab.position == 0) {
|
onBackClick = { finish() },
|
||||||
doReverseToc()
|
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
|
package io.legado.app.ui.book.toc
|
||||||
|
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import io.legado.app.R
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.legado.app.base.BaseViewModel
|
import io.legado.app.base.BaseRuleViewModel
|
||||||
import io.legado.app.constant.AppLog
|
|
||||||
import io.legado.app.data.appDb
|
import io.legado.app.data.appDb
|
||||||
import io.legado.app.data.entities.Book
|
import io.legado.app.data.entities.Book
|
||||||
import io.legado.app.data.entities.BookSource
|
import io.legado.app.data.entities.BookChapter
|
||||||
import io.legado.app.exception.NoStackTraceException
|
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.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.ReadBook
|
||||||
import io.legado.app.model.localBook.LocalBook
|
import io.legado.app.model.localBook.LocalBook
|
||||||
import io.legado.app.utils.FileDoc
|
import io.legado.app.ui.config.readConfig.ReadConfig
|
||||||
import io.legado.app.utils.GSON
|
import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
|
||||||
import io.legado.app.utils.createFileIfNotExist
|
import io.legado.app.ui.widget.components.rules.RuleActionState
|
||||||
import io.legado.app.utils.openOutputStream
|
import io.legado.app.ui.widget.components.rules.SelectableItem
|
||||||
import io.legado.app.utils.toastOnUi
|
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) {
|
data class TocUiItem(
|
||||||
var bookUrl: String = ""
|
val chapter: BookChapter,
|
||||||
var bookSource: BookSource? = null
|
val displayTitle: String,
|
||||||
var bookData = MutableLiveData<Book>()
|
override val id: Int = chapter.index
|
||||||
var chapterListCallBack: ChapterListCallBack? = null
|
) : SelectableItem<Int>
|
||||||
var bookMarkCallBack: BookmarkCallBack? = null
|
|
||||||
var searchKey: String? = null
|
data class TocActionState(
|
||||||
fun replaceRuleChanged() {
|
override val items: List<TocUiItem> = emptyList(),
|
||||||
execute {
|
override val selectedIds: Set<Int> = emptySet(),
|
||||||
ReadBook.book?.let {
|
override val searchKey: String = "",
|
||||||
ContentProcessor.get(it.name, it.origin).upReplaceRules()
|
override val isSearch: Boolean = false,
|
||||||
ReadBook.loadContent(resetPageOffset = 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) {
|
override fun composeUiState(
|
||||||
this.bookUrl = bookUrl
|
items: List<TocUiItem>,
|
||||||
execute {
|
selectedIds: Set<Int>,
|
||||||
appDb.bookDao.getBook(bookUrl)?.let {
|
isSearch: Boolean,
|
||||||
bookSource = appDb.bookSourceDao.getBookSource(it.origin)
|
isUploading: Boolean,
|
||||||
bookData.postValue(it)
|
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 {
|
execute {
|
||||||
appDb.bookDao.update(book)
|
appDb.bookDao.update(book)
|
||||||
LocalBook.getChapterList(book).let {
|
LocalBook.getChapterList(book).let { chapters ->
|
||||||
appDb.bookChapterDao.delByBook(book.bookUrl)
|
appDb.bookChapterDao.delByBook(book.bookUrl)
|
||||||
appDb.bookChapterDao.insert(*it.toTypedArray())
|
appDb.bookChapterDao.insert(*chapters.toTypedArray())
|
||||||
appDb.bookDao.update(book)
|
appDb.bookDao.update(book)
|
||||||
ReadBook.onChapterListUpdated(book)
|
ReadBook.onChapterListUpdated(book)
|
||||||
bookData.postValue(book)
|
|
||||||
|
_book.value = book
|
||||||
}
|
}
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
|
_isUploading.value = false
|
||||||
complete.invoke(null)
|
complete.invoke(null)
|
||||||
}.onError {
|
}.onError {
|
||||||
|
_isUploading.value = false
|
||||||
complete.invoke(it)
|
complete.invoke(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reverseToc(success: (book: Book) -> Unit) {
|
fun reverseToc() = execute {
|
||||||
execute {
|
val currentBook = _book.value ?: return@execute
|
||||||
bookData.value?.apply {
|
|
||||||
setReverseToc(!getReverseToc())
|
val newReverseState = !_isReverse.value
|
||||||
val toc = appDb.bookChapterDao.getChapterList(bookUrl)
|
_isReverse.value = newReverseState
|
||||||
val newToc = toc.reversed()
|
|
||||||
newToc.forEachIndexed { index, bookChapter ->
|
val newBook = currentBook.copy().apply {
|
||||||
bookChapter.index = index
|
setReverseToc(newReverseState)
|
||||||
}
|
}
|
||||||
appDb.bookChapterDao.insert(*newToc.toTypedArray())
|
appDb.bookDao.update(newBook)
|
||||||
}
|
_book.value = newBook
|
||||||
}.onSuccess {
|
}
|
||||||
it?.let(success)
|
|
||||||
|
fun toggleVolume(volumeName: String) {
|
||||||
|
_collapsedVolumes.update { current ->
|
||||||
|
if (current.contains(volumeName)) current - volumeName else current + volumeName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startChapterListSearch(newText: String?) {
|
fun expandAllVolumes() {
|
||||||
chapterListCallBack?.upChapterList(newText)
|
_collapsedVolumes.value = emptySet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startBookmarkSearch(newText: String?) {
|
fun collapseAllVolumes() = execute {
|
||||||
bookMarkCallBack?.upBookmark(newText)
|
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() {
|
fun selectAll() {
|
||||||
chapterListCallBack?.upAdapter()
|
setSelection(uiState.value.items.map { it.id }.toSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBookmark(treeUri: Uri) {
|
fun invertSelection() {
|
||||||
execute {
|
val allIds = uiState.value.items.map { it.id }.toSet()
|
||||||
val book = bookData.value
|
setSelection(allIds - _selectedIds.value)
|
||||||
?: throw NoStackTraceException(context.getString(R.string.no_book))
|
}
|
||||||
val fileName = "bookmark-${book.name} ${book.author}.json"
|
|
||||||
val doc = FileDoc.fromUri(treeUri, true)
|
fun clearSelection() {
|
||||||
doc.createFileIfNotExist(fileName).writeText(
|
setSelection(emptySet())
|
||||||
GSON.toJson(
|
}
|
||||||
appDb.bookmarkDao.getByBook(book.name, book.author)
|
|
||||||
|
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) {
|
context.toastOnUi("保存成功")
|
||||||
execute {
|
} catch (e: Exception) {
|
||||||
val book = bookData.value
|
context.toastOnUi("保存失败: ${e.message}")
|
||||||
?: 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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.onError {
|
|
||||||
AppLog.put("导出失败\n${it.localizedMessage}", it, true)
|
|
||||||
}.onSuccess {
|
|
||||||
context.toastOnUi("导出成功")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChapterListCallBack {
|
fun updateBookmark(bookmark: Bookmark) {
|
||||||
fun upChapterList(searchKey: String?)
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
appDb.bookmarkDao.insert(bookmark)
|
||||||
fun clearDisplayTitle()
|
}
|
||||||
|
|
||||||
fun upAdapter()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BookmarkCallBack {
|
fun deleteBookmark(bookmark: Bookmark) {
|
||||||
fun upBookmark(searchKey: String?)
|
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
|
@Composable
|
||||||
fun TxtRuleScreen(
|
fun TxtRuleScreen(
|
||||||
viewModel: TxtTocRuleViewModel = koinViewModel(),
|
viewModel: TxtTocRuleViewModel = koinViewModel(),
|
||||||
|
initialRule: String? = null,
|
||||||
|
onPickRule: ((String) -> Unit)? = null,
|
||||||
onBackClick: () -> Unit
|
onBackClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -74,7 +76,8 @@ fun TxtRuleScreen(
|
|||||||
|
|
||||||
val rules = uiState.items
|
val rules = uiState.items
|
||||||
val selectedIds = uiState.selectedIds
|
val selectedIds = uiState.selectedIds
|
||||||
val inSelectionMode = selectedIds.isNotEmpty()
|
val isPickMode = onPickRule != null
|
||||||
|
val inSelectionMode = if (isPickMode) false else selectedIds.isNotEmpty()
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
@@ -253,7 +256,7 @@ fun TxtRuleScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
RuleListScaffold(
|
RuleListScaffold(
|
||||||
title = "目录规则",
|
title = if (isPickMode) "选择目录规则" else "目录规则",
|
||||||
state = uiState,
|
state = uiState,
|
||||||
onBackClick = { onBackClick() },
|
onBackClick = { onBackClick() },
|
||||||
onSearchToggle = { active ->
|
onSearchToggle = { active ->
|
||||||
@@ -316,15 +319,29 @@ fun TxtRuleScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(rules, key = { it.id }) { item ->
|
items(rules, key = { it.id }) { item ->
|
||||||
|
|
||||||
|
val isItemHighLighted = if (isPickMode) {
|
||||||
|
item.rule.rule == initialRule
|
||||||
|
} else {
|
||||||
|
selectedIds.contains(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
ReorderableSelectionItem(
|
ReorderableSelectionItem(
|
||||||
state = reorderableState,
|
state = reorderableState,
|
||||||
key = item.id,
|
key = item.id,
|
||||||
title = item.name,
|
title = item.name,
|
||||||
subtitle = item.example,
|
subtitle = item.example,
|
||||||
isEnabled = item.isEnabled,
|
isEnabled = item.isEnabled,
|
||||||
isSelected = selectedIds.contains(item.id),
|
isSelected = isItemHighLighted,
|
||||||
inSelectionMode = inSelectionMode,
|
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)) },
|
onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enable = enabled)) },
|
||||||
onClickEdit = { editingRule = item.rule; showEditSheet = true },
|
onClickEdit = { editingRule = item.rule; showEditSheet = true },
|
||||||
trailingAction = {
|
trailingAction = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package io.legado.app.ui.book.toc.rule
|
package io.legado.app.ui.book.toc.rule
|
||||||
|
|
||||||
//import io.legado.app.lib.theme.primaryColor
|
//import io.legado.app.lib.theme.primaryColor
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import io.legado.app.base.BaseComposeActivity
|
import io.legado.app.base.BaseComposeActivity
|
||||||
import io.legado.app.ui.theme.AppTheme
|
import io.legado.app.ui.theme.AppTheme
|
||||||
@@ -9,8 +10,20 @@ class TxtTocRuleActivity : BaseComposeActivity() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content() {
|
override fun Content() {
|
||||||
|
val initialRule = intent.getStringExtra("tocRegex")
|
||||||
|
|
||||||
AppTheme {
|
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.delSelectionByIds(ids as Set<Long>)
|
||||||
viewModel.setSelection(emptySet())
|
viewModel.setSelection(emptySet())
|
||||||
},
|
},
|
||||||
stickySubContent = {
|
bottomContent = {
|
||||||
if (tabItems.size > 1) {
|
if (tabItems.size > 1) {
|
||||||
PrimaryScrollableTabRow(
|
PrimaryScrollableTabRow(
|
||||||
selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1)
|
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
|
package io.legado.app.ui.widget.components
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -9,11 +11,6 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.material3.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -112,28 +109,72 @@ fun AnimatedTextLine(
|
|||||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||||
style: TextStyle = LocalTextStyle.current
|
style: TextStyle = LocalTextStyle.current
|
||||||
) {
|
) {
|
||||||
var currentText by remember { mutableStateOf(text) }
|
AnimatedContent(
|
||||||
SideEffect { currentText = text }
|
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,
|
@Composable
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
fun AdaptiveAnimatedText(
|
||||||
) {
|
text: String,
|
||||||
AnimatedContent(
|
useCharMode: Boolean,
|
||||||
targetState = currentText,
|
modifier: Modifier = Modifier,
|
||||||
transitionSpec = {
|
color: Color = Color.Unspecified,
|
||||||
(slideInVertically(initialOffsetY = { it })).togetherWith(
|
fontSize: TextUnit = TextUnit.Unspecified,
|
||||||
slideOutVertically(targetOffsetY = { -it })
|
fontStyle: FontStyle? = null,
|
||||||
)
|
fontWeight: FontWeight? = null,
|
||||||
},
|
fontFamily: FontFamily? = null,
|
||||||
label = ""
|
letterSpacing: TextUnit = TextUnit.Unspecified,
|
||||||
) { text ->
|
textDecoration: TextDecoration? = null,
|
||||||
Text(
|
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,
|
text = text,
|
||||||
modifier = modifier,
|
|
||||||
style = style,
|
|
||||||
color = color,
|
color = color,
|
||||||
softWrap = softWrap,
|
|
||||||
fontSize = fontSize,
|
fontSize = fontSize,
|
||||||
fontStyle = fontStyle,
|
fontStyle = fontStyle,
|
||||||
fontWeight = fontWeight,
|
fontWeight = fontWeight,
|
||||||
@@ -143,9 +184,30 @@ fun AnimatedTextLine(
|
|||||||
textAlign = textAlign,
|
textAlign = textAlign,
|
||||||
lineHeight = lineHeight,
|
lineHeight = lineHeight,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
minLines = minLines,
|
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.IconToggleButtonShapes
|
||||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.OutlinedIconToggleButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -25,7 +28,7 @@ fun SmallIconButton(
|
|||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier.size(
|
modifier = Modifier.size(
|
||||||
IconButtonDefaults.extraSmallContainerSize(
|
IconButtonDefaults.extraSmallContainerSize(
|
||||||
IconButtonDefaults.IconButtonWidthOption.Narrow
|
IconButtonDefaults.IconButtonWidthOption.Uniform
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
shape = IconButtonDefaults.extraSmallRoundShape,
|
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.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.RowScope
|
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.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
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.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
|
import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.PlainTooltip
|
import androidx.compose.material3.PlainTooltip
|
||||||
@@ -57,11 +51,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import io.legado.app.R
|
import io.legado.app.R
|
||||||
import io.legado.app.ui.widget.components.ActionItem
|
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.SelectionBottomBar
|
||||||
import io.legado.app.ui.widget.components.button.SmallTopBarButton
|
import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -76,7 +67,7 @@ fun <T> RuleListScaffold(
|
|||||||
searchTrailingIcon: @Composable (() -> Unit)? = null,
|
searchTrailingIcon: @Composable (() -> Unit)? = null,
|
||||||
searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
|
searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
|
||||||
topBarActions: @Composable RowScope.() -> Unit = {},
|
topBarActions: @Composable RowScope.() -> Unit = {},
|
||||||
stickySubContent: @Composable (ColumnScope.() -> Unit)? = null,
|
bottomContent: @Composable (ColumnScope.() -> Unit)? = null,
|
||||||
dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {},
|
dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {},
|
||||||
onClearSelection: () -> Unit,
|
onClearSelection: () -> Unit,
|
||||||
onSelectAll: () -> Unit,
|
onSelectAll: () -> Unit,
|
||||||
@@ -142,67 +133,19 @@ fun <T> RuleListScaffold(
|
|||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
DynamicTopAppBar(
|
||||||
GlassMediumFlexibleTopAppBar(
|
title = title,
|
||||||
title = {
|
state = state,
|
||||||
val titleText = when {
|
scrollBehavior = scrollBehavior,
|
||||||
state.isUploading -> "请稍后..."
|
onBackClick = onBackClick,
|
||||||
state.selectedIds.isNotEmpty() -> "已选择 ${state.selectedIds.size}/${state.items.size}"
|
onSearchToggle = onSearchToggle,
|
||||||
else -> title
|
onSearchQueryChange = onSearchQueryChange,
|
||||||
}
|
searchPlaceholder = searchPlaceholder,
|
||||||
AnimatedText(text = titleText)
|
onClearSelection = onClearSelection,
|
||||||
},
|
topBarActions = topBarActions,
|
||||||
navigationIcon = {
|
dropDownMenuContent = dropDownMenuContent,
|
||||||
SmallTopBarButton(
|
bottomContent = bottomContent
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
floatingActionButton = floatingActionButton,
|
floatingActionButton = floatingActionButton,
|
||||||
content = { paddingValues ->
|
content = { paddingValues ->
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ fun SwipeActionContainer(
|
|||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
val dismissState = rememberSwipeToDismissBoxState(
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
positionalThreshold = { totalDistance ->
|
positionalThreshold = { totalDistance ->
|
||||||
totalDistance * 0.7f
|
totalDistance * 0.6f
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val isThresholdReached =
|
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
|
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.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
object FlowEventBus {
|
object FlowEventBus {
|
||||||
// 核心存储结构:Tag -> SharedFlow
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
private val events = ConcurrentHashMap<String, MutableSharedFlow<Any>>()
|
private val events = ConcurrentHashMap<String, MutableSharedFlow<Any>>()
|
||||||
|
|
||||||
// 获取或创建对应 Tag 的 Flow
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun <T> with(tag: String): MutableSharedFlow<T> {
|
fun <T> with(tag: String): MutableSharedFlow<T> {
|
||||||
return events.getOrPut(tag) {
|
return events.getOrPut(tag) {
|
||||||
MutableSharedFlow<Any>(
|
MutableSharedFlow(
|
||||||
replay = 1, // 相当于 LiveData 的粘性,确保新订阅者能收到最后一次通知
|
replay = 1,
|
||||||
extraBufferCapacity = 64,
|
extraBufferCapacity = 64,
|
||||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
)
|
)
|
||||||
} as MutableSharedFlow<T>
|
} as MutableSharedFlow<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快捷发送
|
|
||||||
fun post(tag: String, value: Any) {
|
fun post(tag: String, value: Any) {
|
||||||
MainScope().launch {
|
scope.launch {
|
||||||
with<Any>(tag).emit(value)
|
with<Any>(tag).emit(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,11 @@
|
|||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginEnd="12dp">
|
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
|
<com.google.android.material.tabs.TabLayout
|
||||||
android:id="@+id/tab_layout"
|
android:id="@+id/tab_layout"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|||||||
Reference in new issue
Block a user