[新增] 使用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
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user