[新增] 使用Compose重写的正文搜索界面,也许存在不稳定情况

This commit is contained in:
HapeLee
2025-12-28 04:39:51 +08:00
parent 4ae46386e0
commit 7849274fad
9 changed files with 666 additions and 372 deletions
@@ -0,0 +1,12 @@
package io.legado.app.data.repository
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
class BookRepository {
suspend fun getBook(bookUrl: String): Book? {
return appDb.bookDao.getBook(bookUrl)
}
}
@@ -0,0 +1,143 @@
package io.legado.app.data.repository
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.book.isLocal
import io.legado.app.help.config.AppConfig
import io.legado.app.ui.book.searchContent.SearchResult
import io.legado.app.utils.ChineseUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
class SearchContentRepository {
fun search(book: Book, query: String, replaceEnabled: Boolean, regexReplace: Boolean): Flow<List<SearchResult>> = flow {
val chapters = appDb.bookChapterDao.getChapterList(book.bookUrl)
val totalChapters = chapters.size
val contentProcessor = ContentProcessor.get(book.name, book.origin)
val cacheChapterNames = BookHelp.getChapterFiles(book).toHashSet()
val allResults = mutableListOf<SearchResult>()
var lastEmitTime = System.currentTimeMillis()
for (bookChapter in chapters) {
currentCoroutineContext().ensureActive()
if (book.isLocal || cacheChapterNames.contains(bookChapter.getFileName())) {
val chapterResults = searchChapter(
query,
book,
bookChapter,
contentProcessor,
replaceEnabled,
regexReplace
).map {
if (totalChapters > 0) {
it.copy(progressPercent = (bookChapter.index + 1).toFloat() / totalChapters * 100f)
} else {
it
}
}
if (chapterResults.isNotEmpty()) {
allResults.addAll(chapterResults)
val now = System.currentTimeMillis()
if (now - lastEmitTime > 350L) {
emit(ArrayList(allResults))
lastEmitTime = now
}
}
}
}
emit(ArrayList(allResults))
}.flowOn(Dispatchers.Default)
private suspend fun searchChapter(
query: String,
book: Book,
chapter: BookChapter,
contentProcessor: ContentProcessor,
replaceEnabled: Boolean,
regexReplace: Boolean
): List<SearchResult> {
val searchResultsWithinChapter: MutableList<SearchResult> = mutableListOf()
val chapterContent = BookHelp.getContent(book, chapter) ?: return searchResultsWithinChapter
chapter.title = when (AppConfig.chineseConverterType) {
1 -> ChineseUtils.t2s(chapter.title)
2 -> ChineseUtils.s2t(chapter.title)
else -> chapter.title
}
val mContent = contentProcessor.getContent(
book, chapter, chapterContent, useReplace = replaceEnabled
).toString()
val positions = searchPosition(mContent, query, regexReplace)
positions.forEachIndexed { index, position ->
val construct = getResultAndQueryIndex(mContent, position, query)
val result = SearchResult(
resultCountWithinChapter = index,
resultText = construct.second,
chapterTitle = chapter.title,
query = query,
chapterIndex = chapter.index,
queryIndexInResult = construct.first,
queryIndexInChapter = position,
isRegex = regexReplace
)
searchResultsWithinChapter.add(result)
}
return searchResultsWithinChapter
}
private fun searchPosition(content: String, pattern: String, regexReplace: Boolean): List<Int> {
val position: MutableList<Int> = mutableListOf()
if (regexReplace) { // 正则表达式搜索
try {
Regex(pattern).findAll(content).forEach { match ->
position.add(match.range.first)
}
} catch (e: Exception) {
return position
}
} else {
var index = content.indexOf(pattern)
while (index >= 0) {
position.add(index)
index = content.indexOf(pattern, index + pattern.length)
}
}
return position
}
private fun getResultAndQueryIndex(
content: String,
queryIndexInContent: Int,
query: String
): Pair<Int, String> {
val length = 12
var po1 = queryIndexInContent - length
var po2 = queryIndexInContent + query.length + length
if (po1 < 0) {
po1 = 0
}
if (po2 > content.length) {
po2 = content.length
}
val queryIndexInResult = queryIndexInContent - po1
val newText = content.substring(po1, po2)
return queryIndexInResult to newText
}
}
@@ -1,14 +1,17 @@
package io.legado.app.di
import io.legado.app.data.AppDatabase
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.DirectLinkUploadRepository
import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.ReadRecordRepository
import io.legado.app.data.repository.SearchContentRepository
import io.legado.app.data.repository.UploadRepository
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.replace.ReplaceRuleViewModel
import io.legado.app.ui.replace.edit.ReplaceEditViewModel
import org.koin.android.ext.koin.androidApplication
@@ -41,4 +44,9 @@ val appModule = module {
get()
)
}
// Search
single { SearchContentRepository() }
single { BookRepository() }
viewModel { SearchContentViewModel(get(), get()) }
}
@@ -1,264 +1,29 @@
package io.legado.app.ui.book.searchContent
//import io.legado.app.lib.theme.bottomBackground
//import io.legado.app.lib.theme.getPrimaryTextColor
//import io.legado.app.lib.theme.primaryTextColor
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.widget.EditText
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.core.view.allViews
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.databinding.ActivitySearchContentBinding
import io.legado.app.help.IntentData
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal
import io.legado.app.ui.widget.recycler.UpLinearLayoutManager
import io.legado.app.ui.widget.recycler.VerticalDivider
import io.legado.app.utils.applyNavigationBarPadding
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.invisible
import io.legado.app.utils.observeEvent
import io.legado.app.utils.postEvent
import io.legado.app.utils.shouldHideSoftInput
import io.legado.app.utils.showSoftInput
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.runtime.Composable
import io.legado.app.base.AppTheme
import io.legado.app.base.BaseComposeActivity
class SearchContentActivity : BaseComposeActivity() {
class SearchContentActivity :
VMBaseActivity<ActivitySearchContentBinding, SearchContentViewModel>(),
SearchContentAdapter.Callback {
override val binding by viewBinding(ActivitySearchContentBinding::inflate)
override val viewModel by viewModels<SearchContentViewModel>()
private val adapter by lazy { SearchContentAdapter(this, this) }
private val mLayoutManager by lazy { UpLinearLayoutManager(this) }
private val searchView: SearchView by lazy {
binding.titleBar.findViewById(R.id.search_view)
}
private var durChapterIndex = 0
private var searchJob: Job? = null
private var initJob: Job? = null
private var bookUrl: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.llSearchBaseInfo.applyNavigationBarPadding()
val searchResultList = IntentData.get<List<SearchResult>>("searchResultList")
val position = intent.getIntExtra("searchResultIndex", 0)
val noSearchResult = searchResultList == null
initSearchView(noSearchResult)
initRecyclerView()
initView()
val bookUrl = intent.getStringExtra("bookUrl") ?: return
viewModel.initBook(bookUrl) {
initSearchResultList(searchResultList, position)
initBook(noSearchResult)
}
bookUrl = intent.getStringExtra("bookUrl")
}
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.content_search, menu)
return super.onCompatCreateOptionsMenu(menu)
}
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean {
menu.findItem(R.id.menu_enable_replace)?.isChecked = viewModel.replaceEnabled
return super.onMenuOpened(featureId, menu)
}
override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_enable_replace -> {
viewModel.replaceEnabled = !viewModel.replaceEnabled
item.isChecked = viewModel.replaceEnabled
}
}
return super.onCompatOptionsItemSelected(item)
}
private fun initSearchResultList(list: List<SearchResult>?, position: Int) {
list ?: return
viewModel.searchResultList.addAll(list)
viewModel.searchResultCounts = list.size
adapter.setItems(list)
binding.recyclerView.scrollToPosition(position)
}
private fun initSearchView(requestFocus: Boolean) {
//searchView.applyTint(primaryTextColor)
searchView.isSubmitButtonEnabled = true
searchView.queryHint = getString(R.string.search)
if (requestFocus) searchView.isIconified = false
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
startContentSearch(query.trim())
searchView.clearFocus()
return false
}
override fun onQueryTextChange(newText: String): Boolean {
return false
}
})
}
private fun initRecyclerView() {
binding.recyclerView.layoutManager = mLayoutManager
binding.recyclerView.adapter = adapter
}
private fun initView() {
binding.ivSearchContentTop.setOnClickListener {
mLayoutManager.scrollToPositionWithOffset(0, 0)
}
binding.ivSearchContentBottom.setOnClickListener {
if (adapter.itemCount > 0) {
mLayoutManager.scrollToPositionWithOffset(adapter.itemCount - 1, 0)
}
}
binding.tvCurrentSearchInfo.setOnClickListener {
searchView.allViews.forEach { view ->
if (view is EditText) {
view.showSoftInput()
return@setOnClickListener
}
}
}
binding.fbStop.setOnClickListener {
searchJob?.cancel()
}
}
@SuppressLint("SetTextI18n")
private fun initBook(submit: Boolean = true) {
binding.tvCurrentSearchInfo.text =
this.getString(R.string.search_content_size) + ": ${viewModel.searchResultCounts}"
viewModel.book?.let {
initCacheFileNames(it)
durChapterIndex = it.durChapterIndex
intent.getStringExtra("searchWord")?.let { searchWord ->
searchView.setQuery(searchWord, submit)
@Composable
override fun Content() {
AppTheme {
bookUrl?.let {
SearchContentScreen(
bookUrl = it,
onBack = { finish() }
)
}
}
}
private fun initCacheFileNames(book: Book) {
initJob = lifecycleScope.launch {
withContext(IO) {
viewModel.cacheChapterNames.addAll(BookHelp.getChapterFiles(book))
}
adapter.notifyItemRangeChanged(0, adapter.itemCount, true)
}
}
override fun observeLiveBus() {
observeEvent<Pair<Book, BookChapter>>(EventBus.SAVE_CONTENT) { (book, chapter) ->
viewModel.book?.bookUrl?.let { bookUrl ->
if (book.bookUrl == bookUrl) {
viewModel.cacheChapterNames.add(chapter.getFileName())
adapter.notifyItemChanged(chapter.index, true)
}
}
}
}
@SuppressLint("SetTextI18n")
fun startContentSearch(query: String) {
// 按章节搜索内容
if (query.isBlank()) return
searchJob?.cancel()
adapter.clearItems()
viewModel.searchResultList.clear()
viewModel.searchResultCounts = 0
viewModel.lastQuery = query
binding.refreshProgressBar.isVisible = true
binding.fbStop.visible()
searchJob = lifecycleScope.launch(IO) {
initJob?.join()
kotlin.runCatching {
appDb.bookChapterDao.getChapterList(viewModel.bookUrl).forEach { bookChapter ->
ensureActive()
val totalChapters = viewModel.book?.totalChapterNum
?: appDb.bookChapterDao.getChapterCount(viewModel.bookUrl)
val searchResults = if (isLocalBook
|| viewModel.cacheChapterNames.contains(bookChapter.getFileName())
) {
viewModel.searchChapter(query, bookChapter).map { result ->
if (totalChapters > 0) {
result.copy(progressPercent = (bookChapter.index + 1).toFloat() / totalChapters * 100f)
} else {
result
}
}
} else {
return@forEach
}
ensureActive()
if (searchResults.isNotEmpty()) {
viewModel.searchResultList.addAll(searchResults)
binding.tvCurrentSearchInfo.post {
binding.tvCurrentSearchInfo.text =
this@SearchContentActivity.getString(R.string.search_content_size) + ": ${viewModel.searchResultCounts}"
adapter.addItems(searchResults)
}
}
}
if (viewModel.searchResultCounts == 0) {
val noSearchResult =
SearchResult(resultText = getString(R.string.search_content_empty))
binding.tvCurrentSearchInfo.post {
adapter.addItem(noSearchResult)
}
}
}.onFailure {
AppLog.put("全文搜索出错\n${it.localizedMessage}", it)
}
binding.tvCurrentSearchInfo.post {
binding.fbStop.invisible()
binding.refreshProgressBar.isVisible = false
}
}
}
private val isLocalBook: Boolean
get() = viewModel.book?.isLocal == true
override fun openSearchResult(searchResult: SearchResult, index: Int) {
searchJob?.cancel()
postEvent(EventBus.SEARCH_RESULT, viewModel.searchResultList as List<SearchResult>)
val searchData = Intent()
val key = System.currentTimeMillis()
IntentData.put("searchResult$key", searchResult)
IntentData.put("searchResultList$key", viewModel.searchResultList)
searchData.putExtra("key", key)
searchData.putExtra("index", index)
setResult(RESULT_OK, searchData)
finish()
}
override fun durChapterIndex(): Int {
return durChapterIndex
}
}
}
@@ -0,0 +1,326 @@
package io.legado.app.ui.book.searchContent
import android.app.Activity
import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import io.legado.app.ui.widget.components.AnimatedTextLine
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.TextCard
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchContentScreen(
bookUrl: String,
onBack: () -> Unit,
viewModel: SearchContentViewModel = koinViewModel()
) {
val activity = LocalActivity.current
val uiState by viewModel.uiState.collectAsState()
val isSearching = uiState.isSearching
val searchResults = uiState.searchResults
val durChapterIndex = uiState.durChapterIndex
val error = uiState.error
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
LaunchedEffect(bookUrl) {
viewModel.initBook(bookUrl)
}
/*
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is SearchUiEffect.OpenSearchResult -> {
navController.previousBackStackEntry
?.savedStateHandle
?.set("searchResult", effect.result)
navController.previousBackStackEntry
?.savedStateHandle
?.set("searchResultList", effect.allResults)
navController.previousBackStackEntry
?.savedStateHandle
?.set("searchResultIndex", effect.index)
navController.popBackStack()
}
}
}
}*/
var searchQuery by remember { mutableStateOf("") }
var replaceEnabled by remember { mutableStateOf(false) }
var regexReplace by remember { mutableStateOf(false) }
val contentState = when {
error != null -> SearchContentState.Error(error)
isSearching -> SearchContentState.Loading
searchQuery.isBlank() -> SearchContentState.EmptyQuery
searchResults.isEmpty() -> SearchContentState.EmptyResult
else -> null
}
val listState = rememberLazyListState()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
MediumTopAppBar(
title = {
val title = if (searchQuery.isNotBlank() && searchResults.isNotEmpty()) {
"${searchResults.size} 条结果"
} else {
"搜索内容"
}
AnimatedTextLine(
text = title
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
scrollBehavior = scrollBehavior
)
SearchBarSection(
query = searchQuery,
onQueryChange = {
searchQuery = it
viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
}
)
AnimatedVisibility(visible = scrollBehavior.state.heightOffset == 0f) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
FilterChip(
selected = replaceEnabled,
onClick = { replaceEnabled = !replaceEnabled },
label = { Text("启用替换") }
)
FilterChip(
selected = regexReplace,
onClick = { regexReplace = !regexReplace },
label = { Text("正则匹配") }
)
}
}
AnimatedVisibility(visible = contentState == SearchContentState.Loading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
},
floatingActionButton = {
if (isSearching) {
FloatingActionButton(onClick = { viewModel.stopSearch() }) {
Icon(Icons.Default.Stop, contentDescription = "停止搜索")
}
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
AnimatedContent(
targetState = contentState,
label = "SearchContentTransition"
) { state ->
when (state) {
is SearchContentState.Error -> {
EmptyMessageView(
message = state.throwable.localizedMessage ?: "发生未知错误",
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
)
}
SearchContentState.EmptyQuery -> {
EmptyMessageView(
message = "请输入关键词开始搜索",
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
)
}
SearchContentState.EmptyResult -> {
EmptyMessageView(
message = "没有找到相关内容",
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
)
}
null -> Unit
else -> {}
}
}
FastScrollLazyColumn(
state = listState,
modifier = Modifier.weight(1f)
) {
itemsIndexed(searchResults) { index, result ->
SearchResultItem(
modifier = Modifier.animateItem(),
result = result,
isCurrentChapter = result.chapterIndex == durChapterIndex,
onClick = {
viewModel.onSearchResultClick(result, index) { key ->
val intent = Intent().apply {
putExtra("key", key)
putExtra("index", index)
}
activity?.setResult(Activity.RESULT_OK, intent)
activity?.finish()
}
}
)
}
}
}
}
}
@Composable
fun SearchResultItem(
modifier: Modifier,
result: SearchResult,
isCurrentChapter: Boolean,
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Box(modifier = Modifier.padding(16.dp)) {
Column {
Text(
text = buildAnnotatedString {
append(
result.getTitleSpannable(
MaterialTheme.colorScheme.primary.toArgb()
)
)
},
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = buildAnnotatedString {
append(
result.getContentSpannable(
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
accentColor = MaterialTheme.colorScheme.primary.toArgb(),
bgColor = MaterialTheme.colorScheme.primaryContainer.toArgb()
)
)
},
style = MaterialTheme.typography.bodyMedium
)
}
Row (
modifier = Modifier.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (isCurrentChapter) {
TextCard(
text = "当前章节",
backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
cornerRadius = 8.dp,
horizontalPadding = 4.dp,
verticalPadding = 2.dp,
)
}
if (result.progressPercent > 0f) {
TextCard(
text = String.format("%.1f%%", result.progressPercent),
backgroundColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
cornerRadius = 8.dp,
horizontalPadding = 4.dp,
verticalPadding = 2.dp,
)
}
}
}
}
}
@@ -1,108 +1,127 @@
package io.legado.app.ui.book.searchContent
import android.app.Application
import io.legado.app.base.BaseViewModel
import io.legado.app.data.appDb
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.constant.EventBus
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.help.config.AppConfig
import io.legado.app.utils.ChineseUtils
import kotlinx.coroutines.ensureActive
import kotlin.coroutines.coroutineContext
import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.SearchContentRepository
import io.legado.app.help.IntentData
import io.legado.app.utils.postEvent
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SearchContentViewModel(application: Application) : BaseViewModel(application) {
var bookUrl: String = ""
var book: Book? = null
private var contentProcessor: ContentProcessor? = null
var lastQuery: String = ""
var searchResultCounts = 0
val cacheChapterNames = hashSetOf<String>()
val searchResultList: MutableList<SearchResult> = mutableListOf()
var replaceEnabled = false
data class SearchUiState(
val isSearching: Boolean = false,
val searchResults: List<SearchResult> = emptyList(),
val durChapterIndex: Int = -1,
val book: Book? = null,
val error: Throwable? = null
)
fun initBook(bookUrl: String, success: () -> Unit) {
this.bookUrl = bookUrl
execute {
book = appDb.bookDao.getBook(bookUrl)
book?.let {
contentProcessor = ContentProcessor.get(it.name, it.origin)
}
}.onSuccess {
success.invoke()
}
}
sealed interface SearchUiEffect {
data class OpenSearchResult(
val result: SearchResult,
val index: Int,
val allResults: List<SearchResult>
) : SearchUiEffect
}
suspend fun searchChapter(
query: String,
chapter: BookChapter
): List<SearchResult> {
val searchResultsWithinChapter: MutableList<SearchResult> = mutableListOf()
val book = book ?: return searchResultsWithinChapter
val chapterContent = BookHelp.getContent(book, chapter) ?: return searchResultsWithinChapter
coroutineContext.ensureActive()
chapter.title = when (AppConfig.chineseConverterType) {
1 -> ChineseUtils.t2s(chapter.title)
2 -> ChineseUtils.s2t(chapter.title)
else -> chapter.title
}
coroutineContext.ensureActive()
val mContent = contentProcessor!!.getContent(
book, chapter, chapterContent, useReplace = replaceEnabled
).toString()
val positions = searchPosition(mContent, query)
positions.forEachIndexed { index, position ->
coroutineContext.ensureActive()
val construct = getResultAndQueryIndex(mContent, position, query)
val result = SearchResult(
resultCountWithinChapter = index,
resultText = construct.second,
chapterTitle = chapter.title,
query = query,
chapterIndex = chapter.index,
queryIndexInResult = construct.first,
queryIndexInChapter = position
sealed interface SearchContentState {
data object Loading : SearchContentState
data object EmptyQuery : SearchContentState
data object EmptyResult : SearchContentState
data class Error(val throwable: Throwable) : SearchContentState
}
class SearchContentViewModel(
private val bookRepository: BookRepository,
private val searchContentRepository: SearchContentRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SearchUiState())
val uiState = _uiState.asStateFlow()
private val _effect = MutableSharedFlow<SearchUiEffect>()
val effect = _effect.asSharedFlow()
private var searchJob: Job? = null
fun initBook(bookUrl: String) {
viewModelScope.launch {
val book = bookRepository.getBook(bookUrl)
_uiState.value = _uiState.value.copy(
book = book,
durChapterIndex = book?.durChapterIndex ?: -1
)
searchResultsWithinChapter.add(result)
}
searchResultCounts += searchResultsWithinChapter.size
return searchResultsWithinChapter
}
private suspend fun searchPosition(content: String, pattern: String): List<Int> {
val position: MutableList<Int> = mutableListOf()
var index = content.indexOf(pattern)
while (index >= 0) {
coroutineContext.ensureActive()
position.add(index)
index = content.indexOf(pattern, index + pattern.length)
fun startSearch(query: String, replaceEnabled: Boolean, regexReplace: Boolean) {
searchJob?.cancel()
if (query.isBlank()) {
_uiState.update { it.copy(
isSearching = false,
searchResults = emptyList(),
error = null
)}
return
}
searchJob = viewModelScope.launch {
_uiState.value.book?.let { book ->
searchContentRepository
.search(book, query, replaceEnabled, regexReplace)
.onStart {
_uiState.update { it.copy(isSearching = true, error = null) }
}
.onCompletion {
_uiState.update { it.copy(isSearching = false) }
}
.catch { e ->
_uiState.update { it.copy(isSearching = false, error = e) }
}
.collect { results ->
_uiState.update { it.copy(searchResults = results) }
}
}
}
return position
}
private fun getResultAndQueryIndex(
content: String,
queryIndexInContent: Int,
query: String
): Pair<Int, String> {
// 左右移动20个字符,构建关键词周边文字,在搜索结果里显示
// 判断段落,只在关键词所在段落内分割
// 利用标点符号分割完整的句
// length和设置结合,自由调整周边文字长度
val length = 20
var po1 = queryIndexInContent - length
var po2 = queryIndexInContent + query.length + length
if (po1 < 0) {
po1 = 0
}
if (po2 > content.length) {
po2 = content.length
}
val queryIndexInResult = queryIndexInContent - po1
val newText = content.substring(po1, po2)
return queryIndexInResult to newText
fun stopSearch() {
searchJob?.cancel()
}
}
fun onSearchResultClick(result: SearchResult, index: Int) {
searchJob?.cancel()
viewModelScope.launch {
_effect.emit(
SearchUiEffect.OpenSearchResult(
result = result,
index = index,
allResults = _uiState.value.searchResults
)
)
}
}
fun onSearchResultClick(searchResult: SearchResult, index: Int, onSuccess: (key: Long) -> Unit) {
stopSearch()
postEvent(EventBus.SEARCH_RESULT, uiState.value.searchResults)
val key = System.currentTimeMillis()
IntentData.put("searchResult$key", searchResult)
IntentData.put("searchResultList$key", uiState.value.searchResults)
onSuccess(key)
}
}
@@ -21,6 +21,7 @@ data class SearchResult(
val pageIndex: Int = 0,
val queryIndexInResult: Int = 0,
val queryIndexInChapter: Int = 0,
val isRegex: Boolean = false,
val progressPercent: Float = 0f
) {
@@ -34,8 +34,10 @@ fun SearchBarSection(
shape = RoundedCornerShape(32.dp),
color = backgroundColor
) {
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
value = query,
onValueChange = onQueryChange,
placeholder = { Text(placeholder) },
@@ -1,22 +1,27 @@
package io.legado.app.ui.widget.components
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Preview(showBackground = true)
@Composable
@@ -30,38 +35,51 @@ fun PreviewTextCard() {
@Composable
fun TextCard(
text: String,
modifier: Modifier = Modifier,
backgroundColor: Color = colorScheme.tertiaryContainer,
contentColor: Color = colorScheme.onTertiaryContainer,
cornerRadius: Dp = 12.dp,
paddingHorizontal: Dp = 8.dp,
paddingVertical: Dp = 0.dp,
textSize: TextUnit = 10.sp,
text: String,
icon: ImageVector? = null,
backgroundColor: Color = colorScheme.primaryContainer,
contentColor: Color = colorScheme.onPrimaryContainer,
cornerRadius: Dp = 8.dp,
horizontalPadding: Dp = 8.dp,
verticalPadding: Dp = 2.dp,
iconSize: Dp = 14.dp,
spacing: Dp = 4.dp,
textStyle: TextStyle = MaterialTheme.typography.labelSmall,
bold: Boolean = true,
onClick: (() -> Unit)? = null,
) {
Card(
modifier = modifier
.then(
if (onClick != null) Modifier.clickable { onClick() }
else Modifier
),
colors = CardDefaults.cardColors(
containerColor = backgroundColor,
contentColor = contentColor
),
modifier = modifier,
shape = RoundedCornerShape(cornerRadius),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(
containerColor = backgroundColor
),
) {
Text(
text = text,
Row(
modifier = Modifier.padding(
horizontal = paddingHorizontal,
vertical = paddingVertical
horizontal = horizontalPadding,
vertical = verticalPadding
),
fontSize = textSize,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal
)
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(iconSize)
)
Spacer(modifier = Modifier.width(spacing))
}
Text(
text = text,
style = textStyle,
color = contentColor,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal
)
}
}
}