Przeglądaj źródła

增加搜素分源布局

HapeLee 5 miesięcy temu
rodzic
commit
df2aaff0e2

+ 14 - 9
app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt

@@ -6,6 +6,7 @@ import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.BookSearchScope
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.model.webBook.WebBook
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CancellationException
@@ -30,7 +31,7 @@ data class BookSearchRequest(
     val keyword: String,
     val keyword: String,
     val page: Int,
     val page: Int,
     val scope: BookSearchScope,
     val scope: BookSearchScope,
-    val precision: Boolean,
+    val matchMode: MatchMode,
     val concurrency: Int,
     val concurrency: Int,
     val types: Set<Int>? = null,
     val types: Set<Int>? = null,
 )
 )
@@ -103,7 +104,7 @@ class SearchBooksUseCase(
             throw NoStackTraceException("可搜索书源为空")
             throw NoStackTraceException("可搜索书源为空")
         }
         }
 
 
-        val merger = SearchResultMerger(keyword, request.precision)
+        val merger = SearchResultMerger(keyword, request.matchMode)
         val concurrency = request.concurrency.coerceAtLeast(1)
         val concurrency = request.concurrency.coerceAtLeast(1)
         var hasMore = false
         var hasMore = false
         var processedSources = 0
         var processedSources = 0
