[新增] 使用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 package io.legado.app.di
import io.legado.app.data.AppDatabase 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.DirectLinkUploadRepository
import io.legado.app.data.repository.ExploreRepository import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.ReadRecordRepository 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.data.repository.UploadRepository
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.explore.ExploreShowViewModel import io.legado.app.ui.book.explore.ExploreShowViewModel
import io.legado.app.ui.book.readRecord.ReadRecordViewModel import io.legado.app.ui.book.readRecord.ReadRecordViewModel
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.replace.ReplaceRuleViewModel import io.legado.app.ui.replace.ReplaceRuleViewModel
import io.legado.app.ui.replace.edit.ReplaceEditViewModel import io.legado.app.ui.replace.edit.ReplaceEditViewModel
import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidApplication
@@ -41,4 +44,9 @@ val appModule = module {
get() get()
) )
} }
// Search
single { SearchContentRepository() }
single { BookRepository() }
viewModel { SearchContentViewModel(get(), get()) }
} }
@@ -1,264 +1,29 @@
package io.legado.app.ui.book.searchContent 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.os.Bundle
import android.view.Menu import androidx.compose.runtime.Composable
import android.view.MenuItem import io.legado.app.base.AppTheme
import android.view.MotionEvent import io.legado.app.base.BaseComposeActivity
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
class SearchContentActivity : BaseComposeActivity() {
class SearchContentActivity : private var bookUrl: String? = null
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
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding.llSearchBaseInfo.applyNavigationBarPadding() bookUrl = intent.getStringExtra("bookUrl")
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)
}
} }
override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { @Composable
menuInflater.inflate(R.menu.content_search, menu) override fun Content() {
return super.onCompatCreateOptionsMenu(menu) AppTheme {
} bookUrl?.let {
SearchContentScreen(
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean { bookUrl = it,
menu.findItem(R.id.menu_enable_replace)?.isChecked = viewModel.replaceEnabled onBack = { finish() }
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)
} }
} }
} }
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 package io.legado.app.ui.book.searchContent
import androidx.lifecycle.ViewModel
import android.app.Application import androidx.lifecycle.viewModelScope
import io.legado.app.base.BaseViewModel 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.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.repository.BookRepository
import io.legado.app.help.book.BookHelp import io.legado.app.data.repository.SearchContentRepository
import io.legado.app.help.book.ContentProcessor import io.legado.app.help.IntentData
import io.legado.app.help.config.AppConfig import io.legado.app.utils.postEvent
import io.legado.app.utils.ChineseUtils import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableSharedFlow
import kotlin.coroutines.coroutineContext 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) { data class SearchUiState(
var bookUrl: String = "" val isSearching: Boolean = false,
var book: Book? = null val searchResults: List<SearchResult> = emptyList(),
private var contentProcessor: ContentProcessor? = null val durChapterIndex: Int = -1,
var lastQuery: String = "" val book: Book? = null,
var searchResultCounts = 0 val error: Throwable? = null
val cacheChapterNames = hashSetOf<String>() )
val searchResultList: MutableList<SearchResult> = mutableListOf()
var replaceEnabled = false
fun initBook(bookUrl: String, success: () -> Unit) { sealed interface SearchUiEffect {
this.bookUrl = bookUrl data class OpenSearchResult(
execute { val result: SearchResult,
book = appDb.bookDao.getBook(bookUrl) val index: Int,
book?.let { val allResults: List<SearchResult>
contentProcessor = ContentProcessor.get(it.name, it.origin) ) : SearchUiEffect
} }
}.onSuccess {
success.invoke()
}
}
suspend fun searchChapter( sealed interface SearchContentState {
query: String, data object Loading : SearchContentState
chapter: BookChapter data object EmptyQuery : SearchContentState
): List<SearchResult> { data object EmptyResult : SearchContentState
val searchResultsWithinChapter: MutableList<SearchResult> = mutableListOf() data class Error(val throwable: Throwable) : SearchContentState
val book = book ?: return searchResultsWithinChapter }
val chapterContent = BookHelp.getContent(book, chapter) ?: return searchResultsWithinChapter
coroutineContext.ensureActive()
chapter.title = when (AppConfig.chineseConverterType) { class SearchContentViewModel(
1 -> ChineseUtils.t2s(chapter.title) private val bookRepository: BookRepository,
2 -> ChineseUtils.s2t(chapter.title) private val searchContentRepository: SearchContentRepository
else -> chapter.title ) : ViewModel() {
}
coroutineContext.ensureActive() private val _uiState = MutableStateFlow(SearchUiState())
val mContent = contentProcessor!!.getContent( val uiState = _uiState.asStateFlow()
book, chapter, chapterContent, useReplace = replaceEnabled
).toString() private val _effect = MutableSharedFlow<SearchUiEffect>()
val positions = searchPosition(mContent, query) val effect = _effect.asSharedFlow()
positions.forEachIndexed { index, position ->
coroutineContext.ensureActive() private var searchJob: Job? = null
val construct = getResultAndQueryIndex(mContent, position, query)
val result = SearchResult( fun initBook(bookUrl: String) {
resultCountWithinChapter = index, viewModelScope.launch {
resultText = construct.second, val book = bookRepository.getBook(bookUrl)
chapterTitle = chapter.title, _uiState.value = _uiState.value.copy(
query = query, book = book,
chapterIndex = chapter.index, durChapterIndex = book?.durChapterIndex ?: -1
queryIndexInResult = construct.first,
queryIndexInChapter = position
) )
searchResultsWithinChapter.add(result)
} }
searchResultCounts += searchResultsWithinChapter.size
return searchResultsWithinChapter
} }
private suspend fun searchPosition(content: String, pattern: String): List<Int> { fun startSearch(query: String, replaceEnabled: Boolean, regexReplace: Boolean) {
val position: MutableList<Int> = mutableListOf() searchJob?.cancel()
var index = content.indexOf(pattern)
while (index >= 0) { if (query.isBlank()) {
coroutineContext.ensureActive() _uiState.update { it.copy(
position.add(index) isSearching = false,
index = content.indexOf(pattern, index + pattern.length) 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( fun stopSearch() {
content: String, searchJob?.cancel()
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 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 pageIndex: Int = 0,
val queryIndexInResult: Int = 0, val queryIndexInResult: Int = 0,
val queryIndexInChapter: Int = 0, val queryIndexInChapter: Int = 0,
val isRegex: Boolean = false,
val progressPercent: Float = 0f val progressPercent: Float = 0f
) { ) {
@@ -34,8 +34,10 @@ fun SearchBarSection(
shape = RoundedCornerShape(32.dp), shape = RoundedCornerShape(32.dp),
color = backgroundColor color = backgroundColor
) { ) {
TextField( TextField(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
placeholder = { Text(placeholder) }, placeholder = { Text(placeholder) },
@@ -1,22 +1,27 @@
package io.legado.app.ui.widget.components package io.legado.app.ui.widget.components
import androidx.compose.ui.graphics.Color 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -30,38 +35,51 @@ fun PreviewTextCard() {
@Composable @Composable
fun TextCard( fun TextCard(
text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
backgroundColor: Color = colorScheme.tertiaryContainer, text: String,
contentColor: Color = colorScheme.onTertiaryContainer, icon: ImageVector? = null,
cornerRadius: Dp = 12.dp, backgroundColor: Color = colorScheme.primaryContainer,
paddingHorizontal: Dp = 8.dp, contentColor: Color = colorScheme.onPrimaryContainer,
paddingVertical: Dp = 0.dp, cornerRadius: Dp = 8.dp,
textSize: TextUnit = 10.sp, horizontalPadding: Dp = 8.dp,
verticalPadding: Dp = 2.dp,
iconSize: Dp = 14.dp,
spacing: Dp = 4.dp,
textStyle: TextStyle = MaterialTheme.typography.labelSmall,
bold: Boolean = true, bold: Boolean = true,
onClick: (() -> Unit)? = null,
) { ) {
Card( Card(
modifier = modifier modifier = modifier,
.then(
if (onClick != null) Modifier.clickable { onClick() }
else Modifier
),
colors = CardDefaults.cardColors(
containerColor = backgroundColor,
contentColor = contentColor
),
shape = RoundedCornerShape(cornerRadius), shape = RoundedCornerShape(cornerRadius),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), colors = CardDefaults.cardColors(
containerColor = backgroundColor
),
) { ) {
Text( Row(
text = text,
modifier = Modifier.padding( modifier = Modifier.padding(
horizontal = paddingHorizontal, horizontal = horizontalPadding,
vertical = paddingVertical vertical = verticalPadding
), ),
fontSize = textSize, verticalAlignment = Alignment.CenterVertically
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal ) {
)
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
)
}
} }
} }