[新增] 使用Compose重写的正文搜索界面,也许存在不稳定情况
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user