Merge branch 'main' into fix/theme-switch

This commit is contained in:
HapeLee
2026-06-14 13:26:57 +08:00
35 changed files with 669 additions and 305 deletions
@@ -82,10 +82,6 @@ object PreferKey {
const val exportNoChapterName = "exportNoChapterName"
const val exportType = "exportType"
const val exportPictureFile = "exportPictureFile"
const val changeSourceCheckAuthor = "changeSourceCheckAuthor"
const val changeSourceLoadToc = "changeSourceLoadToc"
const val changeSourceLoadInfo = "changeSourceLoadInfo"
const val changeSourceLoadWordCount = "changeSourceLoadWordCount"
const val chineseConverterType = "chineseConverterType"
const val launcherIcon = "launcherIcon"
const val textSelectAble = "selectText"
@@ -24,4 +24,11 @@ object LocalPreferencesKeys {
val PASSWORD = stringPreferencesKey("password")
val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
// Change source options
val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
val CHANGE_SOURCE_LOAD_INFO = booleanPreferencesKey("changeSourceLoadInfo")
val CHANGE_SOURCE_LOAD_TOC = booleanPreferencesKey("changeSourceLoadToc")
val CHANGE_SOURCE_LOAD_WORD_COUNT = booleanPreferencesKey("changeSourceLoadWordCount")
val CHANGE_SOURCE_SEARCH_SCOPE = stringPreferencesKey("changeSourceSearchScope")
}
@@ -122,6 +122,7 @@ class SearchContentRepository {
matches.forEachIndexed { index, match ->
val construct = getResultAndQueryIndex(mContent, match.position, match.length)
val result = SearchResult(
bookUrl = book.bookUrl,
resultCountWithinChapter = index,
resultText = construct.second,
chapterTitle = chapter.title,
@@ -1,8 +1,8 @@
package io.legado.app.domain.model
import com.google.gson.GsonBuilder
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.splitNotBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
data class BookSearchScope(val raw: String) {
@@ -39,11 +39,13 @@ data class BookSearchScope(val raw: String) {
get() = groups.isEmpty() && sources.isEmpty()
}
@Serializable
data class ScopeSourceItem(
val name: String,
val url: String,
)
@Serializable
private data class SerializedSearchScope(
val type: String = "",
val groups: List<String> = emptyList(),
@@ -57,7 +59,9 @@ data class BookSearchScope(val raw: String) {
return if (selected.isEmpty()) {
""
} else {
scopeGson.toJson(SerializedSearchScope(type = TYPE_GROUP, groups = selected))
scopeJson.encodeToString(
SerializedSearchScope(type = TYPE_GROUP, groups = selected)
)
}
}
@@ -66,7 +70,9 @@ data class BookSearchScope(val raw: String) {
return if (selected.isEmpty()) {
""
} else {
scopeGson.toJson(SerializedSearchScope(type = TYPE_SOURCE, sources = selected))
scopeJson.encodeToString(
SerializedSearchScope(type = TYPE_SOURCE, sources = selected)
)
}
}
@@ -84,7 +90,9 @@ data class BookSearchScope(val raw: String) {
val json = raw.trim()
if (!json.startsWith("{") || !json.endsWith("}")) return null
return scopeGson.fromJsonObject<SerializedSearchScope>(json).getOrNull()?.let { scope ->
return runCatching {
scopeJson.decodeFromString<SerializedSearchScope>(json)
}.getOrNull()?.let { scope ->
when (scope.type) {
TYPE_SOURCE -> ParsedSearchScope(
sources = scope.sources.filter { it.url.isNotBlank() }
@@ -124,7 +132,10 @@ data class BookSearchScope(val raw: String) {
private const val TYPE_GROUP = "group"
private const val TYPE_SOURCE = "source"
private val scopeGson = GsonBuilder().disableHtmlEscaping().create()
private val scopeJson = Json {
encodeDefaults = false
ignoreUnknownKeys = true
}
}
}
@@ -9,7 +9,7 @@ import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.primaryStr
import io.legado.app.help.book.releaseHtmlData
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.changesource.ChangeSourceConfig
import io.legado.app.help.source.SourceHelp
import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.changesource.ObservableSourceConfig
@@ -135,10 +135,10 @@ class ChangeSourceSearchUseCase(
fromReadBookActivity: Boolean,
contentProcessor: ContentProcessor,
): List<SearchBook> {
val checkAuthor = AppConfig.changeSourceCheckAuthor
val loadInfo = AppConfig.changeSourceLoadInfo
val loadToc = AppConfig.changeSourceLoadToc
val loadWordCount = AppConfig.changeSourceLoadWordCount
val checkAuthor = ChangeSourceConfig.checkAuthor
val loadInfo = ChangeSourceConfig.loadInfo
val loadToc = ChangeSourceConfig.loadToc
val loadWordCount = ChangeSourceConfig.loadWordCount
val resultBooks = WebBook.searchBookAwait(
source, name,
@@ -465,12 +465,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.putPrefBoolean(PreferKey.parallelExportBook, value)
}
var changeSourceCheckAuthor: Boolean
get() = appCtx.getPrefBoolean(PreferKey.changeSourceCheckAuthor)
set(value) {
appCtx.putPrefBoolean(PreferKey.changeSourceCheckAuthor, value)
}
var ttsEngine: String?
get() = io.legado.app.ui.config.readConfig.ReadConfig.ttsEngine
set(value) {
@@ -504,24 +498,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val autoChangeSource: Boolean
get() = ReadConfig.autoChangeSource
var changeSourceLoadInfo: Boolean
get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo)
set(value) {
appCtx.putPrefBoolean(PreferKey.changeSourceLoadInfo, value)
}
var changeSourceLoadToc: Boolean
get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadToc)
set(value) {
appCtx.putPrefBoolean(PreferKey.changeSourceLoadToc, value)
}
var changeSourceLoadWordCount: Boolean
get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadWordCount)
set(value) {
appCtx.putPrefBoolean(PreferKey.changeSourceLoadWordCount, value)
}
var openBookInfoByClickTitle: Boolean
get() = appCtx.getPrefBoolean(PreferKey.openBookInfoByClickTitle, true)
set(value) {
@@ -11,7 +11,6 @@ import io.legado.app.base.adapter.DiffRecyclerAdapter
import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.ItemChangeSourceBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.utils.gone
import io.legado.app.utils.invisible
@@ -91,13 +90,13 @@ class ChangeBookSourceAdapter(
binding.ivGood.setImageResource(R.drawable.ic_praise)
}
if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
if (ChangeSourceConfig.loadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
tvCurrentChapterWordCount.visible()
} else {
tvCurrentChapterWordCount.gone()
}
if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) {
if (ChangeSourceConfig.loadWordCount && item.respondTime >= 0) {
tvRespondTime.visible()
} else {
tvRespondTime.gone()
@@ -227,11 +227,13 @@ class ChangeBookSourceComposeViewModel(
fun onLoadInfoChange(enabled: Boolean) {
if (ChangeSourceConfig.loadInfo == enabled) return
ChangeSourceConfig.loadInfo = enabled
refresh()
}
fun onLoadTocChange(enabled: Boolean) {
if (ChangeSourceConfig.loadToc == enabled) return
ChangeSourceConfig.loadToc = enabled
refresh()
}
fun onLoadWordCountChange(enabled: Boolean) {
@@ -29,7 +29,6 @@ import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource
import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.DialogBookChangeSourceBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.search.SearchScope
@@ -133,13 +132,13 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
binding.toolBar.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.menu.findItem(R.id.menu_check_author)
?.isChecked = AppConfig.changeSourceCheckAuthor
?.isChecked = ChangeSourceConfig.checkAuthor
binding.toolBar.menu.findItem(R.id.menu_load_info)
?.isChecked = AppConfig.changeSourceLoadInfo
?.isChecked = ChangeSourceConfig.loadInfo
binding.toolBar.menu.findItem(R.id.menu_load_toc)
?.isChecked = AppConfig.changeSourceLoadToc
?.isChecked = ChangeSourceConfig.loadToc
binding.toolBar.menu.findItem(R.id.menu_load_word_count)
?.isChecked = AppConfig.changeSourceLoadWordCount
?.isChecked = ChangeSourceConfig.loadWordCount
}
private fun initRecyclerView() {
@@ -316,23 +315,25 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) {
R.id.menu_check_author -> {
AppConfig.changeSourceCheckAuthor = !item.isChecked
ChangeSourceConfig.checkAuthor = !item.isChecked
item.isChecked = !item.isChecked
viewModel.refresh()
}
R.id.menu_load_info -> {
AppConfig.changeSourceLoadInfo = !item.isChecked
ChangeSourceConfig.loadInfo = !item.isChecked
item.isChecked = !item.isChecked
viewModel.refresh()
}
R.id.menu_load_toc -> {
AppConfig.changeSourceLoadToc = !item.isChecked
ChangeSourceConfig.loadToc = !item.isChecked
item.isChecked = !item.isChecked
viewModel.refresh()
}
R.id.menu_load_word_count -> {
AppConfig.changeSourceLoadWordCount = !item.isChecked
ChangeSourceConfig.loadWordCount = !item.isChecked
item.isChecked = !item.isChecked
viewModel.onLoadWordCountChecked(item.isChecked)
}
@@ -19,7 +19,6 @@ import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.primaryStr
import io.legado.app.help.book.releaseHtmlData
import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.SourceConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.SourceHelp
@@ -137,7 +136,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
}
}.map {
kotlin.runCatching {
val comparator = if (AppConfig.changeSourceLoadWordCount) {
val comparator = if (ChangeSourceConfig.loadWordCount) {
wordCountComparator
} else {
defaultComparator
@@ -269,10 +268,10 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
}
private suspend fun search(source: BookSource) {
val checkAuthor = AppConfig.changeSourceCheckAuthor
val loadInfo = AppConfig.changeSourceLoadInfo
val loadToc = AppConfig.changeSourceLoadToc
val loadWordCount = AppConfig.changeSourceLoadWordCount
val checkAuthor = ChangeSourceConfig.checkAuthor
val loadInfo = ChangeSourceConfig.loadInfo
val loadToc = ChangeSourceConfig.loadToc
val loadWordCount = ChangeSourceConfig.loadWordCount
val resultBooks = WebBook.searchBookAwait(
source, name,
filter = { fName, fAuthor, _ ->
@@ -295,7 +294,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book)
}
if (AppConfig.changeSourceLoadToc || AppConfig.changeSourceLoadWordCount) {
if (ChangeSourceConfig.loadToc || ChangeSourceConfig.loadWordCount) {
loadBookToc(source, book)
} else {
//从详情页里获取最新章节
@@ -315,7 +314,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
}
bookMap[book.primaryStr()] = book
book.releaseHtmlData()
if (AppConfig.changeSourceLoadWordCount) {
if (ChangeSourceConfig.loadWordCount) {
loadBookWordCount(source, book, chapters)
} else {
val searchBook = book.toSearchBook()
@@ -418,13 +417,13 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
else -> searchScope.displayNames.firstOrNull() ?: ""
}
return if (screenKey.isEmpty()) {
if (AppConfig.changeSourceCheckAuthor) {
if (ChangeSourceConfig.checkAuthor) {
appDb.searchBookDao.changeSourceByGroup(name, author, group)
} else {
appDb.searchBookDao.changeSourceByGroup(name, "", group)
}
} else {
if (AppConfig.changeSourceCheckAuthor) {
if (ChangeSourceConfig.checkAuthor) {
appDb.searchBookDao.changeSourceSearch(name, author, screenKey, group)
} else {
appDb.searchBookDao.changeSourceSearch(name, "", screenKey, group)
@@ -133,11 +133,13 @@ class ChangeChapterSourceViewModel(
is ChangeChapterSourceIntent.SetLoadInfo -> {
ChangeSourceConfig.loadInfo = intent.enabled
_uiState.update { it.copy(loadInfo = intent.enabled) }
refreshResults()
}
is ChangeChapterSourceIntent.SetLoadToc -> {
ChangeSourceConfig.loadToc = intent.enabled
_uiState.update { it.copy(loadToc = intent.enabled) }
refreshResults()
}
is ChangeChapterSourceIntent.SetLoadWordCount -> {
@@ -1,35 +1,83 @@
package io.legado.app.ui.book.changesource
import io.legado.app.constant.PreferKey
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import io.legado.app.data.local.preferences.LocalPreferencesKeys
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
import io.legado.app.ui.config.prefDelegate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import splitties.init.appCtx
object ChangeSourceConfig {
var searchScope by prefDelegate(
key = "changeSourceSearchScope",
defaultValue = ""
)
private val repo = LocalPreferencesRepository(appCtx)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var checkAuthor by prefDelegate(
key = PreferKey.changeSourceCheckAuthor,
defaultValue = false
)
private val _searchScope = mutableStateOf("")
var searchScope: String
get() = _searchScope.value
set(value) {
if (_searchScope.value != value) {
Snapshot.withMutableSnapshot { _searchScope.value = value }
scope.launch {
repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE, value)
}
}
}
var loadInfo by prefDelegate(
key = PreferKey.changeSourceLoadInfo,
defaultValue = false
)
private val _checkAuthor = mutableStateOf(false)
var checkAuthor: Boolean
get() = _checkAuthor.value
set(value) {
if (_checkAuthor.value != value) {
Snapshot.withMutableSnapshot { _checkAuthor.value = value }
scope.launch {
repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR, value)
}
}
}
var loadToc by prefDelegate(
key = PreferKey.changeSourceLoadToc,
defaultValue = false
)
private val _loadInfo = mutableStateOf(false)
var loadInfo: Boolean
get() = _loadInfo.value
set(value) {
if (_loadInfo.value != value) {
Snapshot.withMutableSnapshot { _loadInfo.value = value }
scope.launch {
repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO, value)
}
}
}
var loadWordCount by prefDelegate(
key = PreferKey.changeSourceLoadWordCount,
defaultValue = false
)
private val _loadToc = mutableStateOf(false)
var loadToc: Boolean
get() = _loadToc.value
set(value) {
if (_loadToc.value != value) {
Snapshot.withMutableSnapshot { _loadToc.value = value }
scope.launch {
repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC, value)
}
}
}
private val _loadWordCount = mutableStateOf(false)
var loadWordCount: Boolean
get() = _loadWordCount.value
set(value) {
if (_loadWordCount.value != value) {
Snapshot.withMutableSnapshot { _loadWordCount.value = value }
scope.launch {
repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT, value)
}
}
}
var migrateChapters by prefDelegate(
key = "migrateChapters",
@@ -64,6 +112,44 @@ object ChangeSourceConfig {
defaultValue = false
)
init {
runBlocking(Dispatchers.IO) {
Snapshot.withMutableSnapshot {
_searchScope.value =
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE, "").first()
_checkAuthor.value =
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR, false).first()
_loadInfo.value =
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO, false).first()
_loadToc.value =
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC, false).first()
_loadWordCount.value =
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT, false).first()
}
}
scope.launch {
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE, "")
.collect { Snapshot.withMutableSnapshot { _searchScope.value = it } }
}
scope.launch {
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR, false)
.collect { Snapshot.withMutableSnapshot { _checkAuthor.value = it } }
}
scope.launch {
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO, false)
.collect { Snapshot.withMutableSnapshot { _loadInfo.value = it } }
}
scope.launch {
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC, false)
.collect { Snapshot.withMutableSnapshot { _loadToc.value = it } }
}
scope.launch {
repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT, false)
.collect { Snapshot.withMutableSnapshot { _loadWordCount.value = it } }
}
}
fun getMigrationOptions(): ChangeSourceMigrationOptions {
return ChangeSourceMigrationOptions(
migrateChapters = migrateChapters,
@@ -40,6 +40,7 @@ sealed interface ReadBookMenuRoute {
data object ReadAloud : ReadBookMenuRoute
data object AutoRead : ReadBookMenuRoute
data object PaddingConfig : ReadBookMenuRoute
data object HeaderFooterConfig : ReadBookMenuRoute
data class Bookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookMenuRoute
}
@@ -717,7 +717,7 @@ class ReadBookController(
// Chapter not loaded — open it, then mark in the success callback
ReadBook.openChapter(
effect.chapterIndex,
effect.result.queryIndexInChapter
0
) {
val tc = ReadBook.curTextChapter ?: return@openChapter
val query = effect.result.query
@@ -139,6 +139,7 @@ import io.legado.app.help.config.ReadStyleResolver
import io.legado.app.ui.animation.DampedDragAnimation
import io.legado.app.ui.animation.InteractiveHighlight
import io.legado.app.ui.book.read.sheet.AutoReadContent
import io.legado.app.ui.book.read.sheet.HeaderFooterPage
import io.legado.app.ui.book.read.sheet.PaddingConfigContent
import io.legado.app.ui.book.read.sheet.ReadAloudContent
import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo
@@ -186,7 +187,7 @@ fun ReadBookMenuBar(
} else {
ReadBookMenuContent.Route(currentRoute)
}
val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig
val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig || currentRoute == ReadBookMenuRoute.HeaderFooterConfig
var readStylePage by remember { mutableIntStateOf(0) }
LaunchedEffect(currentRoute) {
if (currentRoute != ReadBookMenuRoute.ReadStyle) {
@@ -392,7 +393,7 @@ private fun ReadBookMenuSurface(
is ReadBookMenuContent.Route -> contentTarget.route
}
val expanded = route != ReadBookMenuRoute.Main
val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig
val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig || route == ReadBookMenuRoute.HeaderFooterConfig
val density = LocalDensity.current
val windowSize = LocalWindowInfo.current.containerSize
var surfaceHeightPx by remember { mutableIntStateOf(0) }
@@ -400,18 +401,6 @@ private fun ReadBookMenuSurface(
targetValue = if (dialogLikeRoute) 1f else 0f,
label = "ReadBookMenuMorph",
)
val headerFooterLift by animateDpAsState(
targetValue = if (route == ReadBookMenuRoute.ReadStyle && readStylePage == 2) {
72.dp
} else {
0.dp
},
animationSpec = tween(
durationMillis = 280,
easing = LinearOutSlowInEasing,
),
label = "ReadBookMenuHeaderFooterLift",
)
val maxHeight = with(density) {
windowSize.height.toDp() * 0.64f
}
@@ -518,8 +507,7 @@ private fun ReadBookMenuSurface(
.onSizeChanged { surfaceHeightPx = it.height }
.offset {
val dialogLiftPx = ((windowSize.height - surfaceHeightPx) / 2f) * morphProgress
val liftPx = dialogLiftPx + headerFooterLift.toPx()
IntOffset(x = 0, y = -liftPx.roundToInt())
IntOffset(x = 0, y = -dialogLiftPx.roundToInt())
}
.then(
if (useLiquidGlass) {
@@ -620,6 +608,9 @@ private fun ReadBookMenuSurface(
onOpenPaddingConfig = {
onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.PaddingConfig))
},
onOpenHeaderFooterConfig = {
onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.HeaderFooterConfig))
},
onOpenMoreConfig = {
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.MoreConfig))
},
@@ -659,6 +650,20 @@ private fun ReadBookMenuSurface(
}
}
ReadBookMenuRoute.HeaderFooterConfig -> {
ReadBookMenuRoutePage(
title = stringResource(R.string.header_footer),
maxHeight = maxHeight,
scrollContent = false,
bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
) {
HeaderFooterPage(
onIntent = onIntent,
)
}
}
ReadBookMenuRoute.TextTitle -> {
ReadBookMenuRoutePage(
title = stringResource(R.string.read_config_text_effects),
@@ -31,6 +31,7 @@ import io.legado.app.R
import io.legado.app.constant.AppLog
import io.legado.app.constant.ReadMenuBlurMode
import io.legado.app.help.IntentHelp
import io.legado.app.model.ReadBook
import io.legado.app.ui.book.info.BookInfoActivity
import io.legado.app.ui.book.read.page.ContentTextView
import io.legado.app.ui.book.read.page.ReadView
@@ -383,6 +384,10 @@ fun ReadBookRouteScreen(
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
SearchContentResult.results.collect { result ->
effectsReady.await()
if (result.bookUrl != ReadBook.book?.bookUrl) {
SearchContentResult.resetReplayCache()
return@collect
}
viewModel.onIntent(
ReadBookIntent.SetSearchResults(result.searchResults, result.index, result.query)
)
@@ -2241,50 +2241,21 @@ class ReadBookViewModel(
return arrayOf(-1, 0, 0, 0, 0, 0)
}
var pageIndex = 0
var length = pages[pageIndex].text.length
while (length < contentPosition && pageIndex + 1 < pages.size) {
pageIndex += 1
length += pages[pageIndex].text.length
}
if (length < contentPosition) return arrayOf(-1, 0, 0, 0, 0, 0)
val currentPage = pages[pageIndex]
val lines = currentPage.lines
if (lines.isEmpty()) return arrayOf(-1, 0, 0, 0, 0, 0)
var lineIndex = 0
var currentLine = lines[lineIndex]
length = length - currentPage.text.length + currentLine.text.length
if (currentLine.isParagraphEnd) length++
while (length <= contentPosition && lineIndex + 1 < lines.size) {
lineIndex += 1
currentLine = lines[lineIndex]
length += currentLine.text.length
if (currentLine.isParagraphEnd) length++
}
var currentLineLength = currentLine.text.length
if (currentLine.isParagraphEnd) currentLineLength++
length -= currentLineLength
val charIndex = contentPosition - length
var addLine = 0
var charIndex2 = 0
if ((charIndex + queryLength) > currentLineLength) {
addLine = 1
charIndex2 = charIndex + queryLength - currentLineLength - 1
}
if ((lineIndex + addLine + 1) > currentPage.lines.size) {
addLine = -1
charIndex2 = charIndex + queryLength - currentLineLength - 1
}
return when (addLine) {
0 -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex, charIndex + queryLength - 1)
1 -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex + 1, charIndex2)
-1 -> arrayOf(pageIndex, lineIndex, charIndex, 1, 0, charIndex2)
else -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex, charIndex)
}
val start = findSearchTextPoint(pages, contentPosition, preferPreviousLine = false)
?: return arrayOf(-1, 0, 0, 0, 0, 0)
val end = findSearchTextPoint(
pages,
contentPosition + queryLength - 1,
preferPreviousLine = true
) ?: start
return arrayOf(
start.pageIndex,
start.lineIndex,
start.charIndex,
end.pageIndex - start.pageIndex,
end.lineIndex,
end.charIndex
)
}
private fun findSearchResultMatch(
@@ -2293,6 +2264,26 @@ class ReadBookViewModel(
query: String,
): Pair<Int, Int>? {
if (query.isEmpty()) return null
val directLength = if (searchResult.matchLength > 0) searchResult.matchLength else query.length
val directIndex = searchResult.queryIndexInChapter
if (directIndex >= 0 && directIndex + directLength <= content.length) {
val directMatch = if (searchResult.isRegex) {
runCatching {
Regex(query).matches(content.substring(directIndex, directIndex + directLength))
}.getOrDefault(false)
} else {
content.regionMatches(
directIndex,
query,
0,
query.length,
ignoreCase = false
)
}
if (directMatch) {
return directIndex to directLength
}
}
if (searchResult.isRegex) {
return runCatching {
Regex(query).findAll(content)
@@ -2311,6 +2302,43 @@ class ReadBookViewModel(
return index.takeIf { it >= 0 }?.let { it to query.length }
}
private fun findSearchTextPoint(
pages: List<TextPage>,
contentPosition: Int,
preferPreviousLine: Boolean,
): SearchTextPoint? {
var fallback: SearchTextPoint? = null
pages.forEachIndexed { pageIndex, page ->
page.lines.forEachIndexed { lineIndex, line ->
if (line.columns.isEmpty()) return@forEachIndexed
val lineStart = line.chapterPosition
val lineEndExclusive = lineStart + line.charSize
if (contentPosition in lineStart until lineEndExclusive) {
return SearchTextPoint(
pageIndex = pageIndex,
lineIndex = lineIndex,
charIndex = (contentPosition - lineStart).coerceIn(0, line.columns.lastIndex)
)
}
if (preferPreviousLine && line.isParagraphEnd && contentPosition == lineEndExclusive) {
return SearchTextPoint(
pageIndex = pageIndex,
lineIndex = lineIndex,
charIndex = line.columns.lastIndex
)
}
if (contentPosition >= lineEndExclusive) {
fallback = SearchTextPoint(
pageIndex = pageIndex,
lineIndex = lineIndex,
charIndex = line.columns.lastIndex
)
}
}
}
return fallback
}
/**
* Compute the search result position and emit [ReadBookEffect.NavigateToSearchResult]
* so the Controller can navigate and highlight.
@@ -3572,6 +3600,12 @@ private const val TOOL_BUTTON_PREFS = "tool_button_config"
private const val TOOL_BUTTON_KEY = "tool_buttons"
private const val DEFAULT_ENABLED_BUTTON_COUNT = 5
private data class SearchTextPoint(
val pageIndex: Int,
val lineIndex: Int,
val charIndex: Int,
)
private fun Int.coerceSearchResultIndex(resultSize: Int): Int {
return if (resultSize <= 0) 0 else coerceIn(0, resultSize - 1)
}
@@ -1,25 +1,35 @@
package io.legado.app.ui.book.read.sheet
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
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.Box
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.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
@@ -32,11 +42,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -46,6 +54,7 @@ import io.legado.app.data.repository.ReadSettingsRepository
import io.legado.app.help.config.ReadBookConfig
import io.legado.app.ui.book.read.ConfigUpdate
import io.legado.app.ui.book.read.ReadBookIntent
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.FontSelectSheet
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
@@ -106,6 +115,42 @@ internal fun HeaderFooterPage(
var colorPickerInitial by remember { mutableIntStateOf(0) }
var showFontSelect by remember { mutableStateOf(false) }
var expandHeaderPadding by remember { mutableStateOf(false) }
var expandFooterPadding by remember { mutableStateOf(false) }
val headerScrollState = rememberScrollState()
val footerScrollState = rememberScrollState()
LaunchedEffect(expandHeaderPadding, headerScrollState.maxValue) {
if (expandHeaderPadding) {
headerScrollState.scrollTo(headerScrollState.maxValue)
}
}
LaunchedEffect(expandFooterPadding, footerScrollState.maxValue) {
if (expandFooterPadding) {
footerScrollState.scrollTo(footerScrollState.maxValue)
}
}
DisposableEffect(Unit) {
onDispose {
onIntent(ReadBookIntent.SaveReadStyleConfig)
}
}
// Header padding state
var headerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingTop.toFloat()) }
var headerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingBottom.toFloat()) }
var headerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingLeft.toFloat()) }
var headerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingRight.toFloat()) }
// Footer padding state
var footerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingTop.toFloat()) }
var footerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingBottom.toFloat()) }
var footerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingLeft.toFloat()) }
var footerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingRight.toFloat()) }
val tipNames = remember { ReadBookConfig.tipNames }
val tipValues = remember { ReadBookConfig.tipValues }
@@ -153,7 +198,7 @@ internal fun HeaderFooterPage(
scope.launch {
pagerState.animateScrollToPage(
page = index,
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)
}
},
@@ -181,7 +226,7 @@ internal fun HeaderFooterPage(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
.verticalScroll(headerScrollState),
) {
TinySwitchSettingItem(
title = stringResource(R.string.showLine),
@@ -252,6 +297,56 @@ internal fun HeaderFooterPage(
showColorPicker = true
},
)
Spacer(Modifier.height(8.dp))
TinyClickableSettingItem(
title = stringResource(R.string.padding),
description = stringResource(
R.string.padding_format,
headerPaddingTop.toInt(),
headerPaddingBottom.toInt(),
headerPaddingLeft.toInt(),
headerPaddingRight.toInt(),
),
trailingContent = {
Icon(
imageVector = if (expandHeaderPadding) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
contentDescription = null,
tint = LegadoTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
onClick = {
expandHeaderPadding = !expandHeaderPadding
},
)
AnimatedVisibility(
visible = expandHeaderPadding,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
PaddingSliders(
top = headerPaddingTop, bottom = headerPaddingBottom,
left = headerPaddingLeft, right = headerPaddingRight,
onTopChange = {
headerPaddingTop = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt())))
},
onBottomChange = {
headerPaddingBottom = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt())))
},
onLeftChange = {
headerPaddingLeft = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt())))
},
onRightChange = {
headerPaddingRight = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt())))
},
)
}
}
}
}
@@ -261,7 +356,7 @@ internal fun HeaderFooterPage(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
.verticalScroll(footerScrollState),
) {
TinySwitchSettingItem(
title = stringResource(R.string.showLine),
@@ -332,6 +427,56 @@ internal fun HeaderFooterPage(
showColorPicker = true
},
)
Spacer(Modifier.height(8.dp))
TinyClickableSettingItem(
title = stringResource(R.string.padding),
description = stringResource(
R.string.padding_format,
footerPaddingTop.toInt(),
footerPaddingBottom.toInt(),
footerPaddingLeft.toInt(),
footerPaddingRight.toInt(),
),
trailingContent = {
Icon(
imageVector = if (expandFooterPadding) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
contentDescription = null,
tint = LegadoTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
onClick = {
expandFooterPadding = !expandFooterPadding
},
)
AnimatedVisibility(
visible = expandFooterPadding,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
PaddingSliders(
top = footerPaddingTop, bottom = footerPaddingBottom,
left = footerPaddingLeft, right = footerPaddingRight,
onTopChange = {
footerPaddingTop = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt())))
},
onBottomChange = {
footerPaddingBottom = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt())))
},
onLeftChange = {
footerPaddingLeft = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt())))
},
onRightChange = {
footerPaddingRight = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt())))
},
)
}
}
}
}
@@ -3,18 +3,11 @@ package io.legado.app.ui.book.read.sheet
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -24,8 +17,6 @@ import io.legado.app.ui.book.read.ConfigUpdate
import io.legado.app.ui.book.read.ReadBookIntent
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
import io.legado.app.ui.widget.components.tabRow.CardTabRow
import kotlinx.coroutines.launch
@Composable
fun PaddingConfigSheet(
@@ -58,122 +49,35 @@ fun PaddingConfigContent(
var paddingBottom by remember { mutableFloatStateOf(ReadBookConfig.paddingBottom.toFloat()) }
var paddingLeft by remember { mutableFloatStateOf(ReadBookConfig.paddingLeft.toFloat()) }
var paddingRight by remember { mutableFloatStateOf(ReadBookConfig.paddingRight.toFloat()) }
// Header padding
var headerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingTop.toFloat()) }
var headerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingBottom.toFloat()) }
var headerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingLeft.toFloat()) }
var headerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingRight.toFloat()) }
// Footer padding
var footerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingTop.toFloat()) }
var footerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingBottom.toFloat()) }
var footerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingLeft.toFloat()) }
var footerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingRight.toFloat()) }
val scope = rememberCoroutineScope()
val tabTitles = listOf(
stringResource(R.string.header),
stringResource(R.string.main_body),
stringResource(R.string.footer),
)
val pagerState = rememberPagerState(pageCount = { 3 })
var selectedTab by remember { mutableIntStateOf(0) }
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { selectedTab = it }
}
Column(
modifier = modifier.fillMaxWidth(),
) {
CardTabRow(
tabTitles = tabTitles,
selectedTabIndex = selectedTab,
onTabSelected = { index ->
selectedTab = index
scope.launch { pagerState.animateScrollToPage(index) }
PaddingSliders(
top = paddingTop, bottom = paddingBottom,
left = paddingLeft, right = paddingRight,
onTopChange = {
paddingTop = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingTop(it.toInt())))
},
onBottomChange = {
paddingBottom = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingBottom(it.toInt())))
},
onLeftChange = {
paddingLeft = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingLeft(it.toInt())))
},
onRightChange = {
paddingRight = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingRight(it.toInt())))
},
modifier = Modifier.padding(bottom = 8.dp),
)
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { page ->
when (page) {
0 -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
PaddingSliders(
top = headerPaddingTop, bottom = headerPaddingBottom,
left = headerPaddingLeft, right = headerPaddingRight,
onTopChange = {
headerPaddingTop = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt())))
},
onBottomChange = {
headerPaddingBottom = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt())))
},
onLeftChange = {
headerPaddingLeft = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt())))
},
onRightChange = {
headerPaddingRight = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt())))
},
)
}
1 -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
PaddingSliders(
top = paddingTop, bottom = paddingBottom,
left = paddingLeft, right = paddingRight,
onTopChange = {
paddingTop = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingTop(it.toInt())))
},
onBottomChange = {
paddingBottom = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingBottom(it.toInt())))
},
onLeftChange = {
paddingLeft = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingLeft(it.toInt())))
},
onRightChange = {
paddingRight = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingRight(it.toInt())))
},
)
}
2 -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
PaddingSliders(
top = footerPaddingTop, bottom = footerPaddingBottom,
left = footerPaddingLeft, right = footerPaddingRight,
onTopChange = {
footerPaddingTop = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt())))
},
onBottomChange = {
footerPaddingBottom = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt())))
},
onLeftChange = {
footerPaddingLeft = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt())))
},
onRightChange = {
footerPaddingRight = it
onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt())))
},
)
}
}
}
}
}
@Composable
private fun PaddingSliders(
internal fun PaddingSliders(
top: Float, bottom: Float, left: Float, right: Float,
onTopChange: (Float) -> Unit, onBottomChange: (Float) -> Unit,
onLeftChange: (Float) -> Unit, onRightChange: (Float) -> Unit,
@@ -38,6 +38,7 @@ import kotlinx.coroutines.launch
@Composable
fun ReadStyleContent(
onOpenPaddingConfig: () -> Unit,
onOpenHeaderFooterConfig: () -> Unit,
onOpenMoreConfig: () -> Unit,
onOpenBgTextConfig: (Int) -> Unit,
onOpenTextTitle: () -> Unit,
@@ -51,7 +52,7 @@ fun ReadStyleContent(
styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
) {
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { 3 })
val pagerState = rememberPagerState(pageCount = { 2 })
var currentPage by remember { mutableIntStateOf(0) }
val pageHeights = remember { mutableStateMapOf<Int, Int>() }
@@ -105,10 +106,6 @@ fun ReadStyleContent(
bottomBarButtons = bottomBarButtons,
onIntent = onIntent,
)
2 -> HeaderFooterPage(
onIntent = onIntent,
)
}
}
}
@@ -123,15 +120,17 @@ fun ReadStyleContent(
tabTitles = tabTitles,
selectedTabIndex = currentPage,
onTabSelected = { index ->
if (index < 3) {
scope.launch {
pagerState.animateScrollToPage(
page = index,
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
)
when (index) {
0, 1 -> {
scope.launch {
pagerState.animateScrollToPage(
page = index,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)
}
}
} else {
onOpenMoreConfig()
2 -> onOpenHeaderFooterConfig()
3 -> onOpenMoreConfig()
}
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
@@ -128,7 +128,7 @@ internal fun SystemMenuPage(
scope.launch {
pagerState.animateScrollToPage(
page = index,
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)
}
},
@@ -25,6 +25,7 @@ import io.legado.app.R
import io.legado.app.data.entities.BookSourcePart
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.icon.AppIcons
@@ -46,9 +47,20 @@ fun ScopeSelectSheet(
isSourceScope: Boolean = false,
title: String = stringResource(R.string.search_select_group),
onConfirm: (() -> Unit)? = null,
onApplyScope: ((ScopeSelection) -> Unit)? = null,
) {
var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) }
var filterText by rememberSaveable(show) { mutableStateOf("") }
var draftIsAll by remember(show, isAll) { mutableStateOf(isAll) }
var draftIsSourceScope by remember(show, isSourceScope) { mutableStateOf(isSourceScope) }
var draftGroups by remember(show, selectedGroups) { mutableStateOf(selectedGroups.toSet()) }
var draftSourceUrls by remember(show, selectedSources) { mutableStateOf(selectedSources.toSet()) }
val useDraftSelection = onApplyScope != null
val currentIsAll = if (useDraftSelection) draftIsAll else isAll
val currentIsSourceScope = if (useDraftSelection) draftIsSourceScope else isSourceScope
val currentGroups = if (useDraftSelection) draftGroups else selectedGroups
val currentSourceUrls = if (useDraftSelection) draftSourceUrls else selectedSources
val filteredGroups = remember(groups, filterText) {
if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) }
@@ -87,11 +99,18 @@ fun ScopeSelectSheet(
SelectionItemCard(
title = stringResource(R.string.all_source),
isSelected = isAll,
isSelected = currentIsAll,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true,
onToggleSelection = {
onSelectAll()
if (useDraftSelection) {
draftIsAll = true
draftIsSourceScope = false
draftGroups = emptySet()
draftSourceUrls = emptySet()
} else {
onSelectAll()
}
}
)
@@ -113,14 +132,27 @@ fun ScopeSelectSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(filteredGroups, key = { it }) { groupName ->
val selected = !isSourceScope && selectedGroups.contains(groupName)
val selected = !currentIsSourceScope && currentGroups.contains(groupName)
SelectionItemCard(
title = groupName,
isSelected = selected,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true,
onToggleSelection = {
onToggleGroup(groupName)
if (useDraftSelection) {
val next = currentGroups.toMutableSet()
if (!currentIsSourceScope && next.contains(groupName)) {
next.remove(groupName)
} else {
next.add(groupName)
}
draftGroups = next
draftSourceUrls = emptySet()
draftIsSourceScope = false
draftIsAll = next.isEmpty()
} else {
onToggleGroup(groupName)
}
}
)
}
@@ -142,7 +174,7 @@ fun ScopeSelectSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(filteredSources, key = { it.bookSourceUrl }) { source ->
val selected = selectedSources.contains(source.bookSourceUrl)
val selected = currentSourceUrls.contains(source.bookSourceUrl)
SelectionItemCard(
title = source.bookSourceName,
subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
@@ -150,7 +182,20 @@ fun ScopeSelectSheet(
isSelected = selected,
inSelectionMode = true,
onToggleSelection = {
onToggleSource(source)
if (useDraftSelection) {
val next = currentSourceUrls.toMutableSet()
if (next.contains(source.bookSourceUrl)) {
next.remove(source.bookSourceUrl)
} else {
next.add(source.bookSourceUrl)
}
draftSourceUrls = next
draftGroups = emptySet()
draftIsSourceScope = next.isNotEmpty()
draftIsAll = next.isEmpty()
} else {
onToggleSource(source)
}
}
)
}
@@ -158,7 +203,36 @@ fun ScopeSelectSheet(
}
}
if (onApplyScope != null) {
Spacer(modifier = Modifier.height(16.dp))
ConfirmDismissButtonsRow(
onDismiss = onDismissRequest,
onConfirm = {
onApplyScope(
ScopeSelection(
groupNames = if (!draftIsSourceScope) draftGroups.toList() else emptyList(),
sources = if (draftIsSourceScope) {
sources.filter { draftSourceUrls.contains(it.bookSourceUrl) }
} else {
emptyList()
},
isSourceScope = draftIsSourceScope,
)
)
onDismissRequest()
},
dismissText = stringResource(R.string.cancel),
confirmText = stringResource(R.string.confirm),
)
}
Spacer(modifier = Modifier.height(20.dp))
}
}
}
data class ScopeSelection(
val groupNames: List<String>,
val sources: List<BookSourcePart>,
val isSourceScope: Boolean,
)
@@ -88,6 +88,11 @@ sealed interface SearchIntent {
data class ToggleSourceType(val type: Int) : SearchIntent
data object ClearAllSourceTypes : SearchIntent
data object SelectAllScope : SearchIntent
data class ApplyScopeSelection(
val groupNames: List<String>,
val sources: List<BookSourcePart>,
val isSourceScope: Boolean,
) : SearchIntent
data class ToggleScopeGroup(val groupName: String) : SearchIntent
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
data class RemoveScopeItem(val scopeName: String) : SearchIntent
@@ -70,7 +70,7 @@ data class SearchScope(private var scope: String) {
if (scope.isEmpty()) {
return appCtx.getString(R.string.all_source)
}
return scope
return parsedScope().groupNames.joinToString(",")
}
/**
@@ -112,6 +112,7 @@ fun SearchScreen(
val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
var keepResultsPinnedToTop by rememberSaveable { mutableStateOf(true) }
val showSuggestionPanel = state.showSuggestions
val latestQuery by rememberUpdatedState(state.query)
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
@@ -243,6 +244,37 @@ fun SearchScreen(
}
}
LaunchedEffect(state.committedQuery) {
keepResultsPinnedToTop = true
}
LaunchedEffect(isSourceGroupedMode, listState, groupedListState) {
snapshotFlow {
val activeState = if (isSourceGroupedMode) groupedListState else listState
Triple(
activeState.firstVisibleItemIndex,
activeState.firstVisibleItemScrollOffset,
activeState.isScrollInProgress
)
}.collect { (index, offset, isScrollInProgress) ->
if (index == 0 && offset == 0) {
keepResultsPinnedToTop = true
} else if (isScrollInProgress) {
keepResultsPinnedToTop = false
}
}
}
val firstResultKey = state.results.firstOrNull()?.let {
"${it.book.origin}:${it.book.bookUrl}"
}
LaunchedEffect(firstResultKey, state.results.size, state.isSearching) {
if (state.isSearching && keepResultsPinnedToTop && state.results.isNotEmpty()) {
listState.scrollToItem(0)
groupedListState.scrollToItem(0)
}
}
val submitSearch: (String) -> Unit = { rawQuery ->
val normalized = rawQuery.trim()
if (normalized.isNotBlank()) {
@@ -611,6 +643,15 @@ fun SearchScreen(
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
isSourceScope = state.isSourceScope,
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
onApplyScope = { selection ->
viewModel.onIntent(
SearchIntent.ApplyScopeSelection(
groupNames = selection.groupNames,
sources = selection.sources,
isSourceScope = selection.isSourceScope,
)
)
},
)
AppModalBottomSheet(
@@ -211,6 +211,7 @@ class SearchViewModel(
syncScopeState(restartSearch = true, oldScope = oldScope)
}
is SearchIntent.ApplyScopeSelection -> applyScopeSelection(intent)
is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName)
is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source)
is SearchIntent.RemoveScopeItem -> {
@@ -221,6 +222,7 @@ class SearchViewModel(
}
is SearchIntent.SetMatchMode -> {
_uiState.update { it.copy(matchMode = intent.mode) }
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, intent.mode.value
@@ -427,7 +429,6 @@ class SearchViewModel(
it.copy(
query = query,
showSuggestions = showSuggestions,
isManualStop = false,
emptyScopeAction = null,
)
}
@@ -657,6 +658,7 @@ class SearchViewModel(
_uiState.update { it.copy(emptyScopeAction = null) }
if (action.wasMatchMode == MatchMode.EXACT) {
_uiState.update { it.copy(matchMode = MatchMode.DEFAULT) }
viewModelScope.launch {
localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
@@ -672,12 +674,29 @@ class SearchViewModel(
}
private fun restartCommittedSearchIfNeeded() {
val committed = _uiState.value.committedQuery
if (committed.isNotBlank()) {
val state = _uiState.value
val committed = state.committedQuery
if (
committed.isNotBlank() &&
state.query.trim() == committed &&
!state.showSuggestions &&
!state.isManualStop
) {
submitSearch(committed)
}
}
private fun applyScopeSelection(intent: SearchIntent.ApplyScopeSelection) {
val oldScope = searchScope.toString()
when {
intent.isSourceScope -> searchScope.updateSources(intent.sources)
intent.groupNames.isNotEmpty() -> searchScope.update(intent.groupNames)
else -> searchScope.update("")
}
persistSearchScope()
syncScopeState(restartSearch = true, oldScope = oldScope)
}
private fun syncScopeState(
restartSearch: Boolean = false,
oldScope: String? = null,
@@ -1,5 +1,6 @@
package io.legado.app.ui.book.searchContent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow
object SearchContentResult {
data class Result(
val bookUrl: String,
val searchResults: List<SearchResult>,
val index: Int,
val query: String,
@@ -25,6 +27,7 @@ object SearchContentResult {
_results.tryEmit(result)
}
@OptIn(ExperimentalCoroutinesApi::class)
fun resetReplayCache() {
_results.resetReplayCache()
}
@@ -195,6 +195,7 @@ class SearchContentViewModel(
if (index < 0) return false
SearchContentResult.emitResult(
SearchContentResult.Result(
bookUrl = bookUrl,
searchResults = results,
index = index,
query = searchResult.query
@@ -11,6 +11,7 @@ import android.text.style.UnderlineSpan
import io.legado.app.help.config.AppConfig
data class SearchResult(
val bookUrl: String = "",
val resultCount: Int = 0,
val resultCountWithinChapter: Int = 0,
val resultText: String = "",
@@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.NavKey
@@ -67,6 +68,12 @@ import kotlin.coroutines.suspendCoroutine
open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
companion object {
private const val KEY_RESTORE_READ_ROUTE = "restoreReadRoute"
private const val KEY_RESTORE_READ_BOOK_URL = "restoreReadBookUrl"
private const val KEY_RESTORE_READ_ALOUD = "restoreReadAloud"
private const val KEY_RESTORE_READ_IN_BOOKSHELF = "restoreReadInBookshelf"
private const val KEY_RESTORE_READ_CHAPTER_CHANGED = "restoreReadChapterChanged"
@Volatile
var hasActiveReadBookRoute: Boolean = false
@@ -143,10 +150,15 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null
private var shouldApplyDefaultToRead = true
private var restoredReadBookRoute: MainRouteReadBook? = null
private var latestBackStack: List<NavKey> = emptyList()
internal var activeReadBookInputHandler: ReadBookInputHandler? = null
internal var activeReadBookRoute: MainRouteReadBook? = null
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
shouldApplyDefaultToRead = savedInstanceState == null
restoredReadBookRoute = savedInstanceState?.restoreReadBookRoute()
super.onCreate(savedInstanceState)
if (checkStartupRoute()) return
@@ -175,6 +187,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (!intent.hasExplicitStartRoute()) return
routeEvents.tryEmit(MainNavigator.resolveStartRoute(intent))
}
@@ -194,12 +207,21 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
}
val startRoutes = remember {
resolveInitialStartRoutes(
resolved = MainNavigator.resolveStartRoute(intent),
defaultToRead = OtherConfig.defaultToRead,
isFreshCreate = shouldApplyDefaultToRead,
).toTypedArray()
val resolved = MainNavigator.resolveStartRoute(intent)
val hasExplicitStartRoute = intent?.hasExplicitStartRoute() == true
when {
!hasExplicitStartRoute && restoredReadBookRoute != null -> {
arrayOf(MainRouteHome, restoredReadBookRoute!!)
}
shouldApplyDefaultToRead && OtherConfig.defaultToRead && resolved == MainRouteHome -> {
arrayOf(MainRouteHome, MainRouteReadBook())
}
else -> {
arrayOf(resolved)
}
}
}
latestBackStack = startRoutes.toList()
val backStack = rememberNavBackStack(*startRoutes)
SideEffect {
@@ -212,6 +234,11 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
}
}
LaunchedEffect(backStack) {
snapshotFlow { backStack.toList() }
.collect { latestBackStack = it }
}
SharedTransitionLayout {
NavDisplay(
backStack = backStack,
@@ -374,6 +401,29 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
if (AppConfig.autoRefreshBook) {
outState.putBoolean("isAutoRefreshedBook", true)
}
val readRoute = latestBackStack.lastOrNull() as? MainRouteReadBook
?: activeReadBookRoute
if (readRoute != null) {
outState.putBoolean(KEY_RESTORE_READ_ROUTE, true)
outState.putString(KEY_RESTORE_READ_BOOK_URL, readRoute.bookUrl)
outState.putBoolean(KEY_RESTORE_READ_ALOUD, readRoute.readAloud)
outState.putBoolean(KEY_RESTORE_READ_IN_BOOKSHELF, readRoute.inBookshelf)
outState.putBoolean(KEY_RESTORE_READ_CHAPTER_CHANGED, readRoute.chapterChanged)
}
}
private fun Bundle.restoreReadBookRoute(): MainRouteReadBook? {
if (!getBoolean(KEY_RESTORE_READ_ROUTE, false)) return null
return MainRouteReadBook(
bookUrl = getString(KEY_RESTORE_READ_BOOK_URL),
readAloud = getBoolean(KEY_RESTORE_READ_ALOUD, false),
inBookshelf = getBoolean(KEY_RESTORE_READ_IN_BOOKSHELF, true),
chapterChanged = getBoolean(KEY_RESTORE_READ_CHAPTER_CHANGED, false),
)
}
private fun Intent.hasExplicitStartRoute(): Boolean {
return hasExtra(MainIntent.EXTRA_START_ROUTE)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -438,18 +488,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
}
internal fun resolveInitialStartRoutes(
resolved: NavKey,
defaultToRead: Boolean,
isFreshCreate: Boolean,
): List<NavKey> {
return if (isFreshCreate && defaultToRead && resolved == MainRouteHome) {
listOf(MainRouteHome, MainRouteReadBook())
} else {
listOf(resolved)
}
}
class LauncherW : MainActivity()
class Launcher1 : MainActivity()
class Launcher2 : MainActivity()
@@ -310,6 +310,7 @@ fun MainActivity.mainEntryProvider(
DisposableEffect(controller, lifecycleOwner, route.readAloud) {
activeReadBookInputHandler = controller
activeReadBookRoute = route
MainActivity.hasActiveReadBookRoute = true
controller.onClose = { onNavigateBack() }
controller.onStartContentLoadFinish = {
@@ -335,6 +336,9 @@ fun MainActivity.mainEntryProvider(
if (activeReadBookInputHandler === controller) {
activeReadBookInputHandler = null
}
if (activeReadBookRoute == route) {
activeReadBookRoute = null
}
MainActivity.hasActiveReadBookRoute = false
controller.clearTts()
}
@@ -354,6 +358,7 @@ fun MainActivity.mainEntryProvider(
entry<MainRouteSearchContent> { route ->
val viewModel = koinViewModel<SearchContentViewModel>(
key = route.bookUrl,
parameters = { parametersOf(route) }
)
SearchContentScreen(