diff --git a/app/src/main/java/io/legado/app/data/repository/BookRepository.kt b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt new file mode 100644 index 000000000..f79696688 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/BookRepository.kt @@ -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) + } + +} diff --git a/app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt b/app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt new file mode 100644 index 000000000..6aa31ac07 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt @@ -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> = 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() + 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 { + val searchResultsWithinChapter: MutableList = 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 { + val position: MutableList = 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 { + 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 + } + +} diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index c51ec4243..6c1c84f5a 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -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()) } } diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt index 91be47e49..a39caf1ab 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentActivity.kt @@ -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(), - SearchContentAdapter.Callback { - - override val binding by viewBinding(ActivitySearchContentBinding::inflate) - override val viewModel by viewModels() - 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>("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?, 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>(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) - 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 - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt new file mode 100644 index 000000000..9ba4308cb --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt @@ -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, + ) + } + } + } + } +} + + diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt index f5d774795..2959e26d7 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt @@ -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() - val searchResultList: MutableList = mutableListOf() - var replaceEnabled = false +data class SearchUiState( + val isSearching: Boolean = false, + val searchResults: List = 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 + ) : SearchUiEffect +} - suspend fun searchChapter( - query: String, - chapter: BookChapter - ): List { - val searchResultsWithinChapter: MutableList = 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() + 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 { - val position: MutableList = 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 { - // 左右移动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() } -} \ No newline at end of file + 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) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt index 25f35bc7e..7b90fd2ec 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt +++ b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt @@ -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 ) { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt index cbbb60854..c45a1ffaa 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt @@ -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) }, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt b/app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt index 22c4330df..908bab0b6 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt @@ -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 + ) + } } } +