@@ -116,7 +117,7 @@ class SearchBooksUseCase(
             .flatMapMerge(concurrency) { searchableSource ->
             .flatMapMerge(concurrency) { searchableSource ->
                 flow {
                 flow {
                     control.awaitResumed()
                     control.awaitResumed()
-                    emit(searchSource(searchableSource, keyword, request.page, request.precision))
+                    emit(searchSource(searchableSource, keyword, request.page, request.matchMode))
                 }.flowOn(Dispatchers.IO)
                 }.flowOn(Dispatchers.IO)
             }
             }
             .collect { result ->
             .collect { result ->
@@ -177,7 +178,7 @@ class SearchBooksUseCase(
         searchableSource: SearchableSource,
         searchableSource: SearchableSource,
         keyword: String,
         keyword: String,
         page: Int,
         page: Int,
-        precision: Boolean,
+        matchMode: MatchMode,
     ): SourceSearchResult {
     ): SourceSearchResult {
         return try {
         return try {
             val source = searchableSource.source
             val source = searchableSource.source
@@ -191,7 +192,7 @@ class SearchBooksUseCase(
                     keyword,
                     keyword,
                     page,
                     page,
                     filter = { name, author ->
                     filter = { name, author ->
-                        !precision ||
+                        matchMode == MatchMode.DEFAULT ||
                             name.contains(keyword, ignoreCase = true) ||
                             name.contains(keyword, ignoreCase = true) ||
                             author.contains(keyword, ignoreCase = true)
                             author.contains(keyword, ignoreCase = true)
                     }
                     }
@@ -222,7 +223,7 @@ class SearchBooksUseCase(
 
 
     private class SearchResultMerger(
     private class SearchResultMerger(
         private val keyword: String,
         private val keyword: String,
-        private val precision: Boolean,
+        private val matchMode: MatchMode,
     ) {
     ) {
         private companion object {
         private companion object {
             const val MAX_RETAINED_SEARCH_RESULTS = 1000
             const val MAX_RETAINED_SEARCH_RESULTS = 1000
@@ -267,9 +268,13 @@ class SearchBooksUseCase(
                 book.name.equals(keyword, ignoreCase = true) ||
                 book.name.equals(keyword, ignoreCase = true) ||
                     book.author.equals(keyword, ignoreCase = true) -> equalBooks
                     book.author.equals(keyword, ignoreCase = true) -> equalBooks
                 book.name.contains(keyword, ignoreCase = true) ||
                 book.name.contains(keyword, ignoreCase = true) ||
-                    book.author.contains(keyword, ignoreCase = true) -> containsBooks
-                !precision -> otherBooks
-                else -> null
+                        book.author.contains(
+                            keyword,
+                            ignoreCase = true
+                        ) -> if (matchMode == MatchMode.EXACT) null else containsBooks
+
+                matchMode != MatchMode.DEFAULT -> null
+                else -> otherBooks
             }
             }
         }
         }
 
 

+ 4 - 5
app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt

@@ -9,8 +9,6 @@ import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Check
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
@@ -27,8 +25,9 @@ import io.legado.app.R
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.SearchBar
-import io.legado.app.ui.widget.components.button.MediumIconButton
+import io.legado.app.ui.widget.components.button.MediumPlainButton
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
+import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 
 
@@ -68,9 +67,9 @@ fun ScopeSelectSheet(
         title = title,
         title = title,
         endAction = onConfirm?.let {
         endAction = onConfirm?.let {
             {
             {
-                MediumIconButton(
+                MediumPlainButton(
                     onClick = it,
                     onClick = it,
-                    imageVector = Icons.Default.Check
+                    icon = AppIcons.Settings
                 )
                 )
             }
             }
         }
         }

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt

@@ -54,7 +54,7 @@ class SearchActivity : BaseComposeActivity() {
             },
             },
             onOpenSourceManage = {
             onOpenSourceManage = {
                 startActivity<BookSourceActivity>()
                 startActivity<BookSourceActivity>()
-            }
+            },
         )
         )
     }
     }
 
 

+ 26 - 11
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -5,7 +5,10 @@ import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import io.legado.app.ui.main.bookshelf.BookShelfItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
 
 
 @Stable
 @Stable
 data class SearchResultItemUi(
 data class SearchResultItemUi(
@@ -17,17 +20,17 @@ data class SearchResultItemUi(
 data class SearchUiState(
 data class SearchUiState(
     val query: String = "",
     val query: String = "",
     val committedQuery: String = "",
     val committedQuery: String = "",
-    val results: List<SearchResultItemUi> = emptyList(),
-    val history: List<SearchKeyword> = emptyList(),
-    val bookshelfHints: List<BookShelfItem> = emptyList(),
-    val enabledGroups: List<String> = emptyList(),
-    val enabledSources: List<BookSourcePart> = emptyList(),
+    val results: ImmutableList<SearchResultItemUi> = persistentListOf(),
+    val history: ImmutableList<SearchKeyword> = persistentListOf(),
+    val bookshelfHints: ImmutableList<BookShelfItem> = persistentListOf(),
+    val enabledGroups: ImmutableList<String> = persistentListOf(),
+    val enabledSources: ImmutableList<BookSourcePart> = persistentListOf(),
     val scopeDisplay: String = "",
     val scopeDisplay: String = "",
-    val scopeDisplayNames: List<String> = emptyList(),
+    val scopeDisplayNames: ImmutableList<String> = persistentListOf(),
     val selectedScopeSourceUrls: Set<String> = emptySet(),
     val selectedScopeSourceUrls: Set<String> = emptySet(),
     val isAllScope: Boolean = true,
     val isAllScope: Boolean = true,
     val isSourceScope: Boolean = false,
     val isSourceScope: Boolean = false,
-    val isPrecisionSearch: Boolean = false,
+    val matchMode: MatchMode = MatchMode.DEFAULT,
     val isSearching: Boolean = false,
     val isSearching: Boolean = false,
     val isManualStop: Boolean = false,
     val isManualStop: Boolean = false,
     val hasMore: Boolean = true,
     val hasMore: Boolean = true,
@@ -35,17 +38,24 @@ data class SearchUiState(
     val totalSources: Int = 0,
     val totalSources: Int = 0,
     val selectedSourceTypes: Set<Int> = emptySet(),
     val selectedSourceTypes: Set<Int> = emptySet(),
     val showScopeSheet: Boolean = false,
     val showScopeSheet: Boolean = false,
-    val showTypeSheet: Boolean = false,
+    val showSettingsSheet: Boolean = false,
     val showClearHistoryDialog: Boolean = false,
     val showClearHistoryDialog: Boolean = false,
     val showSuggestions: Boolean = true,
     val showSuggestions: Boolean = true,
     val emptyScopeAction: SearchEmptyScopeAction? = null,
     val emptyScopeAction: SearchEmptyScopeAction? = null,
     val savedScrollIndex: Int = 0,
     val savedScrollIndex: Int = 0,
     val savedScrollOffset: Int = 0,
     val savedScrollOffset: Int = 0,
+    val expandedSourceUrl: String? = null,
+    val expandedSourceName: String? = null,
+    val expandedSourceBooks: ImmutableList<SearchBook> = persistentListOf(),
+    val expandedSourceLoading: Boolean = false,
+    val expandedSourceEnd: Boolean = false,
+    val expandedSourceError: String? = null,
+    val expandedSourcePage: Int = 1,
 )
 )
 
 
 data class SearchEmptyScopeAction(
 data class SearchEmptyScopeAction(
     val scopeDisplay: String,
     val scopeDisplay: String,
-    val wasPrecisionSearch: Boolean,
+    val wasMatchMode: MatchMode,
 )
 )
 
 
 sealed interface SearchIntent {
 sealed interface SearchIntent {
@@ -60,17 +70,22 @@ sealed interface SearchIntent {
     data class UseHistoryKeyword(val keyword: String) : SearchIntent
     data class UseHistoryKeyword(val keyword: String) : SearchIntent
     data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
     data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
     data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
     data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
+    data class ExpandSource(val sourceUrl: String, val sourceName: String) : SearchIntent
+    data object DismissExpandedSource : SearchIntent
+    data object LoadMoreExpandedSource : SearchIntent
+    data class OpenExpandedSourceBook(val book: SearchBook, val sharedCoverKey: String?) :
+        SearchIntent
     data class DeleteHistory(val item: SearchKeyword) : SearchIntent
     data class DeleteHistory(val item: SearchKeyword) : SearchIntent
     data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
     data class SetClearHistoryDialogVisible(val visible: Boolean) : SearchIntent
     data object ConfirmClearHistory : SearchIntent
     data object ConfirmClearHistory : SearchIntent
     data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
     data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
-    data class SetTypeSheetVisible(val visible: Boolean) : SearchIntent
+    data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
     data class ToggleSourceType(val type: Int) : SearchIntent
     data class ToggleSourceType(val type: Int) : SearchIntent
     data object SelectAllScope : SearchIntent
     data object SelectAllScope : SearchIntent
     data class ToggleScopeGroup(val groupName: String) : SearchIntent
     data class ToggleScopeGroup(val groupName: String) : SearchIntent
     data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
     data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
     data class RemoveScopeItem(val scopeName: String) : SearchIntent
     data class RemoveScopeItem(val scopeName: String) : SearchIntent
-    data class TogglePrecision(val enabled: Boolean) : SearchIntent
+    data class SetMatchMode(val mode: MatchMode) : SearchIntent
     data object ConfirmEmptyScopeAction : SearchIntent
     data object ConfirmEmptyScopeAction : SearchIntent
     data object DismissEmptyScopeAction : SearchIntent
     data object DismissEmptyScopeAction : SearchIntent
     data object OpenSourceManage : SearchIntent
     data object OpenSourceManage : SearchIntent

+ 312 - 103
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -20,8 +20,10 @@ 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.automirrored.outlined.FormatListBulleted
 import androidx.compose.material.icons.filled.Book
 import androidx.compose.material.icons.filled.Book
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.Layers
 import androidx.compose.material.icons.filled.Layers
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material.icons.filled.Stop
@@ -54,6 +56,7 @@ import io.legado.app.R
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
@@ -63,12 +66,12 @@ import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.LoadMoreFooter
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.book.SearchBookListItem
 import io.legado.app.ui.widget.components.book.SearchBookListItem
 import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
 import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
-import io.legado.app.ui.widget.components.button.SmallIconButton
-import io.legado.app.ui.widget.components.button.SmallTextButton
+import io.legado.app.ui.widget.components.button.SmallPlainButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -76,11 +79,11 @@ import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
 import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
-import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
@@ -100,6 +103,8 @@ fun SearchScreen(
 ) {
 ) {
     val context = LocalContext.current
     val context = LocalContext.current
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val searchLayoutMode by viewModel.searchLayoutMode.collectAsStateWithLifecycle()
+    val isSourceGroupedMode = searchLayoutMode == 1
     var previewBook by remember { mutableStateOf<SearchBook?>(null) }
     var previewBook by remember { mutableStateOf<SearchBook?>(null) }
     var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
     var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
     val listState = rememberLazyListState()
     val listState = rememberLazyListState()
@@ -149,13 +154,15 @@ fun SearchScreen(
         state.hasMore,
         state.hasMore,
         state.isManualStop,
         state.isManualStop,
         state.showSuggestions,
         state.showSuggestions,
+        isSourceGroupedMode,
     ) {
     ) {
         if (
         if (
             shouldLoadMore &&
             shouldLoadMore &&
             !state.isSearching &&
             !state.isSearching &&
             state.hasMore &&
             state.hasMore &&
             !state.isManualStop &&
             !state.isManualStop &&
-            !state.showSuggestions
+            !state.showSuggestions &&
+            !isSourceGroupedMode
         ) {
         ) {
             viewModel.onIntent(SearchIntent.LoadMore)
             viewModel.onIntent(SearchIntent.LoadMore)
         }
         }
@@ -261,32 +268,15 @@ fun SearchScreen(
                         )
                         )
                     },
                     },
                     actions = {
                     actions = {
-                        TopBarActionButton(
-                            onClick = {
-                                viewModel.onIntent(SearchIntent.OpenSourceManage)
-                            },
-                            imageVector = AppIcons.Settings,
-                            contentDescription = stringResource(R.string.book_source_manage)
-                        )
-                        TopBarAnimatedActionButton(
-                            checked = state.isPrecisionSearch,
-                            onCheckedChange = { checked ->
-                                viewModel.onIntent(SearchIntent.TogglePrecision(checked))
-                            },
-                            iconChecked = AppIcons.PrecisionSearch,
-                            iconUnchecked = AppIcons.UnPrecisionSearch,
-                            activeText = stringResource(R.string.precision_search),
-                            inactiveText = stringResource(R.string.search),
-                        )
                         TopBarAnimatedActionButton(
                         TopBarAnimatedActionButton(
-                            checked = state.selectedSourceTypes.isNotEmpty(),
+                            checked = isSourceGroupedMode || state.matchMode == MatchMode.EXACT || state.selectedSourceTypes.isNotEmpty(),
                             onCheckedChange = {
                             onCheckedChange = {
-                                viewModel.onIntent(SearchIntent.SetTypeSheetVisible(true))
+                                viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(true))
                             },
                             },
-                            iconChecked = Icons.Default.Layers,
-                            iconUnchecked = Icons.Default.Layers,
-                            activeText = "搜素类型",
-                            inactiveText = "搜素类型",
+                            iconChecked = AppIcons.Settings,
+                            iconUnchecked = AppIcons.Settings,
+                            activeText = stringResource(R.string.setting),
+                            inactiveText = stringResource(R.string.setting),
                         )
                         )
                         TopBarAnimatedActionButton(
                         TopBarAnimatedActionButton(
                             checked = !state.isAllScope,
                             checked = !state.isAllScope,
@@ -296,7 +286,7 @@ fun SearchScreen(
                             iconChecked = AppIcons.Filter,
                             iconChecked = AppIcons.Filter,
                             iconUnchecked = AppIcons.Filter,
                             iconUnchecked = AppIcons.Filter,
                             activeText = stringResource(R.string.screen),
                             activeText = stringResource(R.string.screen),
-                            inactiveText = stringResource(R.string.screen),
+                            inactiveText = stringResource(R.string.screen)
                         )
                         )
                     },
                     },
                     scrollBehavior = scrollBehavior
                     scrollBehavior = scrollBehavior
@@ -315,12 +305,13 @@ fun SearchScreen(
                         placeholder = searchLabel,
                         placeholder = searchLabel,
                         trailingIcon = {
                         trailingIcon = {
                             if (queryInput.isNotEmpty()) {
                             if (queryInput.isNotEmpty()) {
-                                TopBarActionButton(
+                                SmallPlainButton(
+                                    modifier = Modifier.padding(horizontal = 8.dp),
                                     onClick = {
                                     onClick = {
                                         queryInput = ""
                                         queryInput = ""
                                         viewModel.onIntent(SearchIntent.UpdateQuery(""))
                                         viewModel.onIntent(SearchIntent.UpdateQuery(""))
                                     },
                                     },
-                                    imageVector = AppIcons.Close,
+                                    icon = AppIcons.Close,
                                     contentDescription = stringResource(R.string.clear)
                                     contentDescription = stringResource(R.string.clear)
                                 )
                                 )
                             }
                             }
@@ -394,52 +385,124 @@ fun SearchScreen(
                         }
                         }
 
 
                         if (state.results.isNotEmpty()) {
                         if (state.results.isNotEmpty()) {
-                            LazyColumn(
+                            val sourceGroupedResults = remember(state.results) {
+                                state.results
+                                    .groupBy { it.book.origin }
+                                    .map { (origin, books) ->
+                                        SourceGroup(
+                                            origin = origin,
+                                            sourceName = books.firstOrNull()?.book?.originName?.takeIf { it.isNotBlank() }
+                                                ?: origin,
+                                            items = books
+                                        )
+                                    }
+                            }
+
+                            AnimatedContent(
+                                targetState = isSourceGroupedMode,
+                                label = "SearchLayoutTransition",
                                 modifier = Modifier.fillMaxSize(),
                                 modifier = Modifier.fillMaxSize(),
-                                state = listState,
-                                contentPadding = adaptiveContentPaddingOnlyVertical(
-                                    top = 48.dp,
-                                    bottom = 8.dp
-                                ),
-                                verticalArrangement = Arrangement.spacedBy(6.dp)
-                            ) {
-                                itemsIndexed(
-                                    items = state.results,
-                                    key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
-                                ) { index, item ->
-                                    val sharedCoverKey = bookCoverSharedElementKey(
-                                        item.book.bookUrl,
-                                        "search:${item.book.origin}:$index"
-                                    )
-                                    SearchBookListItem(
-                                        book = item.book,
-                                        shelfState = item.shelfState,
-                                        onClick = {
-                                            viewModel.onIntent(
-                                                SearchIntent.OpenSearchBook(
-                                                    item.book,
-                                                    sharedCoverKey
+                            ) { isSourceGrouped ->
+                                if (isSourceGrouped) {
+                                    LazyColumn(
+                                        modifier = Modifier.fillMaxSize(),
+                                        contentPadding = adaptiveContentPaddingOnlyVertical(
+                                            top = 48.dp,
+                                            bottom = 8.dp
+                                        ),
+                                        verticalArrangement = Arrangement.spacedBy(4.dp),
+                                    ) {
+                                        sourceGroupedResults.forEachIndexed { groupIndex, group ->
+                                            item(key = "header_${group.origin}") {
+                                                SearchSourceSection(
+                                                    sourceName = group.sourceName,
+                                                    items = group.items,
+                                                    onClickBook = { book, coverKey ->
+                                                        viewModel.onIntent(
+                                                            SearchIntent.OpenSearchBook(
+                                                                book,
+                                                                coverKey
+                                                            )
+                                                        )
+                                                    },
+                                                    onLongClickBook = { book, coverKey ->
+                                                        previewBook = book
+                                                        previewSharedCoverKey = coverKey
+                                                    },
+                                                    onViewAll = {
+                                                        viewModel.onIntent(
+                                                            SearchIntent.ExpandSource(
+                                                                group.origin,
+                                                                group.sourceName
+                                                            )
+                                                        )
+                                                    },
+                                                    sharedTransitionScope = sharedTransitionScope,
+                                                    animatedVisibilityScope = animatedVisibilityScope,
+                                                    sourceSectionIndex = groupIndex,
                                                 )
                                                 )
+                                            }
+                                        }
+
+                                        item {
+                                            SearchResultFooter(
+                                                isSearching = state.isSearching,
+                                                hasMore = state.hasMore,
+                                                hasResult = true,
+                                                committedQuery = state.committedQuery,
+                                                onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
                                             )
                                             )
-                                        },
-                                        onLongClick = { book, coverKey ->
-                                            previewBook = book
-                                            previewSharedCoverKey = coverKey
-                                        },
-                                        sharedTransitionScope = sharedTransitionScope,
-                                        animatedVisibilityScope = animatedVisibilityScope,
-                                        sharedCoverKey = sharedCoverKey
-                                    )
-                                }
-
-                                item {
-                                    SearchResultFooter(
-                                        isSearching = state.isSearching,
-                                        hasMore = state.hasMore,
-                                        hasResult = true,
-                                        committedQuery = state.committedQuery,
-                                        onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
-                                    )
+                                        }
+                                    }
+                                } else {
+                                    LazyColumn(
+                                        modifier = Modifier.fillMaxSize(),
+                                        state = listState,
+                                        contentPadding = adaptiveContentPaddingOnlyVertical(
+                                            top = 48.dp,
+                                            bottom = 8.dp
+                                        ),
+                                        verticalArrangement = Arrangement.spacedBy(8.dp)
+                                    ) {
+                                        itemsIndexed(
+                                            items = state.results,
+                                            key = { index, item -> "${item.book.origin}:${item.book.bookUrl}:$index" }
+                                        ) { index, item ->
+                                            val sharedCoverKey = bookCoverSharedElementKey(
+                                                item.book.bookUrl,
+                                                "search:${item.book.origin}:$index"
+                                            )
+                                            SearchBookListItem(
+                                                book = item.book,
+                                                shelfState = item.shelfState,
+                                                onClick = {
+                                                    viewModel.onIntent(
+                                                        SearchIntent.OpenSearchBook(
+                                                            item.book,
+                                                            sharedCoverKey
+                                                        )
+                                                    )
+                                                },
+                                                onLongClick = { book, coverKey ->
+                                                    previewBook = book
+                                                    previewSharedCoverKey = coverKey
+                                                },
+                                                sharedTransitionScope = sharedTransitionScope,
+                                                animatedVisibilityScope = animatedVisibilityScope,
+                                                sharedCoverKey = sharedCoverKey
+                                            )
+                                        }
+
+                                        item {
+                                            SearchResultFooter(
+                                                isSearching = state.isSearching,
+                                                hasMore = state.hasMore,
+                                                hasResult = true,
+                                                committedQuery = state.committedQuery,
+                                                onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
+                                            )
+                                        }
+                                    }
                                 }
                                 }
                             }
                             }
                         }
                         }
@@ -502,7 +565,7 @@ fun SearchScreen(
         },
         },
         title = stringResource(R.string.draw),
         title = stringResource(R.string.draw),
         textProvider = {
         textProvider = {
-            if (wasPrecisionSearch) {
+            if (wasMatchMode == MatchMode.EXACT) {
                 stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay)
                 stringResource(R.string.search_empty_scope_disable_precision, scopeDisplay)
             } else {
             } else {
                 stringResource(R.string.search_empty_scope_switch_all, scopeDisplay)
                 stringResource(R.string.search_empty_scope_switch_all, scopeDisplay)
@@ -525,46 +588,101 @@ fun SearchScreen(
         sources = state.enabledSources,
         sources = state.enabledSources,
         selectedSources = state.selectedScopeSourceUrls,
         selectedSources = state.selectedScopeSourceUrls,
         onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
         onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
-        isSourceScope = state.isSourceScope
+        isSourceScope = state.isSourceScope,
+        onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
     )
     )
 
 
     AppModalBottomSheet(
     AppModalBottomSheet(
-        show = state.showTypeSheet,
-        onDismissRequest = { viewModel.onIntent(SearchIntent.SetTypeSheetVisible(false)) },
-        title = "搜素类型",
+        show = state.showSettingsSheet,
+        onDismissRequest = { viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(false)) },
+        title = stringResource(R.string.setting),
     ) {
     ) {
-        Column {
-            SelectionItemCard(
-                title = stringResource(R.string.all),
-                isSelected = state.selectedSourceTypes.isEmpty(),
-                containerColor = LegadoTheme.colorScheme.onSheetContent,
-                inSelectionMode = true,
-                onToggleSelection = {
-                    if (state.selectedSourceTypes.isNotEmpty()) {
-                        state.selectedSourceTypes.forEach {
-                            viewModel.onIntent(SearchIntent.ToggleSourceType(it))
-                        }
+        Column(
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            CompactDropdownSettingItem(
+                title = stringResource(R.string.layout_mode),
+                selectedValue = searchLayoutMode.toString(),
+                displayEntries = arrayOf(
+                    stringResource(R.string.search_layout_source_grouped),
+                    stringResource(R.string.search_layout_list)
+                ),
+                entryValues = arrayOf("1", "0"),
+                imageVector = if (isSourceGroupedMode) Icons.Default.GridView else Icons.AutoMirrored.Outlined.FormatListBulleted,
+                onValueChange = { newValue ->
+                    if (newValue.toInt() != searchLayoutMode) {
+                        viewModel.toggleSearchLayout()
                     }
                     }
                 }
                 }
             )
             )
 
 
-            Spacer(modifier = Modifier.height(8.dp))
+            CompactDropdownSettingItem(
+                title = stringResource(R.string.precision_search),
+                selectedValue = state.matchMode.value.toString(),
+                displayEntries = arrayOf(
+                    stringResource(R.string.precision_search),
+                    stringResource(R.string.search)
+                ),
+                entryValues = arrayOf(
+                    MatchMode.EXACT.value.toString(),
+                    MatchMode.DEFAULT.value.toString()
+                ),
+                imageVector = if (state.matchMode == MatchMode.EXACT) AppIcons.PrecisionSearch else AppIcons.UnPrecisionSearch,
+                onValueChange = { newValue ->
+                    val mode = MatchMode.of(newValue.toInt())
+                    if (mode != state.matchMode) {
+                        viewModel.onIntent(SearchIntent.SetMatchMode(mode))
+                    }
+                }
+            )
 
 
-            listOf(
-                0 to stringResource(R.string.noval),
-                2 to stringResource(R.string.manga),
-                1 to stringResource(R.string.audio),
-            ).forEach { (type, label) ->
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(vertical = 12.dp),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                AppIcon(Icons.Default.Layers, contentDescription = null)
+                Spacer(modifier = Modifier.width(8.dp))
+                AppText(
+                    text = "搜索类型",
+                    style = LegadoTheme.typography.titleSmall
+                )
+            }
+
+            Column(
+                modifier = Modifier.padding(horizontal = 16.dp),
+                verticalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
                 SelectionItemCard(
                 SelectionItemCard(
-                    title = label,
-                    isSelected = state.selectedSourceTypes.contains(type),
+                    title = stringResource(R.string.all),
+                    isSelected = state.selectedSourceTypes.isEmpty(),
                     containerColor = LegadoTheme.colorScheme.onSheetContent,
                     containerColor = LegadoTheme.colorScheme.onSheetContent,
                     inSelectionMode = true,
                     inSelectionMode = true,
                     onToggleSelection = {
                     onToggleSelection = {
-                        viewModel.onIntent(SearchIntent.ToggleSourceType(type))
+                        if (state.selectedSourceTypes.isNotEmpty()) {
+                            state.selectedSourceTypes.forEach {
+                                viewModel.onIntent(SearchIntent.ToggleSourceType(it))
+                            }
+                        }
                     }
                     }
                 )
                 )
-                Spacer(modifier = Modifier.height(4.dp))
+
+                listOf(
+                    0 to stringResource(R.string.noval),
+                    2 to stringResource(R.string.manga),
+                    1 to stringResource(R.string.audio),
+                ).forEach { (type, label) ->
+                    SelectionItemCard(
+                        title = label,
+                        isSelected = state.selectedSourceTypes.contains(type),
+                        containerColor = LegadoTheme.colorScheme.onSheetContent,
+                        inSelectionMode = true,
+                        onToggleSelection = {
+                            viewModel.onIntent(SearchIntent.ToggleSourceType(type))
+                        }
+                    )
+                }
             }
             }
 
 
             Spacer(modifier = Modifier.height(20.dp))
             Spacer(modifier = Modifier.height(20.dp))
@@ -588,6 +706,24 @@ fun SearchScreen(
             viewModel.onAddToShelf(book)
             viewModel.onAddToShelf(book)
         },
         },
     )
     )
+
+    ExpandedSourceSheet(
+        show = state.expandedSourceUrl != null,
+        sourceName = state.expandedSourceName ?: "",
+        books = state.expandedSourceBooks,
+        isLoading = state.expandedSourceLoading,
+        isEnd = state.expandedSourceEnd,
+        errorMsg = state.expandedSourceError,
+        onDismiss = { viewModel.onIntent(SearchIntent.DismissExpandedSource) },
+        onLoadMore = { viewModel.onIntent(SearchIntent.LoadMoreExpandedSource) },
+        onBookClick = { book, coverKey ->
+            viewModel.onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey))
+        },
+        onBookLongClick = { book, coverKey ->
+            previewBook = book
+            previewSharedCoverKey = coverKey
+        },
+    )
 }
 }
 
 
 private data class SearchFloatingSummary(
 private data class SearchFloatingSummary(
@@ -653,10 +789,10 @@ private fun SearchSuggestionPanel(
                 }
                 }
 
 
                 if (state.history.isNotEmpty()) {
                 if (state.history.isNotEmpty()) {
-                    SmallTextButton(
+                    SmallPlainButton(
                         onClick = onClearHistory,
                         onClick = onClearHistory,
                         text = stringResource(R.string.clear_all),
                         text = stringResource(R.string.clear_all),
-                        imageVector = Icons.Default.Close
+                        icon = Icons.Default.Close
                     )
                     )
                 }
                 }
             }
             }
@@ -685,9 +821,9 @@ private fun SearchSuggestionPanel(
                     title = history.word,
                     title = history.word,
                     onToggleSelection = { onUseHistory(history.word) },
                     onToggleSelection = { onUseHistory(history.word) },
                     trailingAction = {
                     trailingAction = {
-                        SmallIconButton(
+                        SmallPlainButton(
                             onClick = { onDeleteHistory(history) },
                             onClick = { onDeleteHistory(history) },
-                            imageVector = Icons.Default.Close,
+                            icon = Icons.Default.Close,
                             contentDescription = stringResource(R.string.delete)
                             contentDescription = stringResource(R.string.delete)
                         )
                         )
                     }
                     }
@@ -747,3 +883,76 @@ private fun SearchResultFooter(
         }
         }
     }
     }
 }
 }
+
+private data class SourceGroup(
+    val origin: String,
+    val sourceName: String,
+    val items: List<SearchResultItemUi>,
+)
+
+@Composable
+private fun ExpandedSourceSheet(
+    show: Boolean,
+    sourceName: String,
+    books: List<SearchBook>,
+    isLoading: Boolean,
+    isEnd: Boolean,
+    errorMsg: String?,
+    onDismiss: () -> Unit,
+    onLoadMore: () -> Unit,
+    onBookClick: (SearchBook, String?) -> Unit,
+    onBookLongClick: ((SearchBook, String?) -> Unit)? = null,
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismiss,
+        title = sourceName,
+    ) {
+        val listState = rememberLazyListState()
+
+        val shouldLoadMore by remember {
+            derivedStateOf {
+                val total = listState.layoutInfo.totalItemsCount
+                val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+                total > 0 && last >= total - 3
+            }
+        }
+
+        LaunchedEffect(shouldLoadMore, isLoading, isEnd) {
+            if (shouldLoadMore && !isLoading && !isEnd) {
+                onLoadMore()
+            }
+        }
+
+        LazyColumn(
+            state = listState,
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPaddingOnlyVertical(
+                top = 8.dp,
+                bottom = 16.dp
+            ),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(
+                items = books,
+                key = { it.bookUrl },
+            ) { book ->
+                SearchBookListItem(
+                    book = book,
+                    shelfState = BookShelfState.NOT_IN_SHELF,
+                    onClick = { onBookClick(book, null) },
+                    onLongClick = onBookLongClick,
+                )
+            }
+
+            item {
+                LoadMoreFooter(
+                    isLoading = isLoading,
+                    errorMsg = errorMsg,
+                    isEnd = isEnd,
+                    onRetry = onLoadMore,
+                )
+            }
+        }
+    }
+}

+ 149 - 25
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -2,50 +2,73 @@ package io.legado.app.ui.book.search
 
 
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.model.BookSearchScope
 import io.legado.app.domain.model.BookSearchScope
+import io.legado.app.domain.model.MatchMode
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookSearchControl
 import io.legado.app.domain.usecase.BookSearchControl
 import io.legado.app.domain.usecase.BookSearchRequest
 import io.legado.app.domain.usecase.BookSearchRequest
 import io.legado.app.domain.usecase.BookShelfKey
 import io.legado.app.domain.usecase.BookShelfKey
+import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.SearchRunEvent
 import io.legado.app.domain.usecase.SearchRunEvent
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.utils.getPrefBoolean
-import io.legado.app.utils.putPrefBoolean
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
-import splitties.init.appCtx
 
 
 @OptIn(ExperimentalCoroutinesApi::class)
 @OptIn(ExperimentalCoroutinesApi::class)
 class SearchViewModel(
 class SearchViewModel(
     private val repository: SearchRepository,
     private val repository: SearchRepository,
     private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
     private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
     private val searchBooksUseCase: SearchBooksUseCase,
     private val searchBooksUseCase: SearchBooksUseCase,
+    private val exploreBooksUseCase: ExploreBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
+    private val localPreferencesRepository: LocalPreferencesRepository,
 ) : ViewModel() {
 ) : ViewModel() {
 
 
+    val searchLayoutMode = localPreferencesRepository
+        .getPreference(LocalPreferencesKeys.SEARCH_LAYOUT_MODE, 0)
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
+
+    fun toggleSearchLayout() {
+        viewModelScope.launch {
+            val newMode = if (searchLayoutMode.value == 0) 1 else 0
+            localPreferencesRepository.updatePreference(
+                LocalPreferencesKeys.SEARCH_LAYOUT_MODE, newMode
+            )
+        }
+    }
+
+    private val matchModeFlow = localPreferencesRepository
+        .getPreference(LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value)
+        .distinctUntilChanged()
+        .map { MatchMode.of(it) }
+
     private val _uiState = MutableStateFlow(
     private val _uiState = MutableStateFlow(
         SearchUiState(
         SearchUiState(
-            isPrecisionSearch = appCtx.getPrefBoolean(PreferKey.precisionSearch),
             scopeDisplay = SearchScope(AppConfig.searchScope).display,
             scopeDisplay = SearchScope(AppConfig.searchScope).display,
-            scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames,
+            scopeDisplayNames = SearchScope(AppConfig.searchScope).displayNames.toImmutableList(),
             isAllScope = SearchScope(AppConfig.searchScope).isAll(),
             isAllScope = SearchScope(AppConfig.searchScope).isAll(),
             isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
             isSourceScope = SearchScope(AppConfig.searchScope).isSource(),
         )
         )
@@ -72,6 +95,7 @@ class SearchViewModel(
         observeBookshelf()
         observeBookshelf()
         observeQueryHistory()
         observeQueryHistory()
         observeQueryBookshelfHints()
         observeQueryBookshelfHints()
+        observeMatchMode()
     }
     }
 
 
     fun onAddToShelf(book: SearchBook) {
     fun onAddToShelf(book: SearchBook) {
@@ -153,8 +177,8 @@ class SearchViewModel(
                 _uiState.update { it.copy(showScopeSheet = intent.visible) }
                 _uiState.update { it.copy(showScopeSheet = intent.visible) }
             }
             }
 
 
-            is SearchIntent.SetTypeSheetVisible -> {
-                _uiState.update { it.copy(showTypeSheet = intent.visible) }
+            is SearchIntent.SetSettingsSheetVisible -> {
+                _uiState.update { it.copy(showSettingsSheet = intent.visible) }
             }
             }
 
 
             is SearchIntent.ToggleSourceType -> {
             is SearchIntent.ToggleSourceType -> {
@@ -184,9 +208,12 @@ class SearchViewModel(
                 syncScopeState(restartSearch = true, oldScope = oldScope)
                 syncScopeState(restartSearch = true, oldScope = oldScope)
             }
             }
 
 
-            is SearchIntent.TogglePrecision -> {
-                appCtx.putPrefBoolean(PreferKey.precisionSearch, intent.enabled)
-                _uiState.update { it.copy(isPrecisionSearch = intent.enabled) }
+            is SearchIntent.SetMatchMode -> {
+                viewModelScope.launch {
+                    localPreferencesRepository.updatePreference(
+                        LocalPreferencesKeys.MATCH_MODE, intent.mode.value
+                    )
+                }
                 restartCommittedSearchIfNeeded()
                 restartCommittedSearchIfNeeded()
             }
             }
 
 
@@ -197,6 +224,61 @@ class SearchViewModel(
 
 
             SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
             SearchIntent.OpenSourceManage -> emitEffect(SearchEffect.OpenSourceManage)
 
 
+            is SearchIntent.ExpandSource -> {
+                _uiState.update {
+                    it.copy(
+                        expandedSourceUrl = intent.sourceUrl,
+                        expandedSourceName = intent.sourceName,
+                        expandedSourceBooks = persistentListOf(),
+                        expandedSourceLoading = true,
+                        expandedSourceEnd = false,
+                        expandedSourceError = null,
+                        expandedSourcePage = 1,
+                    )
+                }
+                loadExpandedSourcePage(intent.sourceUrl, page = 1)
+            }
+
+            SearchIntent.DismissExpandedSource -> {
+                _uiState.update {
+                    it.copy(
+                        expandedSourceUrl = null,
+                        expandedSourceName = null,
+                        expandedSourceBooks = persistentListOf(),
+                        expandedSourceLoading = false,
+                        expandedSourceEnd = false,
+                        expandedSourceError = null,
+                        expandedSourcePage = 1,
+                    )
+                }
+            }
+
+            SearchIntent.LoadMoreExpandedSource -> {
+                val state = _uiState.value
+                val sourceUrl = state.expandedSourceUrl ?: return
+                if (state.expandedSourceLoading || state.expandedSourceEnd) return
+                _uiState.update {
+                    it.copy(
+                        expandedSourceLoading = true,
+                        expandedSourceError = null
+                    )
+                }
+                loadExpandedSourcePage(sourceUrl, page = state.expandedSourcePage)
+            }
+
+            is SearchIntent.OpenExpandedSourceBook -> {
+                emitEffect(
+                    SearchEffect.OpenBookInfo(
+                        name = intent.book.name,
+                        author = intent.book.author,
+                        bookUrl = intent.book.bookUrl,
+                        origin = intent.book.origin,
+                        coverPath = intent.book.coverUrl,
+                        sharedCoverKey = intent.sharedCoverKey,
+                    )
+                )
+            }
+
             is SearchIntent.SaveScrollState -> {
             is SearchIntent.SaveScrollState -> {
                 _uiState.update {
                 _uiState.update {
                     it.copy(
                     it.copy(
@@ -233,7 +315,7 @@ class SearchViewModel(
             repository.enabledGroups
             repository.enabledGroups
                 .catch { emit(emptyList()) }
                 .catch { emit(emptyList()) }
                 .collect { groups ->
                 .collect { groups ->
-                    _uiState.update { it.copy(enabledGroups = groups) }
+                    _uiState.update { it.copy(enabledGroups = groups.toImmutableList()) }
                 }
                 }
         }
         }
     }
     }
@@ -243,7 +325,7 @@ class SearchViewModel(
             repository.enabledSources
             repository.enabledSources
                 .catch { emit(emptyList()) }
                 .catch { emit(emptyList()) }
                 .collect { sources ->
                 .collect { sources ->
-                    _uiState.update { it.copy(enabledSources = sources) }
+                    _uiState.update { it.copy(enabledSources = sources.toImmutableList()) }
                 }
                 }
         }
         }
     }
     }
@@ -255,7 +337,7 @@ class SearchViewModel(
                 .collect { keys ->
                 .collect { keys ->
                     bookshelfKeys.value = keys
                     bookshelfKeys.value = keys
                     _uiState.update { state ->
                     _uiState.update { state ->
-                        state.copy(results = state.results.withShelfState(keys))
+                        state.copy(results = state.results.withShelfState(keys).toImmutableList())
                     }
                     }
                 }
                 }
         }
         }
@@ -269,7 +351,7 @@ class SearchViewModel(
                 .flatMapLatest { repository.searchHistory(it) }
                 .flatMapLatest { repository.searchHistory(it) }
                 .catch { emit(emptyList()) }
                 .catch { emit(emptyList()) }
                 .collect { history ->
                 .collect { history ->
-                    _uiState.update { it.copy(history = history) }
+                    _uiState.update { it.copy(history = history.toImmutableList()) }
                 }
                 }
         }
         }
     }
     }
@@ -282,11 +364,19 @@ class SearchViewModel(
                 .flatMapLatest { repository.searchBookshelf(it) }
                 .flatMapLatest { repository.searchBookshelf(it) }
                 .catch { emit(emptyList()) }
                 .catch { emit(emptyList()) }
                 .collect { books ->
                 .collect { books ->
-                    _uiState.update { it.copy(bookshelfHints = books) }
+                    _uiState.update { it.copy(bookshelfHints = books.toImmutableList()) }
                 }
                 }
         }
         }
     }
     }
 
 
+    private fun observeMatchMode() {
+        viewModelScope.launch {
+            matchModeFlow.collect { mode ->
+                _uiState.update { it.copy(matchMode = mode) }
+            }
+        }
+    }
+
     private fun updateQuery(query: String, showSuggestions: Boolean) {
     private fun updateQuery(query: String, showSuggestions: Boolean) {
         val currentState = _uiState.value
         val currentState = _uiState.value
         val isSameQuery = currentState.query == query
         val isSameQuery = currentState.query == query
@@ -319,7 +409,7 @@ class SearchViewModel(
         _uiState.update {
         _uiState.update {
             it.copy(
             it.copy(
                 committedQuery = keyword,
                 committedQuery = keyword,
-                results = emptyList(),
+                results = persistentListOf(),
                 isManualStop = false,
                 isManualStop = false,
                 hasMore = true,
                 hasMore = true,
                 processedSources = 0,
                 processedSources = 0,
@@ -362,7 +452,7 @@ class SearchViewModel(
                             keyword = keyword,
                             keyword = keyword,
                             page = page,
                             page = page,
                             scope = BookSearchScope(searchScope.toString()),
                             scope = BookSearchScope(searchScope.toString()),
-                            precision = _uiState.value.isPrecisionSearch,
+                            matchMode = _uiState.value.matchMode,
                             concurrency = OtherConfig.threadCount,
                             concurrency = OtherConfig.threadCount,
                             types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() },
                             types = _uiState.value.selectedSourceTypes.takeIf { it.isNotEmpty() },
                         ),
                         ),
@@ -395,7 +485,7 @@ class SearchViewModel(
                     it.copy(
                     it.copy(
                         results = buildSearchResultItems(
                         results = buildSearchResultItems(
                             shelf = bookshelfKeys.value,
                             shelf = bookshelfKeys.value,
-                        ),
+                        ).toImmutableList(),
                         processedSources = event.processedSources,
                         processedSources = event.processedSources,
                         totalSources = event.totalSources,
                         totalSources = event.totalSources,
                     )
                     )
@@ -407,7 +497,7 @@ class SearchViewModel(
                     val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) {
                     val emptyAction = if (searchResultBooks.isEmpty() && event.isEmpty && !searchScope.isAll()) {
                         SearchEmptyScopeAction(
                         SearchEmptyScopeAction(
                             scopeDisplay = searchScope.display,
                             scopeDisplay = searchScope.display,
-                            wasPrecisionSearch = state.isPrecisionSearch,
+                            wasMatchMode = state.matchMode,
                         )
                         )
                     } else {
                     } else {
                         null
                         null
@@ -441,7 +531,7 @@ class SearchViewModel(
             it.copy(
             it.copy(
                 query = "",
                 query = "",
                 committedQuery = "",
                 committedQuery = "",
-                results = emptyList(),
+                results = persistentListOf(),
                 processedSources = 0,
                 processedSources = 0,
                 totalSources = 0,
                 totalSources = 0,
                 isSearching = false,
                 isSearching = false,
@@ -497,9 +587,12 @@ class SearchViewModel(
         val action = _uiState.value.emptyScopeAction ?: return
         val action = _uiState.value.emptyScopeAction ?: return
         _uiState.update { it.copy(emptyScopeAction = null) }
         _uiState.update { it.copy(emptyScopeAction = null) }
 
 
-        if (action.wasPrecisionSearch) {
-            appCtx.putPrefBoolean(PreferKey.precisionSearch, false)
-            _uiState.update { it.copy(isPrecisionSearch = false) }
+        if (action.wasMatchMode == MatchMode.EXACT) {
+            viewModelScope.launch {
+                localPreferencesRepository.updatePreference(
+                    LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
+                )
+            }
         } else {
         } else {
             searchScope.update("")
             searchScope.update("")
             syncScopeState()
             syncScopeState()
@@ -523,7 +616,7 @@ class SearchViewModel(
         _uiState.update {
         _uiState.update {
             it.copy(
             it.copy(
                 scopeDisplay = searchScope.display,
                 scopeDisplay = searchScope.display,
-                scopeDisplayNames = searchScope.displayNames,
+                scopeDisplayNames = searchScope.displayNames.toImmutableList(),
                 selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
                 selectedScopeSourceUrls = searchScope.sourceUrls.toSet(),
                 isAllScope = searchScope.isAll(),
                 isAllScope = searchScope.isAll(),
                 isSourceScope = searchScope.isSource(),
                 isSourceScope = searchScope.isSource(),
@@ -571,6 +664,37 @@ class SearchViewModel(
         }
         }
     }
     }
 
 
+    private fun loadExpandedSourcePage(sourceUrl: String, page: Int) {
+        viewModelScope.launch {
+            val keyword = _uiState.value.committedQuery
+            try {
+                val result = exploreBooksUseCase.execute(
+                    sourceUrl = sourceUrl,
+                    moduleUrl = null,
+                    args = null,
+                    page = page,
+                    key = keyword,
+                )
+                val newBooks = result.books
+                _uiState.update {
+                    it.copy(
+                        expandedSourceBooks = (it.expandedSourceBooks + newBooks).toImmutableList(),
+                        expandedSourceLoading = false,
+                        expandedSourceEnd = newBooks.isEmpty(),
+                        expandedSourcePage = page + 1,
+                    )
+                }
+            } catch (e: Exception) {
+                _uiState.update {
+                    it.copy(
+                        expandedSourceLoading = false,
+                        expandedSourceError = e.message ?: "Unknown error",
+                    )
+                }
+            }
+        }
+    }
+
     private fun emitEffect(effect: SearchEffect) {
     private fun emitEffect(effect: SearchEffect) {
         _effects.tryEmit(effect)
         _effects.tryEmit(effect)
     }
     }

+ 14 - 13
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -34,11 +34,9 @@ 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
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import io.legado.app.ui.widget.components.AppFloatingActionButton
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
 import androidx.compose.material3.ListItem
 import androidx.compose.material3.ListItem
-import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
@@ -57,20 +55,23 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchContentHistory
 import io.legado.app.data.entities.SearchContentHistory
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
+import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.button.MediumOutlinedButton
 import io.legado.app.ui.widget.components.button.MediumOutlinedButton
-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.topbar.TopBarAnimatedActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.ui.widget.components.button.SmallPlainButton
+import io.legado.app.ui.widget.components.button.SmallToggleButton
+import io.legado.app.ui.widget.components.button.ToggleStyle
 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.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarAnimatedActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 
 
@@ -302,14 +303,14 @@ fun SearchHistoryList(
                 color = LegadoTheme.colorScheme.primary,
                 color = LegadoTheme.colorScheme.primary,
                 modifier = Modifier.align(Alignment.Center)
                 modifier = Modifier.align(Alignment.Center)
             )
             )
-            SmallAnimatedActionButton(
+            SmallToggleButton(
                 modifier = Modifier.align(Alignment.CenterEnd),
                 modifier = Modifier.align(Alignment.CenterEnd),
                 checked = onlyThisBook,
                 checked = onlyThisBook,
                 onCheckedChange = { onToggleScope() },
                 onCheckedChange = { onToggleScope() },
+                style = ToggleStyle.Tonal,
                 iconChecked = Icons.Default.Book,
                 iconChecked = Icons.Default.Book,
-                iconUnchecked = Icons.Default.CollectionsBookmark,
-                activeText = "仅本书",
-                inactiveText = "所有记录"
+                icon = Icons.Default.CollectionsBookmark,
+                text = "仅本书"
             )
             )
         }
         }
 
 
@@ -338,9 +339,9 @@ fun SearchHistoryList(
                             Icon(Icons.Default.History, contentDescription = null)
                             Icon(Icons.Default.History, contentDescription = null)
                         },
                         },
                         trailingContent = {
                         trailingContent = {
-                            SmallIconButton(
+                            SmallPlainButton(
                                 onClick = { onDeleteHistory(item) },
                                 onClick = { onDeleteHistory(item) },
-                                imageVector = Icons.Default.Close,
+                                icon = Icons.Default.Close,
                                 contentDescription = "删除"
                                 contentDescription = "删除"
                             )
                             )
                         },
                         },
@@ -361,7 +362,7 @@ fun SearchHistoryList(
                         MediumOutlinedButton(
                         MediumOutlinedButton(
                             onClick = onClearHistory,
                             onClick = onClearHistory,
                             modifier = Modifier.fillMaxWidth(0.6f),
                             modifier = Modifier.fillMaxWidth(0.6f),
-                            imageVector = Icons.Outlined.DeleteSweep,
+                            icon = Icons.Outlined.DeleteSweep,
                             text = "清除搜索历史"
                             text = "清除搜索历史"
                         )
                         )
                     }
                     }