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

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, "formatVersion": 1,
"database": { "database": {
"version": 82, "version": 82,
"identityHash": "b11be214ae8a55304244694a180932c9", "identityHash": "285a81996bc58258f99a2f67fb5cf84e",
"entities": [ "entities": [
{ {
"tableName": "books", "tableName": "books",
@@ -1176,7 +1176,7 @@
}, },
{ {
"tableName": "bookmarks", "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": [ "fields": [
{ {
"fieldPath": "time", "fieldPath": "time",
@@ -1194,7 +1194,8 @@
"fieldPath": "bookAuthor", "fieldPath": "bookAuthor",
"columnName": "bookAuthor", "columnName": "bookAuthor",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true,
"defaultValue": "''"
}, },
{ {
"fieldPath": "chapterIndex", "fieldPath": "chapterIndex",
@@ -1427,7 +1428,7 @@
}, },
{ {
"tableName": "readRecordDetail", "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": [ "fields": [
{ {
"fieldPath": "deviceId", "fieldPath": "deviceId",
@@ -1441,6 +1442,13 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "bookAuthor",
"columnName": "bookAuthor",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{ {
"fieldPath": "date", "fieldPath": "date",
"columnName": "date", "columnName": "date",
@@ -1481,13 +1489,14 @@
"columnNames": [ "columnNames": [
"deviceId", "deviceId",
"bookName", "bookName",
"bookAuthor",
"date" "date"
] ]
} }
}, },
{ {
"tableName": "readRecordSession", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -1507,6 +1516,13 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "bookAuthor",
"columnName": "bookAuthor",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{ {
"fieldPath": "startTime", "fieldPath": "startTime",
"columnName": "startTime", "columnName": "startTime",
@@ -1671,7 +1687,7 @@
}, },
{ {
"tableName": "readRecord", "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": [ "fields": [
{ {
"fieldPath": "deviceId", "fieldPath": "deviceId",
@@ -1685,6 +1701,13 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "bookAuthor",
"columnName": "bookAuthor",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{ {
"fieldPath": "readTime", "fieldPath": "readTime",
"columnName": "readTime", "columnName": "readTime",
@@ -1704,7 +1727,8 @@
"autoGenerate": false, "autoGenerate": false,
"columnNames": [ "columnNames": [
"deviceId", "deviceId",
"bookName" "bookName",
"bookAuthor"
] ]
} }
}, },
@@ -2016,7 +2040,7 @@
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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 { 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 { fun search(book: Book, query: String, replaceEnabled: Boolean, regexReplace: Boolean): Flow<List<SearchResult>> = flow {
val chapters = appDb.bookChapterDao.getChapterList(book.bookUrl) val chapters = appDb.bookChapterDao.getChapterList(book.bookUrl)
val totalChapters = chapters.size val totalChapters = chapters.size
@@ -25,7 +33,6 @@ class SearchContentRepository {
val cacheChapterNames = BookHelp.getChapterFiles(book).toHashSet() val cacheChapterNames = BookHelp.getChapterFiles(book).toHashSet()
val allResults = mutableListOf<SearchResult>() val allResults = mutableListOf<SearchResult>()
var lastEmitTime = System.currentTimeMillis()
for (bookChapter in chapters) { for (bookChapter in chapters) {
@@ -49,15 +56,12 @@ class SearchContentRepository {
if (chapterResults.isNotEmpty()) { if (chapterResults.isNotEmpty()) {
allResults.addAll(chapterResults) allResults.addAll(chapterResults)
emit(ArrayList(allResults))
val now = System.currentTimeMillis()
if (now - lastEmitTime > 350L) {
emit(ArrayList(allResults))
lastEmitTime = now
}
} }
} }
} }
lastSearchResults = allResults
lastQueryKey = "${book.bookUrl}-$query"
emit(ArrayList(allResults)) emit(ArrayList(allResults))
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
@@ -1,34 +1,17 @@
package io.legado.app.ui.book.searchContent package io.legado.app.ui.book.searchContent
import android.os.Bundle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.legado.app.ui.theme.AppTheme
import io.legado.app.base.BaseComposeActivity import io.legado.app.base.BaseComposeActivity
import io.legado.app.ui.theme.AppTheme
class SearchContentActivity : BaseComposeActivity() { 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 @Composable
override fun Content() { override fun Content() {
AppTheme { AppTheme {
bookUrl?.let { SearchContentScreen(
SearchContentScreen( onBack = { finish() }
bookUrl = it, )
searchWord = searchWord,
onBack = { finish() }
)
}
} }
} }
} }
@@ -5,6 +5,7 @@ import android.content.Intent
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
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.layout.wrapContentSize 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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons 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.Code
import androidx.compose.material.icons.filled.CollectionsBookmark
import androidx.compose.material.icons.filled.FindReplace 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.filled.Stop
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -29,7 +40,9 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -43,38 +56,43 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.AnimatedText
import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.EmptyMessageView
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.SearchBarSection 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.button.TopbarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun SearchContentScreen( fun SearchContentScreen(
bookUrl: String,
searchWord: String?,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: SearchContentViewModel = koinViewModel() viewModel: SearchContentViewModel = koinViewModel()
) { ) {
val activity = LocalActivity.current val activity = LocalActivity.current
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState() 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 isSearching = uiState.isSearching
val searchResults = uiState.searchResults val searchResults = uiState.searchResults
@@ -82,49 +100,41 @@ fun SearchContentScreen(
val error = uiState.error val error = uiState.error
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
var searchQuery by remember(searchWord) { mutableStateOf(searchWord ?: "") } val scrollToCurrentChapter = {
var replaceEnabled by remember { mutableStateOf(false) } val targetIndex = searchResults.indexOfFirst { it.chapterIndex == durChapterIndex }
var regexReplace by remember { mutableStateOf(false) } if (targetIndex != -1) {
scope.launch {
LaunchedEffect(bookUrl, searchWord) { listState.animateScrollToItem(targetIndex)
viewModel.initBook(bookUrl) }
if (!searchWord.isNullOrBlank()) {
viewModel.startSearch(searchWord, replaceEnabled, regexReplace)
} }
} }
/* LaunchedEffect(searchResults) {
LaunchedEffect(Unit) { if (searchResults.isNotEmpty() && viewModel.shouldAutoScroll()) {
viewModel.effect.collect { effect -> val targetIndex = searchResults.indexOfFirst { it.chapterIndex == durChapterIndex }
when (effect) { if (targetIndex != -1) {
is SearchUiEffect.OpenSearchResult -> { snapshotFlow { listState.layoutInfo.totalItemsCount }.collect { count ->
navController.previousBackStackEntry if (count > targetIndex) {
?.savedStateHandle listState.animateScrollToItem(targetIndex)
?.set("searchResult", effect.result) viewModel.markScrollDone()
navController.previousBackStackEntry return@collect
?.savedStateHandle }
?.set("searchResultList", effect.allResults)
navController.previousBackStackEntry
?.savedStateHandle
?.set("searchResultIndex", effect.index)
navController.popBackStack()
} }
} }
} }
}*/ }
val contentState = when { val contentState = when {
error != null -> SearchContentState.Error(error) error != null -> SearchContentState.Error(error)
isSearching -> SearchContentState.Loading isSearching -> SearchContentState.Loading
searchQuery.isBlank() -> SearchContentState.EmptyQuery searchQuery.isBlank() -> SearchContentState.History
searchResults.isEmpty() -> SearchContentState.EmptyResult searchResults.isEmpty() -> SearchContentState.EmptyResult
else -> null else -> null
} }
val listState = rememberLazyListState()
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
@@ -133,51 +143,27 @@ fun SearchContentScreen(
title = { title = {
val title = if (searchQuery.isNotBlank() && searchResults.isNotEmpty()) { val title = if (searchQuery.isNotBlank() && searchResults.isNotEmpty()) {
"${searchResults.size} 条结果" "${searchResults.size} 条结果"
} else { } else "搜索内容"
"搜索内容" AnimatedText(text = title)
}
AnimatedText(
text = title
)
},
navigationIcon = {
TopbarNavigationButton(onClick = onBack)
}, },
navigationIcon = { TopbarNavigationButton(onClick = onBack) },
actions = { actions = {
Row( Row(
modifier = Modifier.padding(end = 12.dp), modifier = Modifier.padding(end = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
AnimatedActionButton( TopBarAnimatedActionButton(
checked = replaceEnabled, checked = replaceEnabled,
onCheckedChange = { onCheckedChange = { viewModel.toggleReplace(it) },
replaceEnabled = it
if (searchQuery.isNotBlank()) {
viewModel.startSearch(
searchQuery,
replaceEnabled,
regexReplace
)
}
},
iconChecked = Icons.Default.FindReplace, iconChecked = Icons.Default.FindReplace,
iconUnchecked = Icons.Default.FindReplace, iconUnchecked = Icons.Default.FindReplace,
activeText = "替换开启", activeText = "替换开启",
inactiveText = "替换关闭" inactiveText = "替换关闭"
) )
AnimatedActionButton( TopBarAnimatedActionButton(
checked = regexReplace, checked = regexReplace,
onCheckedChange = { onCheckedChange = { viewModel.toggleRegex(it) },
regexReplace = it
if (searchQuery.isNotBlank()) {
viewModel.startSearch(
searchQuery,
replaceEnabled,
regexReplace
)
}
},
iconChecked = Icons.Default.Code, iconChecked = Icons.Default.Code,
iconUnchecked = Icons.Default.Code, iconUnchecked = Icons.Default.Code,
activeText = "正则开启", activeText = "正则开启",
@@ -189,33 +175,50 @@ fun SearchContentScreen(
) )
SearchBarSection( SearchBarSection(
query = searchQuery, query = searchQuery,
onQueryChange = { scrollState = listState,
searchQuery = it onQueryChange = { viewModel.onQueryChange(it) }
viewModel.startSearch(searchQuery, replaceEnabled, regexReplace)
}
) )
AnimatedVisibility(visible = contentState == SearchContentState.Loading) { AnimatedVisibility(visible = isSearching) {
LinearProgressIndicator( LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
modifier = Modifier.fillMaxWidth()
)
} }
} }
}, },
floatingActionButton = { floatingActionButton = {
val fabVisible = (isSearching || searchResults.isNotEmpty()) && searchQuery.isNotBlank()
TooltipBox( TooltipBox(
positionProvider = positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), TooltipAnchorPosition.Above
tooltip = { PlainTooltip { Text("Localized description") } }, ),
tooltip = {
PlainTooltip {
Text(if (isSearching) "停止搜索" else "跳转到当前章节")
}
},
state = rememberTooltipState(), state = rememberTooltipState(),
) { ) {
FloatingActionButton( FloatingActionButton(
modifier = Modifier.animateFloatingActionButton( modifier = Modifier.animateFloatingActionButton(
visible = isSearching, visible = fabVisible,
alignment = Alignment.BottomEnd, 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() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
AnimatedContent( AnimatedContent(
targetState = contentState, targetState = contentState,
label = "SearchContentTransition" label = "SearchContentTransition",
modifier = Modifier.weight(1f)
) { state -> ) { state ->
when (state) { when (state) {
is SearchContentState.Error -> { is SearchContentState.Error -> {
@@ -240,15 +243,16 @@ fun SearchContentScreen(
) )
} }
SearchContentState.EmptyQuery -> { SearchContentState.History -> {
EmptyMessageView( SearchHistoryList(
message = "请输入关键词开始搜索", history = searchHistory,
modifier = Modifier onlyThisBook = historyOnlyThisBook,
.fillMaxSize() onHistoryClick = { viewModel.onQueryChange(it.query) },
.wrapContentSize() onDeleteHistory = { viewModel.deleteHistory(it) },
onClearHistory = { viewModel.clearHistory() },
onToggleScope = { viewModel.toggleHistoryScope() }
) )
} }
SearchContentState.EmptyResult -> { SearchContentState.EmptyResult -> {
EmptyMessageView( EmptyMessageView(
message = "没有找到相关内容!", 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 -> {} else -> {}
} }
} }
}
}
}
FastScrollLazyColumn( @OptIn(ExperimentalMaterial3ExpressiveApi::class)
state = listState, @Composable
modifier = Modifier.weight(1f) fun SearchHistoryList(
) { history: List<SearchContentHistory>,
itemsIndexed(searchResults) { index, result -> onlyThisBook: Boolean,
SearchResultItem( onHistoryClick: (SearchContentHistory) -> Unit,
modifier = Modifier.animateItem(), onDeleteHistory: (SearchContentHistory) -> Unit,
result = result, onClearHistory: () -> Unit,
isCurrentChapter = result.chapterIndex == durChapterIndex, onToggleScope: () -> Unit
onClick = { ) {
viewModel.onSearchResultClick(result, index) { key -> Column(modifier = Modifier.fillMaxSize()) {
val intent = Intent().apply { Box(
putExtra("key", key) modifier = Modifier
putExtra("index", index) .fillMaxWidth()
} .padding(horizontal = 16.dp, vertical = 8.dp)
activity?.setResult(Activity.RESULT_OK, intent) ) {
activity?.finish() 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 package io.legado.app.ui.book.searchContent
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.constant.EventBus 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.SearchContentHistory
import io.legado.app.data.repository.BookRepository import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.SearchContentRepository import io.legado.app.data.repository.SearchContentRepository
import io.legado.app.help.IntentData import io.legado.app.help.IntentData
import io.legado.app.utils.postEvent import io.legado.app.utils.postEvent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,70 +33,120 @@ data class SearchUiState(
val error: Throwable? = null val error: Throwable? = null
) )
sealed interface SearchUiEffect {
data class OpenSearchResult(
val result: SearchResult,
val index: Int,
val allResults: List<SearchResult>
) : SearchUiEffect
}
sealed interface SearchContentState { sealed interface SearchContentState {
data object Loading : SearchContentState data object Loading : SearchContentState
data object EmptyQuery : SearchContentState data object History : SearchContentState
data object EmptyResult : SearchContentState data object EmptyResult : SearchContentState
data class Error(val throwable: Throwable) : SearchContentState data class Error(val throwable: Throwable) : SearchContentState
} }
class SearchContentViewModel( class SearchContentViewModel(
savedStateHandle: SavedStateHandle,
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val searchContentRepository: SearchContentRepository 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()) private val _uiState = MutableStateFlow(SearchUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
private val _effect = MutableSharedFlow<SearchUiEffect>() private val _historyOnlyThisBook = MutableStateFlow(true)
val effect = _effect.asSharedFlow() 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 private var searchJob: Job? = null
fun initBook(bookUrl: String) { init {
initBook()
}
private fun initBook() {
viewModelScope.launch { viewModelScope.launch {
val book = bookRepository.getBook(bookUrl) val book = bookRepository.getBook(bookUrl)
_uiState.value = _uiState.value.copy( val cachedResults = searchContentRepository.getCache(bookUrl, _searchQuery.value)
_uiState.update {
it.copy(
book = book, 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() searchJob?.cancel()
val query = _searchQuery.value
val replace = _replaceEnabled.value
val regex = _regexReplace.value
if (query.isBlank()) { if (query.isBlank()) {
_uiState.update { it.copy( _uiState.update {
isSearching = false, it.copy(
searchResults = emptyList(), isSearching = false,
error = null searchResults = emptyList(),
)} error = null
)
}
return return
} }
searchJob = viewModelScope.launch { searchJob = viewModelScope.launch {
_uiState.value.book?.let { book -> _uiState.value.book?.let { book ->
saveSearchHistory(book, query)
searchContentRepository searchContentRepository
.search(book, query, replaceEnabled, regexReplace) .search(book, query, replace, regex)
.onStart { .onStart { _uiState.update { it.copy(isSearching = true, error = null) } }
_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) } }
.onCompletion {
_uiState.update { it.copy(isSearching = false) }
}
.catch { e ->
_uiState.update { it.copy(isSearching = false, error = e) }
}
.collect { results -> .collect { results ->
_uiState.update { it.copy(searchResults = results) } _uiState.update { it.copy(searchResults = results) }
} }
@@ -98,24 +154,42 @@ class SearchContentViewModel(
} }
} }
fun stopSearch() { private suspend fun saveSearchHistory(book: Book, query: String) {
searchJob?.cancel() 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) { fun deleteHistory(history: SearchContentHistory) {
searchJob?.cancel()
viewModelScope.launch { viewModelScope.launch {
_effect.emit( appDb.searchContentHistoryDao.delete(history.id)
SearchUiEffect.OpenSearchResult(
result = result,
index = index,
allResults = _uiState.value.searchResults
)
)
} }
} }
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() stopSearch()
postEvent(EventBus.SEARCH_RESULT, uiState.value.searchResults) postEvent(EventBus.SEARCH_RESULT, uiState.value.searchResults)
val key = System.currentTimeMillis() val key = System.currentTimeMillis()
@@ -123,5 +197,4 @@ class SearchContentViewModel(
IntentData.put("searchResultList$key", uiState.value.searchResults) IntentData.put("searchResultList$key", uiState.value.searchResults)
onSuccess(key) onSuccess(key)
} }
} }