[优化] 新增正文搜索记录
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user