[优化] 新增正文搜索记录

This commit is contained in:
HapeLee
2026-03-05 01:23:47 +08:00
parent f1843ab732
commit bede06bf46
9 changed files with 4578 additions and 195 deletions
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 82,
"identityHash": "b11be214ae8a55304244694a180932c9",
"identityHash": "285a81996bc58258f99a2f67fb5cf84e",
"entities": [
{
"tableName": "books",
@@ -1176,7 +1176,7 @@
},
{
"tableName": "bookmarks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
"fields": [
{
"fieldPath": "time",
@@ -1194,7 +1194,8 @@
"fieldPath": "bookAuthor",
"columnName": "bookAuthor",
"affinity": "TEXT",
"notNull": true
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "chapterIndex",
@@ -1427,7 +1428,7 @@
},
{
"tableName": "readRecordDetail",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `date`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
"fields": [
{
"fieldPath": "deviceId",
@@ -1441,6 +1442,13 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bookAuthor",
"columnName": "bookAuthor",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "date",
"columnName": "date",
@@ -1481,13 +1489,14 @@
"columnNames": [
"deviceId",
"bookName",
"bookAuthor",
"date"
]
}
},
{
"tableName": "readRecordSession",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
@@ -1507,6 +1516,13 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bookAuthor",
"columnName": "bookAuthor",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "startTime",
"columnName": "startTime",
@@ -1671,7 +1687,7 @@
},
{
"tableName": "readRecord",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
"fields": [
{
"fieldPath": "deviceId",
@@ -1685,6 +1701,13 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bookAuthor",
"columnName": "bookAuthor",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "readTime",
"columnName": "readTime",
@@ -1704,7 +1727,8 @@
"autoGenerate": false,
"columnNames": [
"deviceId",
"bookName"
"bookName",
"bookAuthor"
]
}
},
@@ -2016,7 +2040,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b11be214ae8a55304244694a180932c9')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '285a81996bc58258f99a2f67fb5cf84e')"
]
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.legado.app.data.entities.SearchContentHistory
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchContentHistoryDao {
@Query("SELECT * FROM search_content_history ORDER BY time DESC")
fun getAll(): Flow<List<SearchContentHistory>>
@Query("SELECT * FROM search_content_history WHERE bookName = :bookName AND bookAuthor = :bookAuthor ORDER BY time DESC")
fun getByBook(bookName: String, bookAuthor: String): Flow<List<SearchContentHistory>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(searchContentHistory: SearchContentHistory)
@Query("DELETE FROM search_content_history WHERE id = :id")
suspend fun delete(id: Long)
@Query("DELETE FROM search_content_history WHERE bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun deleteByBook(bookName: String, bookAuthor: String)
@Query("DELETE FROM search_content_history")
suspend fun deleteAll()
@Query("SELECT * FROM search_content_history WHERE bookName = :bookName AND bookAuthor = :bookAuthor AND `query` = :query")
suspend fun get(bookName: String, bookAuthor: String, query: String): SearchContentHistory?
}
@@ -0,0 +1,21 @@
package io.legado.app.data.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "search_content_history",
indices = [(Index(value = ["bookName", "bookAuthor", "query"], unique = true))]
)
data class SearchContentHistory(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
@ColumnInfo(defaultValue = "")
var bookName: String? = null,
@ColumnInfo(defaultValue = "")
var bookAuthor: String? = null,
var query: String = "",
var time: Long = System.currentTimeMillis()
)
@@ -18,6 +18,14 @@ import kotlinx.coroutines.flow.flowOn
class SearchContentRepository {
private var lastSearchResults: List<SearchResult>? = null
private var lastQueryKey: String? = null
fun getCache(bookUrl: String, query: String): List<SearchResult>? {
val key = "$bookUrl-$query"
return if (lastQueryKey == key) lastSearchResults else null
}
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
@@ -25,7 +33,6 @@ class SearchContentRepository {
val cacheChapterNames = BookHelp.getChapterFiles(book).toHashSet()
val allResults = mutableListOf<SearchResult>()
var lastEmitTime = System.currentTimeMillis()
for (bookChapter in chapters) {
@@ -49,15 +56,12 @@ class SearchContentRepository {
if (chapterResults.isNotEmpty()) {
allResults.addAll(chapterResults)
val now = System.currentTimeMillis()
if (now - lastEmitTime > 350L) {
emit(ArrayList(allResults))
lastEmitTime = now
}
emit(ArrayList(allResults))
}
}
}
lastSearchResults = allResults
lastQueryKey = "${book.bookUrl}-$query"
emit(ArrayList(allResults))
}.flowOn(Dispatchers.Default)
@@ -1,34 +1,17 @@
package io.legado.app.ui.book.searchContent
import android.os.Bundle
import androidx.compose.runtime.Composable
import io.legado.app.ui.theme.AppTheme
import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.theme.AppTheme
class SearchContentActivity : BaseComposeActivity() {
private var bookUrl: String? = null
private var searchWord: String? = null
private var searchResultIndex: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bookUrl = intent.getStringExtra("bookUrl")
searchWord = intent.getStringExtra("searchWord")
searchResultIndex = intent.getIntExtra("searchResultIndex", 0)
}
@Composable
override fun Content() {
AppTheme {
bookUrl?.let {
SearchContentScreen(
bookUrl = it,
searchWord = searchWord,
onBack = { finish() }
)
}
SearchContentScreen(
onBack = { finish() }
)
}
}
}
@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,13 +15,23 @@ 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CollectionsBookmark
import androidx.compose.material.icons.filled.FindReplace
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,7 +40,9 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -43,38 +56,43 @@ 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.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow
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.platform.LocalContext
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.data.entities.SearchContentHistory
import io.legado.app.ui.widget.components.AnimatedText
import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.SearchBarSection
import io.legado.app.ui.widget.components.button.AnimatedActionButton
import io.legado.app.ui.widget.components.button.SmallAnimatedActionButton
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.TopBarAnimatedActionButton
import io.legado.app.ui.widget.components.button.TopbarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SearchContentScreen(
bookUrl: String,
searchWord: String?,
onBack: () -> Unit,
viewModel: SearchContentViewModel = koinViewModel()
) {
val activity = LocalActivity.current
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val replaceEnabled by viewModel.replaceEnabled.collectAsState()
val regexReplace by viewModel.regexReplace.collectAsState()
val searchHistory by viewModel.searchHistory.collectAsState()
val historyOnlyThisBook by viewModel.historyOnlyThisBook.collectAsState()
val isSearching = uiState.isSearching
val searchResults = uiState.searchResults
@@ -82,49 +100,41 @@ fun SearchContentScreen(
val error = uiState.error
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
var searchQuery by remember(searchWord) { mutableStateOf(searchWord ?: "") }
var replaceEnabled by remember { mutableStateOf(false) }
var regexReplace by remember { mutableStateOf(false) }
LaunchedEffect(bookUrl, searchWord) {
viewModel.initBook(bookUrl)
if (!searchWord.isNullOrBlank()) {
viewModel.startSearch(searchWord, replaceEnabled, regexReplace)
val scrollToCurrentChapter = {
val targetIndex = searchResults.indexOfFirst { it.chapterIndex == durChapterIndex }
if (targetIndex != -1) {
scope.launch {
listState.animateScrollToItem(targetIndex)
}
}
}
/*
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()
LaunchedEffect(searchResults) {
if (searchResults.isNotEmpty() && viewModel.shouldAutoScroll()) {
val targetIndex = searchResults.indexOfFirst { it.chapterIndex == durChapterIndex }
if (targetIndex != -1) {
snapshotFlow { listState.layoutInfo.totalItemsCount }.collect { count ->
if (count > targetIndex) {
listState.animateScrollToItem(targetIndex)
viewModel.markScrollDone()
return@collect
}
}
}
}
}*/
}
val contentState = when {
error != null -> SearchContentState.Error(error)
isSearching -> SearchContentState.Loading
searchQuery.isBlank() -> SearchContentState.EmptyQuery
searchQuery.isBlank() -> SearchContentState.History
searchResults.isEmpty() -> SearchContentState.EmptyResult
else -> null
}
val listState = rememberLazyListState()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -133,51 +143,27 @@ fun SearchContentScreen(
title = {
val title = if (searchQuery.isNotBlank() && searchResults.isNotEmpty()) {
"${searchResults.size} 条结果"
} else {
"搜索内容"
}
AnimatedText(
text = title
)
},
navigationIcon = {
TopbarNavigationButton(onClick = onBack)
} else "搜索内容"
AnimatedText(text = title)
},
navigationIcon = { TopbarNavigationButton(onClick = onBack) },
actions = {
Row(
modifier = Modifier.padding(end = 12.dp),
modifier = Modifier.padding(end = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
AnimatedActionButton(
TopBarAnimatedActionButton(
checked = replaceEnabled,
onCheckedChange = {
replaceEnabled = it
if (searchQuery.isNotBlank()) {
viewModel.startSearch(
searchQuery,
replaceEnabled,
regexReplace
)
}
},
onCheckedChange = { viewModel.toggleReplace(it) },
iconChecked = Icons.Default.FindReplace,
iconUnchecked = Icons.Default.FindReplace,
activeText = "替换开启",
inactiveText = "替换关闭"
)
AnimatedActionButton(
TopBarAnimatedActionButton(
checked = regexReplace,
onCheckedChange = {
regexReplace = it
if (searchQuery.isNotBlank()) {
viewModel.startSearch(
searchQuery,
replaceEnabled,
regexReplace
)
}
},
onCheckedChange = { viewModel.toggleRegex(it) },
iconChecked = Icons.Default.Code,
iconUnchecked = Icons.Default.Code,
activeText = "正则开启",
@@ -189,33 +175,50 @@ fun SearchContentScreen(
)
SearchBarSection(
query = searchQuery,
onQueryChange = {
searchQuery = it
viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
}
scrollState = listState,
onQueryChange = { viewModel.onQueryChange(it) }
)
AnimatedVisibility(visible = contentState == SearchContentState.Loading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
AnimatedVisibility(visible = isSearching) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
},
floatingActionButton = {
val fabVisible = (isSearching || searchResults.isNotEmpty()) && searchQuery.isNotBlank()
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text("Localized description") } },
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above
),
tooltip = {
PlainTooltip {
Text(if (isSearching) "停止搜索" else "跳转到当前章节")
}
},
state = rememberTooltipState(),
) {
FloatingActionButton(
modifier = Modifier.animateFloatingActionButton(
visible = isSearching,
visible = fabVisible,
alignment = Alignment.BottomEnd,
),
onClick = { viewModel.stopSearch() }
onClick = {
if (isSearching) {
viewModel.stopSearch()
} else {
scrollToCurrentChapter()
}
}
) {
Icon(Icons.Default.Stop, contentDescription = "停止搜索")
AnimatedContent(
targetState = isSearching,
label = "FabIconTransition"
) { searching ->
if (searching) {
Icon(Icons.Default.Stop, contentDescription = "停止搜索")
} else {
Icon(Icons.Default.MyLocation, contentDescription = "定位当前章节")
}
}
}
}
}
@@ -225,10 +228,10 @@ fun SearchContentScreen(
.fillMaxSize()
.padding(paddingValues)
) {
AnimatedContent(
targetState = contentState,
label = "SearchContentTransition"
label = "SearchContentTransition",
modifier = Modifier.weight(1f)
) { state ->
when (state) {
is SearchContentState.Error -> {
@@ -240,15 +243,16 @@ fun SearchContentScreen(
)
}
SearchContentState.EmptyQuery -> {
EmptyMessageView(
message = "请输入关键词开始搜索",
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
SearchContentState.History -> {
SearchHistoryList(
history = searchHistory,
onlyThisBook = historyOnlyThisBook,
onHistoryClick = { viewModel.onQueryChange(it.query) },
onDeleteHistory = { viewModel.deleteHistory(it) },
onClearHistory = { viewModel.clearHistory() },
onToggleScope = { viewModel.toggleHistoryScope() }
)
}
SearchContentState.EmptyResult -> {
EmptyMessageView(
message = "没有找到相关内容!",
@@ -258,32 +262,126 @@ fun SearchContentScreen(
)
}
null -> Unit
null -> {
FastScrollLazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
itemsIndexed(searchResults) { index, result ->
SearchResultItem(
modifier = Modifier.animateItem(),
result = result,
isCurrentChapter = result.chapterIndex == durChapterIndex,
onClick = {
viewModel.onSearchResultClick(result) { key ->
val intent = Intent().apply {
putExtra("key", key)
putExtra("index", index)
}
activity?.setResult(Activity.RESULT_OK, intent)
activity?.finish()
}
}
)
}
}
}
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()
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SearchHistoryList(
history: List<SearchContentHistory>,
onlyThisBook: Boolean,
onHistoryClick: (SearchContentHistory) -> Unit,
onDeleteHistory: (SearchContentHistory) -> Unit,
onClearHistory: () -> Unit,
onToggleScope: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = "搜索历史",
style = MaterialTheme.typography.titleSmallEmphasized,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.align(Alignment.Center)
)
SmallAnimatedActionButton(
modifier = Modifier.align(Alignment.CenterEnd),
checked = onlyThisBook,
onCheckedChange = { onToggleScope() },
iconChecked = Icons.Default.Book,
iconUnchecked = Icons.Default.CollectionsBookmark,
activeText = "仅本书",
inactiveText = "所有记录"
)
}
if (history.isEmpty()) {
EmptyMessageView(
message = "暂无搜索历史",
modifier = Modifier
.fillMaxSize()
.wrapContentSize()
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(history, key = { it.id }) { item ->
ListItem(
modifier = Modifier
.clickable { onHistoryClick(item) }
.animateItem(),
headlineContent = {
Text(
text = item.query,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
leadingContent = {
Icon(Icons.Default.History, contentDescription = null)
},
trailingContent = {
SmallIconButton(
onClick = { onDeleteHistory(item) },
icon = Icons.Default.Close,
contentDescription = "删除"
)
}
)
}
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp)
.animateItem(),
contentAlignment = Alignment.Center
) {
OutlinedButton(
onClick = onClearHistory,
modifier = Modifier.fillMaxWidth(0.6f)
) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("清除搜索历史")
}
}
}
}
}
}
@@ -1,21 +1,27 @@
package io.legado.app.ui.book.searchContent
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.SearchContentHistory
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -27,70 +33,120 @@ data class SearchUiState(
val error: Throwable? = null
)
sealed interface SearchUiEffect {
data class OpenSearchResult(
val result: SearchResult,
val index: Int,
val allResults: List<SearchResult>
) : SearchUiEffect
}
sealed interface SearchContentState {
data object Loading : SearchContentState
data object EmptyQuery : SearchContentState
data object History : SearchContentState
data object EmptyResult : SearchContentState
data class Error(val throwable: Throwable) : SearchContentState
}
class SearchContentViewModel(
class SearchContentViewModel(
savedStateHandle: SavedStateHandle,
private val bookRepository: BookRepository,
private val searchContentRepository: SearchContentRepository
) : ViewModel() {
) : ViewModel() {
val bookUrl: String = savedStateHandle.get<String>("bookUrl") ?: ""
private val initialSearchWord: String? = savedStateHandle.get<String>("searchWord")
private val searchResultIndex: Int = savedStateHandle.get<Int>("searchResultIndex") ?: 0
private val _searchQuery = MutableStateFlow(initialSearchWord ?: "")
val searchQuery = _searchQuery.asStateFlow()
private val _replaceEnabled = MutableStateFlow(false)
val replaceEnabled = _replaceEnabled.asStateFlow()
private val _regexReplace = MutableStateFlow(false)
val regexReplace = _regexReplace.asStateFlow()
private val _uiState = MutableStateFlow(SearchUiState())
val uiState = _uiState.asStateFlow()
private val _effect = MutableSharedFlow<SearchUiEffect>()
val effect = _effect.asSharedFlow()
private val _historyOnlyThisBook = MutableStateFlow(true)
val historyOnlyThisBook = _historyOnlyThisBook.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val searchHistory = combine(_historyOnlyThisBook, _uiState) { onlyThisBook, uiState ->
onlyThisBook to uiState.book
}.flatMapLatest { (onlyThisBook, book) ->
if (onlyThisBook && book != null) {
appDb.searchContentHistoryDao.getByBook(book.name, book.author)
} else {
appDb.searchContentHistoryDao.getAll()
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private var hasAutoScrolled = false
private var searchJob: Job? = null
fun initBook(bookUrl: String) {
init {
initBook()
}
private fun initBook() {
viewModelScope.launch {
val book = bookRepository.getBook(bookUrl)
_uiState.value = _uiState.value.copy(
val cachedResults = searchContentRepository.getCache(bookUrl, _searchQuery.value)
_uiState.update {
it.copy(
book = book,
durChapterIndex = book?.durChapterIndex ?: -1
)
durChapterIndex = book?.durChapterIndex ?: -1,
searchResults = cachedResults ?: it.searchResults
)
}
if ((cachedResults == null || cachedResults.isEmpty()) && _searchQuery.value.isNotBlank()) {
executeSearch()
}
}
}
fun startSearch(query: String, replaceEnabled: Boolean, regexReplace: Boolean) {
fun onQueryChange(newQuery: String) {
_searchQuery.value = newQuery
executeSearch()
}
fun toggleReplace(enabled: Boolean) {
_replaceEnabled.value = enabled
executeSearch()
}
fun toggleRegex(enabled: Boolean) {
_regexReplace.value = enabled
executeSearch()
}
fun toggleHistoryScope() {
_historyOnlyThisBook.value = !_historyOnlyThisBook.value
}
private fun executeSearch() {
searchJob?.cancel()
val query = _searchQuery.value
val replace = _replaceEnabled.value
val regex = _regexReplace.value
if (query.isBlank()) {
_uiState.update { it.copy(
isSearching = false,
searchResults = emptyList(),
error = null
)}
_uiState.update {
it.copy(
isSearching = false,
searchResults = emptyList(),
error = null
)
}
return
}
searchJob = viewModelScope.launch {
_uiState.value.book?.let { book ->
saveSearchHistory(book, query)
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) }
}
.search(book, query, replace, regex)
.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) }
}
@@ -98,24 +154,42 @@ class SearchContentViewModel(
}
}
fun stopSearch() {
searchJob?.cancel()
private suspend fun saveSearchHistory(book: Book, query: String) {
val history = appDb.searchContentHistoryDao.get(book.name, book.author, query)
?: SearchContentHistory(bookName = book.name, bookAuthor = book.author, query = query)
history.time = System.currentTimeMillis()
appDb.searchContentHistoryDao.insert(history)
}
fun onSearchResultClick(result: SearchResult, index: Int) {
searchJob?.cancel()
fun deleteHistory(history: SearchContentHistory) {
viewModelScope.launch {
_effect.emit(
SearchUiEffect.OpenSearchResult(
result = result,
index = index,
allResults = _uiState.value.searchResults
)
)
appDb.searchContentHistoryDao.delete(history.id)
}
}
fun onSearchResultClick(searchResult: SearchResult, index: Int, onSuccess: (key: Long) -> Unit) {
fun clearHistory() {
viewModelScope.launch {
val book = _uiState.value.book
if (_historyOnlyThisBook.value && book != null) {
appDb.searchContentHistoryDao.deleteByBook(book.name, book.author)
} else {
appDb.searchContentHistoryDao.deleteAll()
}
}
}
fun stopSearch() {
searchJob?.cancel()
_uiState.update { it.copy(isSearching = false) }
}
fun shouldAutoScroll(): Boolean = searchResultIndex > 0 && !hasAutoScrolled
fun markScrollDone() {
hasAutoScrolled = true
}
fun onSearchResultClick(searchResult: SearchResult, onSuccess: (key: Long) -> Unit) {
stopSearch()
postEvent(EventBus.SEARCH_RESULT, uiState.value.searchResults)
val key = System.currentTimeMillis()
@@ -123,5 +197,4 @@ class SearchContentViewModel(
IntentData.put("searchResultList$key", uiState.value.searchResults)
onSuccess(key)
}
}