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