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 exportNoChapterName = "exportNoChapterName"
const val exportType = "exportType" const val exportType = "exportType"
const val exportPictureFile = "exportPictureFile" 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 chineseConverterType = "chineseConverterType"
const val launcherIcon = "launcherIcon" const val launcherIcon = "launcherIcon"
const val textSelectAble = "selectText" const val textSelectAble = "selectText"
@@ -24,4 +24,11 @@ object LocalPreferencesKeys {
val PASSWORD = stringPreferencesKey("password") val PASSWORD = stringPreferencesKey("password")
val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok") val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked") 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 -> matches.forEachIndexed { index, match ->
val construct = getResultAndQueryIndex(mContent, match.position, match.length) val construct = getResultAndQueryIndex(mContent, match.position, match.length)
val result = SearchResult( val result = SearchResult(
bookUrl = book.bookUrl,
resultCountWithinChapter = index, resultCountWithinChapter = index,
resultText = construct.second, resultText = construct.second,
chapterTitle = chapter.title, chapterTitle = chapter.title,
@@ -1,8 +1,8 @@
package io.legado.app.domain.model package io.legado.app.domain.model
import com.google.gson.GsonBuilder
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.splitNotBlank import io.legado.app.utils.splitNotBlank
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
data class BookSearchScope(val raw: String) { data class BookSearchScope(val raw: String) {
@@ -39,11 +39,13 @@ data class BookSearchScope(val raw: String) {
get() = groups.isEmpty() && sources.isEmpty() get() = groups.isEmpty() && sources.isEmpty()
} }
@Serializable
data class ScopeSourceItem( data class ScopeSourceItem(
val name: String, val name: String,
val url: String, val url: String,
) )
@Serializable
private data class SerializedSearchScope( private data class SerializedSearchScope(
val type: String = "", val type: String = "",
val groups: List<String> = emptyList(), val groups: List<String> = emptyList(),
@@ -57,7 +59,9 @@ data class BookSearchScope(val raw: String) {
return if (selected.isEmpty()) { return if (selected.isEmpty()) {
"" ""
} else { } 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()) { return if (selected.isEmpty()) {
"" ""
} else { } 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() val json = raw.trim()
if (!json.startsWith("{") || !json.endsWith("}")) return null 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) { when (scope.type) {
TYPE_SOURCE -> ParsedSearchScope( TYPE_SOURCE -> ParsedSearchScope(
sources = scope.sources.filter { it.url.isNotBlank() } 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_GROUP = "group"
private const val TYPE_SOURCE = "source" 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.ContentProcessor
import io.legado.app.help.book.primaryStr import io.legado.app.help.book.primaryStr
import io.legado.app.help.book.releaseHtmlData 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.help.source.SourceHelp
import io.legado.app.model.webBook.WebBook import io.legado.app.model.webBook.WebBook
import io.legado.app.ui.book.changesource.ObservableSourceConfig import io.legado.app.ui.book.changesource.ObservableSourceConfig
@@ -135,10 +135,10 @@ class ChangeSourceSearchUseCase(
fromReadBookActivity: Boolean, fromReadBookActivity: Boolean,
contentProcessor: ContentProcessor, contentProcessor: ContentProcessor,
): List<SearchBook> { ): List<SearchBook> {
val checkAuthor = AppConfig.changeSourceCheckAuthor val checkAuthor = ChangeSourceConfig.checkAuthor
val loadInfo = AppConfig.changeSourceLoadInfo val loadInfo = ChangeSourceConfig.loadInfo
val loadToc = AppConfig.changeSourceLoadToc val loadToc = ChangeSourceConfig.loadToc
val loadWordCount = AppConfig.changeSourceLoadWordCount val loadWordCount = ChangeSourceConfig.loadWordCount
val resultBooks = WebBook.searchBookAwait( val resultBooks = WebBook.searchBookAwait(
source, name, source, name,
@@ -465,12 +465,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.putPrefBoolean(PreferKey.parallelExportBook, value) appCtx.putPrefBoolean(PreferKey.parallelExportBook, value)
} }
var changeSourceCheckAuthor: Boolean
get() = appCtx.getPrefBoolean(PreferKey.changeSourceCheckAuthor)
set(value) {
appCtx.putPrefBoolean(PreferKey.changeSourceCheckAuthor, value)
}
var ttsEngine: String? var ttsEngine: String?
get() = io.legado.app.ui.config.readConfig.ReadConfig.ttsEngine get() = io.legado.app.ui.config.readConfig.ReadConfig.ttsEngine
set(value) { set(value) {
@@ -504,24 +498,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val autoChangeSource: Boolean val autoChangeSource: Boolean
get() = ReadConfig.autoChangeSource 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 var openBookInfoByClickTitle: Boolean
get() = appCtx.getPrefBoolean(PreferKey.openBookInfoByClickTitle, true) get() = appCtx.getPrefBoolean(PreferKey.openBookInfoByClickTitle, true)
set(value) { set(value) {
@@ -11,7 +11,6 @@ import io.legado.app.base.adapter.DiffRecyclerAdapter
import io.legado.app.base.adapter.ItemViewHolder import io.legado.app.base.adapter.ItemViewHolder
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.ItemChangeSourceBinding import io.legado.app.databinding.ItemChangeSourceBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
import io.legado.app.utils.gone import io.legado.app.utils.gone
import io.legado.app.utils.invisible import io.legado.app.utils.invisible
@@ -91,13 +90,13 @@ class ChangeBookSourceAdapter(
binding.ivGood.setImageResource(R.drawable.ic_praise) binding.ivGood.setImageResource(R.drawable.ic_praise)
} }
if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) { if (ChangeSourceConfig.loadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
tvCurrentChapterWordCount.visible() tvCurrentChapterWordCount.visible()
} else { } else {
tvCurrentChapterWordCount.gone() tvCurrentChapterWordCount.gone()
} }
if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) { if (ChangeSourceConfig.loadWordCount && item.respondTime >= 0) {
tvRespondTime.visible() tvRespondTime.visible()
} else { } else {
tvRespondTime.gone() tvRespondTime.gone()
@@ -227,11 +227,13 @@ class ChangeBookSourceComposeViewModel(
fun onLoadInfoChange(enabled: Boolean) { fun onLoadInfoChange(enabled: Boolean) {
if (ChangeSourceConfig.loadInfo == enabled) return if (ChangeSourceConfig.loadInfo == enabled) return
ChangeSourceConfig.loadInfo = enabled ChangeSourceConfig.loadInfo = enabled
refresh()
} }
fun onLoadTocChange(enabled: Boolean) { fun onLoadTocChange(enabled: Boolean) {
if (ChangeSourceConfig.loadToc == enabled) return if (ChangeSourceConfig.loadToc == enabled) return
ChangeSourceConfig.loadToc = enabled ChangeSourceConfig.loadToc = enabled
refresh()
} }
fun onLoadWordCountChange(enabled: Boolean) { 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.BookSource
import io.legado.app.data.entities.SearchBook import io.legado.app.data.entities.SearchBook
import io.legado.app.databinding.DialogBookChangeSourceBinding import io.legado.app.databinding.DialogBookChangeSourceBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
import io.legado.app.model.ReadBook import io.legado.app.model.ReadBook
import io.legado.app.ui.book.search.SearchScope 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.menu.applyTint(requireContext())
binding.toolBar.setOnMenuItemClickListener(this) binding.toolBar.setOnMenuItemClickListener(this)
binding.toolBar.menu.findItem(R.id.menu_check_author) binding.toolBar.menu.findItem(R.id.menu_check_author)
?.isChecked = AppConfig.changeSourceCheckAuthor ?.isChecked = ChangeSourceConfig.checkAuthor
binding.toolBar.menu.findItem(R.id.menu_load_info) binding.toolBar.menu.findItem(R.id.menu_load_info)
?.isChecked = AppConfig.changeSourceLoadInfo ?.isChecked = ChangeSourceConfig.loadInfo
binding.toolBar.menu.findItem(R.id.menu_load_toc) 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) binding.toolBar.menu.findItem(R.id.menu_load_word_count)
?.isChecked = AppConfig.changeSourceLoadWordCount ?.isChecked = ChangeSourceConfig.loadWordCount
} }
private fun initRecyclerView() { private fun initRecyclerView() {
@@ -316,23 +315,25 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
override fun onMenuItemClick(item: MenuItem?): Boolean { override fun onMenuItemClick(item: MenuItem?): Boolean {
when (item?.itemId) { when (item?.itemId) {
R.id.menu_check_author -> { R.id.menu_check_author -> {
AppConfig.changeSourceCheckAuthor = !item.isChecked ChangeSourceConfig.checkAuthor = !item.isChecked
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
viewModel.refresh() viewModel.refresh()
} }
R.id.menu_load_info -> { R.id.menu_load_info -> {
AppConfig.changeSourceLoadInfo = !item.isChecked ChangeSourceConfig.loadInfo = !item.isChecked
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
viewModel.refresh()
} }
R.id.menu_load_toc -> { R.id.menu_load_toc -> {
AppConfig.changeSourceLoadToc = !item.isChecked ChangeSourceConfig.loadToc = !item.isChecked
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
viewModel.refresh()
} }
R.id.menu_load_word_count -> { R.id.menu_load_word_count -> {
AppConfig.changeSourceLoadWordCount = !item.isChecked ChangeSourceConfig.loadWordCount = !item.isChecked
item.isChecked = !item.isChecked item.isChecked = !item.isChecked
viewModel.onLoadWordCountChecked(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.ContentProcessor
import io.legado.app.help.book.primaryStr import io.legado.app.help.book.primaryStr
import io.legado.app.help.book.releaseHtmlData 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.config.SourceConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.source.SourceHelp import io.legado.app.help.source.SourceHelp
@@ -137,7 +136,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
} }
}.map { }.map {
kotlin.runCatching { kotlin.runCatching {
val comparator = if (AppConfig.changeSourceLoadWordCount) { val comparator = if (ChangeSourceConfig.loadWordCount) {
wordCountComparator wordCountComparator
} else { } else {
defaultComparator defaultComparator
@@ -269,10 +268,10 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
} }
private suspend fun search(source: BookSource) { private suspend fun search(source: BookSource) {
val checkAuthor = AppConfig.changeSourceCheckAuthor val checkAuthor = ChangeSourceConfig.checkAuthor
val loadInfo = AppConfig.changeSourceLoadInfo val loadInfo = ChangeSourceConfig.loadInfo
val loadToc = AppConfig.changeSourceLoadToc val loadToc = ChangeSourceConfig.loadToc
val loadWordCount = AppConfig.changeSourceLoadWordCount val loadWordCount = ChangeSourceConfig.loadWordCount
val resultBooks = WebBook.searchBookAwait( val resultBooks = WebBook.searchBookAwait(
source, name, source, name,
filter = { fName, fAuthor, _ -> filter = { fName, fAuthor, _ ->
@@ -295,7 +294,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
if (book.tocUrl.isEmpty()) { if (book.tocUrl.isEmpty()) {
WebBook.getBookInfoAwait(source, book) WebBook.getBookInfoAwait(source, book)
} }
if (AppConfig.changeSourceLoadToc || AppConfig.changeSourceLoadWordCount) { if (ChangeSourceConfig.loadToc || ChangeSourceConfig.loadWordCount) {
loadBookToc(source, book) loadBookToc(source, book)
} else { } else {
//从详情页里获取最新章节 //从详情页里获取最新章节
@@ -315,7 +314,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
} }
bookMap[book.primaryStr()] = book bookMap[book.primaryStr()] = book
book.releaseHtmlData() book.releaseHtmlData()
if (AppConfig.changeSourceLoadWordCount) { if (ChangeSourceConfig.loadWordCount) {
loadBookWordCount(source, book, chapters) loadBookWordCount(source, book, chapters)
} else { } else {
val searchBook = book.toSearchBook() val searchBook = book.toSearchBook()
@@ -418,13 +417,13 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
else -> searchScope.displayNames.firstOrNull() ?: "" else -> searchScope.displayNames.firstOrNull() ?: ""
} }
return if (screenKey.isEmpty()) { return if (screenKey.isEmpty()) {
if (AppConfig.changeSourceCheckAuthor) { if (ChangeSourceConfig.checkAuthor) {
appDb.searchBookDao.changeSourceByGroup(name, author, group) appDb.searchBookDao.changeSourceByGroup(name, author, group)
} else { } else {
appDb.searchBookDao.changeSourceByGroup(name, "", group) appDb.searchBookDao.changeSourceByGroup(name, "", group)
} }
} else { } else {
if (AppConfig.changeSourceCheckAuthor) { if (ChangeSourceConfig.checkAuthor) {
appDb.searchBookDao.changeSourceSearch(name, author, screenKey, group) appDb.searchBookDao.changeSourceSearch(name, author, screenKey, group)
} else { } else {
appDb.searchBookDao.changeSourceSearch(name, "", screenKey, group) appDb.searchBookDao.changeSourceSearch(name, "", screenKey, group)
@@ -133,11 +133,13 @@ class ChangeChapterSourceViewModel(
is ChangeChapterSourceIntent.SetLoadInfo -> { is ChangeChapterSourceIntent.SetLoadInfo -> {
ChangeSourceConfig.loadInfo = intent.enabled ChangeSourceConfig.loadInfo = intent.enabled
_uiState.update { it.copy(loadInfo = intent.enabled) } _uiState.update { it.copy(loadInfo = intent.enabled) }
refreshResults()
} }
is ChangeChapterSourceIntent.SetLoadToc -> { is ChangeChapterSourceIntent.SetLoadToc -> {
ChangeSourceConfig.loadToc = intent.enabled ChangeSourceConfig.loadToc = intent.enabled
_uiState.update { it.copy(loadToc = intent.enabled) } _uiState.update { it.copy(loadToc = intent.enabled) }
refreshResults()
} }
is ChangeChapterSourceIntent.SetLoadWordCount -> { is ChangeChapterSourceIntent.SetLoadWordCount -> {
@@ -1,35 +1,83 @@
package io.legado.app.ui.book.changesource 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.domain.usecase.ChangeSourceMigrationOptions
import io.legado.app.ui.config.prefDelegate 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 { object ChangeSourceConfig {
var searchScope by prefDelegate( private val repo = LocalPreferencesRepository(appCtx)
key = "changeSourceSearchScope", private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
defaultValue = ""
)
var checkAuthor by prefDelegate( private val _searchScope = mutableStateOf("")
key = PreferKey.changeSourceCheckAuthor, var searchScope: String
defaultValue = false 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( private val _checkAuthor = mutableStateOf(false)
key = PreferKey.changeSourceLoadInfo, var checkAuthor: Boolean
defaultValue = false 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( private val _loadInfo = mutableStateOf(false)
key = PreferKey.changeSourceLoadToc, var loadInfo: Boolean
defaultValue = false 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( private val _loadToc = mutableStateOf(false)
key = PreferKey.changeSourceLoadWordCount, var loadToc: Boolean
defaultValue = false 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( var migrateChapters by prefDelegate(
key = "migrateChapters", key = "migrateChapters",
@@ -64,6 +112,44 @@ object ChangeSourceConfig {
defaultValue = false 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 { fun getMigrationOptions(): ChangeSourceMigrationOptions {
return ChangeSourceMigrationOptions( return ChangeSourceMigrationOptions(
migrateChapters = migrateChapters, migrateChapters = migrateChapters,
@@ -40,6 +40,7 @@ sealed interface ReadBookMenuRoute {
data object ReadAloud : ReadBookMenuRoute data object ReadAloud : ReadBookMenuRoute
data object AutoRead : ReadBookMenuRoute data object AutoRead : ReadBookMenuRoute
data object PaddingConfig : ReadBookMenuRoute data object PaddingConfig : ReadBookMenuRoute
data object HeaderFooterConfig : ReadBookMenuRoute
data class Bookmark(val bookmark: io.legado.app.data.entities.Bookmark) : 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 // Chapter not loaded — open it, then mark in the success callback
ReadBook.openChapter( ReadBook.openChapter(
effect.chapterIndex, effect.chapterIndex,
effect.result.queryIndexInChapter 0
) { ) {
val tc = ReadBook.curTextChapter ?: return@openChapter val tc = ReadBook.curTextChapter ?: return@openChapter
val query = effect.result.query 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.DampedDragAnimation
import io.legado.app.ui.animation.InteractiveHighlight import io.legado.app.ui.animation.InteractiveHighlight
import io.legado.app.ui.book.read.sheet.AutoReadContent 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.PaddingConfigContent
import io.legado.app.ui.book.read.sheet.ReadAloudContent import io.legado.app.ui.book.read.sheet.ReadAloudContent
import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo
@@ -186,7 +187,7 @@ fun ReadBookMenuBar(
} else { } else {
ReadBookMenuContent.Route(currentRoute) ReadBookMenuContent.Route(currentRoute)
} }
val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig || currentRoute == ReadBookMenuRoute.HeaderFooterConfig
var readStylePage by remember { mutableIntStateOf(0) } var readStylePage by remember { mutableIntStateOf(0) }
LaunchedEffect(currentRoute) { LaunchedEffect(currentRoute) {
if (currentRoute != ReadBookMenuRoute.ReadStyle) { if (currentRoute != ReadBookMenuRoute.ReadStyle) {
@@ -392,7 +393,7 @@ private fun ReadBookMenuSurface(
is ReadBookMenuContent.Route -> contentTarget.route is ReadBookMenuContent.Route -> contentTarget.route
} }
val expanded = route != ReadBookMenuRoute.Main val expanded = route != ReadBookMenuRoute.Main
val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig || route == ReadBookMenuRoute.HeaderFooterConfig
val density = LocalDensity.current val density = LocalDensity.current
val windowSize = LocalWindowInfo.current.containerSize val windowSize = LocalWindowInfo.current.containerSize
var surfaceHeightPx by remember { mutableIntStateOf(0) } var surfaceHeightPx by remember { mutableIntStateOf(0) }
@@ -400,18 +401,6 @@ private fun ReadBookMenuSurface(
targetValue = if (dialogLikeRoute) 1f else 0f, targetValue = if (dialogLikeRoute) 1f else 0f,
label = "ReadBookMenuMorph", 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) { val maxHeight = with(density) {
windowSize.height.toDp() * 0.64f windowSize.height.toDp() * 0.64f
} }
@@ -518,8 +507,7 @@ private fun ReadBookMenuSurface(
.onSizeChanged { surfaceHeightPx = it.height } .onSizeChanged { surfaceHeightPx = it.height }
.offset { .offset {
val dialogLiftPx = ((windowSize.height - surfaceHeightPx) / 2f) * morphProgress val dialogLiftPx = ((windowSize.height - surfaceHeightPx) / 2f) * morphProgress
val liftPx = dialogLiftPx + headerFooterLift.toPx() IntOffset(x = 0, y = -dialogLiftPx.roundToInt())
IntOffset(x = 0, y = -liftPx.roundToInt())
} }
.then( .then(
if (useLiquidGlass) { if (useLiquidGlass) {
@@ -620,6 +608,9 @@ private fun ReadBookMenuSurface(
onOpenPaddingConfig = { onOpenPaddingConfig = {
onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.PaddingConfig)) onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.PaddingConfig))
}, },
onOpenHeaderFooterConfig = {
onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.HeaderFooterConfig))
},
onOpenMoreConfig = { onOpenMoreConfig = {
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.MoreConfig)) 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 -> { ReadBookMenuRoute.TextTitle -> {
ReadBookMenuRoutePage( ReadBookMenuRoutePage(
title = stringResource(R.string.read_config_text_effects), 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.AppLog
import io.legado.app.constant.ReadMenuBlurMode import io.legado.app.constant.ReadMenuBlurMode
import io.legado.app.help.IntentHelp 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.info.BookInfoActivity
import io.legado.app.ui.book.read.page.ContentTextView import io.legado.app.ui.book.read.page.ContentTextView
import io.legado.app.ui.book.read.page.ReadView import io.legado.app.ui.book.read.page.ReadView
@@ -383,6 +384,10 @@ fun ReadBookRouteScreen(
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
SearchContentResult.results.collect { result -> SearchContentResult.results.collect { result ->
effectsReady.await() effectsReady.await()
if (result.bookUrl != ReadBook.book?.bookUrl) {
SearchContentResult.resetReplayCache()
return@collect
}
viewModel.onIntent( viewModel.onIntent(
ReadBookIntent.SetSearchResults(result.searchResults, result.index, result.query) ReadBookIntent.SetSearchResults(result.searchResults, result.index, result.query)
) )
@@ -2241,50 +2241,21 @@ class ReadBookViewModel(
return arrayOf(-1, 0, 0, 0, 0, 0) return arrayOf(-1, 0, 0, 0, 0, 0)
} }
var pageIndex = 0 val start = findSearchTextPoint(pages, contentPosition, preferPreviousLine = false)
var length = pages[pageIndex].text.length ?: return arrayOf(-1, 0, 0, 0, 0, 0)
while (length < contentPosition && pageIndex + 1 < pages.size) { val end = findSearchTextPoint(
pageIndex += 1 pages,
length += pages[pageIndex].text.length contentPosition + queryLength - 1,
} preferPreviousLine = true
if (length < contentPosition) return arrayOf(-1, 0, 0, 0, 0, 0) ) ?: start
return arrayOf(
val currentPage = pages[pageIndex] start.pageIndex,
val lines = currentPage.lines start.lineIndex,
if (lines.isEmpty()) return arrayOf(-1, 0, 0, 0, 0, 0) start.charIndex,
end.pageIndex - start.pageIndex,
var lineIndex = 0 end.lineIndex,
var currentLine = lines[lineIndex] end.charIndex
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)
}
} }
private fun findSearchResultMatch( private fun findSearchResultMatch(
@@ -2293,6 +2264,26 @@ class ReadBookViewModel(
query: String, query: String,
): Pair<Int, Int>? { ): Pair<Int, Int>? {
if (query.isEmpty()) return null 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) { if (searchResult.isRegex) {
return runCatching { return runCatching {
Regex(query).findAll(content) Regex(query).findAll(content)
@@ -2311,6 +2302,43 @@ class ReadBookViewModel(
return index.takeIf { it >= 0 }?.let { it to query.length } 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] * Compute the search result position and emit [ReadBookEffect.NavigateToSearchResult]
* so the Controller can navigate and highlight. * 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 TOOL_BUTTON_KEY = "tool_buttons"
private const val DEFAULT_ENABLED_BUTTON_COUNT = 5 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 { private fun Int.coerceSearchResultIndex(resultSize: Int): Int {
return if (resultSize <= 0) 0 else coerceIn(0, resultSize - 1) return if (resultSize <= 0) 0 else coerceIn(0, resultSize - 1)
} }
@@ -1,25 +1,35 @@
package io.legado.app.ui.book.read.sheet package io.legado.app.ui.book.read.sheet
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.material.icons.filled.TextFields
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -32,11 +42,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.help.config.ReadBookConfig
import io.legado.app.ui.book.read.ConfigUpdate import io.legado.app.ui.book.read.ConfigUpdate
import io.legado.app.ui.book.read.ReadBookIntent 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.FontSelectSheet
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
@@ -106,6 +115,42 @@ internal fun HeaderFooterPage(
var colorPickerInitial by remember { mutableIntStateOf(0) } var colorPickerInitial by remember { mutableIntStateOf(0) }
var showFontSelect by remember { mutableStateOf(false) } 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 tipNames = remember { ReadBookConfig.tipNames }
val tipValues = remember { ReadBookConfig.tipValues } val tipValues = remember { ReadBookConfig.tipValues }
@@ -153,7 +198,7 @@ internal fun HeaderFooterPage(
scope.launch { scope.launch {
pagerState.animateScrollToPage( pagerState.animateScrollToPage(
page = index, page = index,
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
) )
} }
}, },
@@ -181,7 +226,7 @@ internal fun HeaderFooterPage(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(headerScrollState),
) { ) {
TinySwitchSettingItem( TinySwitchSettingItem(
title = stringResource(R.string.showLine), title = stringResource(R.string.showLine),
@@ -252,6 +297,56 @@ internal fun HeaderFooterPage(
showColorPicker = true 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(footerScrollState),
) { ) {
TinySwitchSettingItem( TinySwitchSettingItem(
title = stringResource(R.string.showLine), title = stringResource(R.string.showLine),
@@ -332,6 +427,56 @@ internal fun HeaderFooterPage(
showColorPicker = true 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.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.book.read.ReadBookIntent
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet 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.settingItem.TinySliderSettingItem
import io.legado.app.ui.widget.components.tabRow.CardTabRow
import kotlinx.coroutines.launch
@Composable @Composable
fun PaddingConfigSheet( fun PaddingConfigSheet(
@@ -58,122 +49,35 @@ fun PaddingConfigContent(
var paddingBottom by remember { mutableFloatStateOf(ReadBookConfig.paddingBottom.toFloat()) } var paddingBottom by remember { mutableFloatStateOf(ReadBookConfig.paddingBottom.toFloat()) }
var paddingLeft by remember { mutableFloatStateOf(ReadBookConfig.paddingLeft.toFloat()) } var paddingLeft by remember { mutableFloatStateOf(ReadBookConfig.paddingLeft.toFloat()) }
var paddingRight by remember { mutableFloatStateOf(ReadBookConfig.paddingRight.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( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
CardTabRow( PaddingSliders(
tabTitles = tabTitles, top = paddingTop, bottom = paddingBottom,
selectedTabIndex = selectedTab, left = paddingLeft, right = paddingRight,
onTabSelected = { index -> onTopChange = {
selectedTab = index paddingTop = it
scope.launch { pagerState.animateScrollToPage(index) } 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 @Composable
private fun PaddingSliders( internal fun PaddingSliders(
top: Float, bottom: Float, left: Float, right: Float, top: Float, bottom: Float, left: Float, right: Float,
onTopChange: (Float) -> Unit, onBottomChange: (Float) -> Unit, onTopChange: (Float) -> Unit, onBottomChange: (Float) -> Unit,
onLeftChange: (Float) -> Unit, onRightChange: (Float) -> Unit, onLeftChange: (Float) -> Unit, onRightChange: (Float) -> Unit,
@@ -38,6 +38,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun ReadStyleContent( fun ReadStyleContent(
onOpenPaddingConfig: () -> Unit, onOpenPaddingConfig: () -> Unit,
onOpenHeaderFooterConfig: () -> Unit,
onOpenMoreConfig: () -> Unit, onOpenMoreConfig: () -> Unit,
onOpenBgTextConfig: (Int) -> Unit, onOpenBgTextConfig: (Int) -> Unit,
onOpenTextTitle: () -> Unit, onOpenTextTitle: () -> Unit,
@@ -51,7 +52,7 @@ fun ReadStyleContent(
styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(), styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { 3 }) val pagerState = rememberPagerState(pageCount = { 2 })
var currentPage by remember { mutableIntStateOf(0) } var currentPage by remember { mutableIntStateOf(0) }
val pageHeights = remember { mutableStateMapOf<Int, Int>() } val pageHeights = remember { mutableStateMapOf<Int, Int>() }
@@ -105,10 +106,6 @@ fun ReadStyleContent(
bottomBarButtons = bottomBarButtons, bottomBarButtons = bottomBarButtons,
onIntent = onIntent, onIntent = onIntent,
) )
2 -> HeaderFooterPage(
onIntent = onIntent,
)
} }
} }
} }
@@ -123,15 +120,17 @@ fun ReadStyleContent(
tabTitles = tabTitles, tabTitles = tabTitles,
selectedTabIndex = currentPage, selectedTabIndex = currentPage,
onTabSelected = { index -> onTabSelected = { index ->
if (index < 3) { when (index) {
scope.launch { 0, 1 -> {
pagerState.animateScrollToPage( scope.launch {
page = index, pagerState.animateScrollToPage(
animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing) page = index,
) animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)
}
} }
} else { 2 -> onOpenHeaderFooterConfig()
onOpenMoreConfig() 3 -> onOpenMoreConfig()
} }
}, },
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
@@ -128,7 +128,7 @@ internal fun SystemMenuPage(
scope.launch { scope.launch {
pagerState.animateScrollToPage( pagerState.animateScrollToPage(
page = index, 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.data.entities.BookSourcePart
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.SearchBar 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.button.series.MediumPlainButton
import io.legado.app.ui.widget.components.card.SelectionItemCard import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.icon.AppIcons import io.legado.app.ui.widget.components.icon.AppIcons
@@ -46,9 +47,20 @@ fun ScopeSelectSheet(
isSourceScope: Boolean = false, isSourceScope: Boolean = false,
title: String = stringResource(R.string.search_select_group), title: String = stringResource(R.string.search_select_group),
onConfirm: (() -> Unit)? = null, onConfirm: (() -> Unit)? = null,
onApplyScope: ((ScopeSelection) -> Unit)? = null,
) { ) {
var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) } var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) }
var filterText by rememberSaveable(show) { mutableStateOf("") } 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) { val filteredGroups = remember(groups, filterText) {
if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) } if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) }
@@ -87,11 +99,18 @@ fun ScopeSelectSheet(
SelectionItemCard( SelectionItemCard(
title = stringResource(R.string.all_source), title = stringResource(R.string.all_source),
isSelected = isAll, isSelected = currentIsAll,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true, inSelectionMode = true,
onToggleSelection = { 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) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(filteredGroups, key = { it }) { groupName -> items(filteredGroups, key = { it }) { groupName ->
val selected = !isSourceScope && selectedGroups.contains(groupName) val selected = !currentIsSourceScope && currentGroups.contains(groupName)
SelectionItemCard( SelectionItemCard(
title = groupName, title = groupName,
isSelected = selected, isSelected = selected,
containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f), containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
inSelectionMode = true, inSelectionMode = true,
onToggleSelection = { 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) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
items(filteredSources, key = { it.bookSourceUrl }) { source -> items(filteredSources, key = { it.bookSourceUrl }) { source ->
val selected = selectedSources.contains(source.bookSourceUrl) val selected = currentSourceUrls.contains(source.bookSourceUrl)
SelectionItemCard( SelectionItemCard(
title = source.bookSourceName, title = source.bookSourceName,
subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() }, subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
@@ -150,7 +182,20 @@ fun ScopeSelectSheet(
isSelected = selected, isSelected = selected,
inSelectionMode = true, inSelectionMode = true,
onToggleSelection = { 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)) 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 class ToggleSourceType(val type: Int) : SearchIntent
data object ClearAllSourceTypes : SearchIntent data object ClearAllSourceTypes : SearchIntent
data object SelectAllScope : 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 ToggleScopeGroup(val groupName: String) : SearchIntent
data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
data class RemoveScopeItem(val scopeName: String) : SearchIntent data class RemoveScopeItem(val scopeName: String) : SearchIntent
@@ -70,7 +70,7 @@ data class SearchScope(private var scope: String) {
if (scope.isEmpty()) { if (scope.isEmpty()) {
return appCtx.getString(R.string.all_source) return appCtx.getString(R.string.all_source)
} }
return scope return parsedScope().groupNames.joinToString(",")
} }
/** /**
@@ -112,6 +112,7 @@ fun SearchScreen(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
var queryInput by rememberSaveable { mutableStateOf(state.query) } var queryInput by rememberSaveable { mutableStateOf(state.query) }
var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) } var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
var keepResultsPinnedToTop by rememberSaveable { mutableStateOf(true) }
val showSuggestionPanel = state.showSuggestions val showSuggestionPanel = state.showSuggestions
val latestQuery by rememberUpdatedState(state.query) val latestQuery by rememberUpdatedState(state.query)
val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { 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 submitSearch: (String) -> Unit = { rawQuery ->
val normalized = rawQuery.trim() val normalized = rawQuery.trim()
if (normalized.isNotBlank()) { if (normalized.isNotBlank()) {
@@ -611,6 +643,15 @@ fun SearchScreen(
onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) }, onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
isSourceScope = state.isSourceScope, isSourceScope = state.isSourceScope,
onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) }, onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
onApplyScope = { selection ->
viewModel.onIntent(
SearchIntent.ApplyScopeSelection(
groupNames = selection.groupNames,
sources = selection.sources,
isSourceScope = selection.isSourceScope,
)
)
},
) )
AppModalBottomSheet( AppModalBottomSheet(
@@ -211,6 +211,7 @@ class SearchViewModel(
syncScopeState(restartSearch = true, oldScope = oldScope) syncScopeState(restartSearch = true, oldScope = oldScope)
} }
is SearchIntent.ApplyScopeSelection -> applyScopeSelection(intent)
is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName) is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName)
is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source) is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source)
is SearchIntent.RemoveScopeItem -> { is SearchIntent.RemoveScopeItem -> {
@@ -221,6 +222,7 @@ class SearchViewModel(
} }
is SearchIntent.SetMatchMode -> { is SearchIntent.SetMatchMode -> {
_uiState.update { it.copy(matchMode = intent.mode) }
viewModelScope.launch { viewModelScope.launch {
localPreferencesRepository.updatePreference( localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, intent.mode.value LocalPreferencesKeys.MATCH_MODE, intent.mode.value
@@ -427,7 +429,6 @@ class SearchViewModel(
it.copy( it.copy(
query = query, query = query,
showSuggestions = showSuggestions, showSuggestions = showSuggestions,
isManualStop = false,
emptyScopeAction = null, emptyScopeAction = null,
) )
} }
@@ -657,6 +658,7 @@ class SearchViewModel(
_uiState.update { it.copy(emptyScopeAction = null) } _uiState.update { it.copy(emptyScopeAction = null) }
if (action.wasMatchMode == MatchMode.EXACT) { if (action.wasMatchMode == MatchMode.EXACT) {
_uiState.update { it.copy(matchMode = MatchMode.DEFAULT) }
viewModelScope.launch { viewModelScope.launch {
localPreferencesRepository.updatePreference( localPreferencesRepository.updatePreference(
LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
@@ -672,12 +674,29 @@ class SearchViewModel(
} }
private fun restartCommittedSearchIfNeeded() { private fun restartCommittedSearchIfNeeded() {
val committed = _uiState.value.committedQuery val state = _uiState.value
if (committed.isNotBlank()) { val committed = state.committedQuery
if (
committed.isNotBlank() &&
state.query.trim() == committed &&
!state.showSuggestions &&
!state.isManualStop
) {
submitSearch(committed) 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( private fun syncScopeState(
restartSearch: Boolean = false, restartSearch: Boolean = false,
oldScope: String? = null, oldScope: String? = null,
@@ -1,5 +1,6 @@
package io.legado.app.ui.book.searchContent package io.legado.app.ui.book.searchContent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow
object SearchContentResult { object SearchContentResult {
data class Result( data class Result(
val bookUrl: String,
val searchResults: List<SearchResult>, val searchResults: List<SearchResult>,
val index: Int, val index: Int,
val query: String, val query: String,
@@ -25,6 +27,7 @@ object SearchContentResult {
_results.tryEmit(result) _results.tryEmit(result)
} }
@OptIn(ExperimentalCoroutinesApi::class)
fun resetReplayCache() { fun resetReplayCache() {
_results.resetReplayCache() _results.resetReplayCache()
} }
@@ -195,6 +195,7 @@ class SearchContentViewModel(
if (index < 0) return false if (index < 0) return false
SearchContentResult.emitResult( SearchContentResult.emitResult(
SearchContentResult.Result( SearchContentResult.Result(
bookUrl = bookUrl,
searchResults = results, searchResults = results,
index = index, index = index,
query = searchResult.query query = searchResult.query
@@ -11,6 +11,7 @@ import android.text.style.UnderlineSpan
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
data class SearchResult( data class SearchResult(
val bookUrl: String = "",
val resultCount: Int = 0, val resultCount: Int = 0,
val resultCountWithinChapter: Int = 0, val resultCountWithinChapter: Int = 0,
val resultText: String = "", val resultText: String = "",
@@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
@@ -67,6 +68,12 @@ import kotlin.coroutines.suspendCoroutine
open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
companion object { 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 @Volatile
var hasActiveReadBookRoute: Boolean = false var hasActiveReadBookRoute: Boolean = false
@@ -143,10 +150,15 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1) private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null
private var shouldApplyDefaultToRead = true private var shouldApplyDefaultToRead = true
private var restoredReadBookRoute: MainRouteReadBook? = null
private var latestBackStack: List<NavKey> = emptyList()
internal var activeReadBookInputHandler: ReadBookInputHandler? = null internal var activeReadBookInputHandler: ReadBookInputHandler? = null
internal var activeReadBookRoute: MainRouteReadBook? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() installSplashScreen()
shouldApplyDefaultToRead = savedInstanceState == null shouldApplyDefaultToRead = savedInstanceState == null
restoredReadBookRoute = savedInstanceState?.restoreReadBookRoute()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (checkStartupRoute()) return if (checkStartupRoute()) return
@@ -175,6 +187,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
if (!intent.hasExplicitStartRoute()) return
routeEvents.tryEmit(MainNavigator.resolveStartRoute(intent)) routeEvents.tryEmit(MainNavigator.resolveStartRoute(intent))
} }
@@ -194,12 +207,21 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
} }
val startRoutes = remember { val startRoutes = remember {
resolveInitialStartRoutes( val resolved = MainNavigator.resolveStartRoute(intent)
resolved = MainNavigator.resolveStartRoute(intent), val hasExplicitStartRoute = intent?.hasExplicitStartRoute() == true
defaultToRead = OtherConfig.defaultToRead, when {
isFreshCreate = shouldApplyDefaultToRead, !hasExplicitStartRoute && restoredReadBookRoute != null -> {
).toTypedArray() arrayOf(MainRouteHome, restoredReadBookRoute!!)
}
shouldApplyDefaultToRead && OtherConfig.defaultToRead && resolved == MainRouteHome -> {
arrayOf(MainRouteHome, MainRouteReadBook())
}
else -> {
arrayOf(resolved)
}
}
} }
latestBackStack = startRoutes.toList()
val backStack = rememberNavBackStack(*startRoutes) val backStack = rememberNavBackStack(*startRoutes)
SideEffect { SideEffect {
@@ -212,6 +234,11 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
} }
} }
LaunchedEffect(backStack) {
snapshotFlow { backStack.toList() }
.collect { latestBackStack = it }
}
SharedTransitionLayout { SharedTransitionLayout {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
@@ -374,6 +401,29 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
if (AppConfig.autoRefreshBook) { if (AppConfig.autoRefreshBook) {
outState.putBoolean("isAutoRefreshedBook", true) 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 { 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 LauncherW : MainActivity()
class Launcher1 : MainActivity() class Launcher1 : MainActivity()
class Launcher2 : MainActivity() class Launcher2 : MainActivity()
@@ -310,6 +310,7 @@ fun MainActivity.mainEntryProvider(
DisposableEffect(controller, lifecycleOwner, route.readAloud) { DisposableEffect(controller, lifecycleOwner, route.readAloud) {
activeReadBookInputHandler = controller activeReadBookInputHandler = controller
activeReadBookRoute = route
MainActivity.hasActiveReadBookRoute = true MainActivity.hasActiveReadBookRoute = true
controller.onClose = { onNavigateBack() } controller.onClose = { onNavigateBack() }
controller.onStartContentLoadFinish = { controller.onStartContentLoadFinish = {
@@ -335,6 +336,9 @@ fun MainActivity.mainEntryProvider(
if (activeReadBookInputHandler === controller) { if (activeReadBookInputHandler === controller) {
activeReadBookInputHandler = null activeReadBookInputHandler = null
} }
if (activeReadBookRoute == route) {
activeReadBookRoute = null
}
MainActivity.hasActiveReadBookRoute = false MainActivity.hasActiveReadBookRoute = false
controller.clearTts() controller.clearTts()
} }
@@ -354,6 +358,7 @@ fun MainActivity.mainEntryProvider(
entry<MainRouteSearchContent> { route -> entry<MainRouteSearchContent> { route ->
val viewModel = koinViewModel<SearchContentViewModel>( val viewModel = koinViewModel<SearchContentViewModel>(
key = route.bookUrl,
parameters = { parametersOf(route) } parameters = { parametersOf(route) }
) )
SearchContentScreen( SearchContentScreen(
@@ -352,6 +352,7 @@
<string name="padding_bottom">下边距</string> <string name="padding_bottom">下边距</string>
<string name="padding_left">左边距</string> <string name="padding_left">左边距</string>
<string name="padding_right">右边距</string> <string name="padding_right">右边距</string>
<string name="padding_format">上: %1$d 下: %2$d 左: %3$d 右: %4$d</string>
<string name="check_book_source">校验书源</string> <string name="check_book_source">校验书源</string>
<string name="check_select_source">校验所选</string> <string name="check_select_source">校验所选</string>
<string name="progress_show">%1$s 进度 %2$d/%3$d</string> <string name="progress_show">%1$s 进度 %2$d/%3$d</string>
@@ -299,6 +299,7 @@
<string name="padding_bottom">下邊距</string> <string name="padding_bottom">下邊距</string>
<string name="padding_left">左邊距</string> <string name="padding_left">左邊距</string>
<string name="padding_right">右邊距</string> <string name="padding_right">右邊距</string>
<string name="padding_format">上: %1$d 下: %2$d 左: %3$d 右: %4$d</string>
<string name="check_book_source">校驗書源</string> <string name="check_book_source">校驗書源</string>
<string name="check_select_source">校驗所選</string> <string name="check_select_source">校驗所選</string>
<string name="progress_show">%1$s 進度 %2$d/%3$d</string> <string name="progress_show">%1$s 進度 %2$d/%3$d</string>
@@ -301,6 +301,7 @@
<string name="padding_bottom">下邊距</string> <string name="padding_bottom">下邊距</string>
<string name="padding_left">左邊距</string> <string name="padding_left">左邊距</string>
<string name="padding_right">右邊距</string> <string name="padding_right">右邊距</string>
<string name="padding_format">上: %1$d 下: %2$d 左: %3$d 右: %4$d</string>
<string name="check_book_source">校驗書源</string> <string name="check_book_source">校驗書源</string>
<string name="check_select_source">校驗所選</string> <string name="check_select_source">校驗所選</string>
<string name="progress_show">%1$s 進度 %2$d/%3$d</string> <string name="progress_show">%1$s 進度 %2$d/%3$d</string>
+1
View File
@@ -373,6 +373,7 @@
<string name="padding_bottom">Bottom</string> <string name="padding_bottom">Bottom</string>
<string name="padding_left">Left</string> <string name="padding_left">Left</string>
<string name="padding_right">Right</string> <string name="padding_right">Right</string>
<string name="padding_format">Top: %1$d Bottom: %2$d Left: %3$d Right: %4$d</string>
<string name="check_book_source">Check book sources</string> <string name="check_book_source">Check book sources</string>
<string name="check_select_source">Check the selected source</string> <string name="check_select_source">Check the selected source</string>
<string name="progress_show">%1$s Progress %2$d/%3$d</string> <string name="progress_show">%1$s Progress %2$d/%3$d</string>