Kaynağa Gözat

refactor: 统一主要 Compose 功能路由的 UDF 架构

迁移主界面、发现、RSS、收藏、规则订阅、搜索、正文搜索、导入和阅读记录等现有 Compose 页面。

补齐 Contract、Intent、Effect、不可变集合与数据仓库边界,并由 Route 负责 ViewModel 注入和生命周期收集。
5151561 3 ay önce
ebeveyn
işleme
5d892f469c
37 değiştirilmiş dosya ile 1775 ekleme ve 1500 silme
  1. 15 0
      app/src/main/java/io/legado/app/data/repository/RssFavoriteRepository.kt
  2. 14 0
      app/src/main/java/io/legado/app/data/repository/RuleSubscriptionRepository.kt
  3. 27 0
      app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt
  4. 13 1
      app/src/main/java/io/legado/app/di/appModule.kt
  5. 1 0
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt
  6. 46 20
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  7. 10 2
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  8. 76 53
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
  9. 10 2
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  10. 50 32
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
  11. 6 0
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt
  12. 30 12
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt
  13. 18 0
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt
  14. 79 40
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
  15. 40 4
      app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt
  16. 1 1
      app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
  17. 3 0
      app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt
  18. 95 90
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  19. 18 1
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  20. 51 29
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
  21. 129 130
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt
  22. 280 521
      app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt
  23. 48 0
      app/src/main/java/io/legado/app/ui/main/MainContract.kt
  24. 0 10
      app/src/main/java/io/legado/app/ui/main/MainDestination.kt
  25. 20 20
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  26. 25 18
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  27. 77 93
      app/src/main/java/io/legado/app/ui/main/MainViewModel.kt
  28. 76 55
      app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
  29. 70 26
      app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt
  30. 21 4
      app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt
  31. 13 4
      app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt
  32. 35 22
      app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt
  33. 32 0
      app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt
  34. 35 20
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
  35. 129 143
      app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt
  36. 66 43
      app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
  37. 116 104
      app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubViewModel.kt

+ 15 - 0
app/src/main/java/io/legado/app/data/repository/RssFavoriteRepository.kt

@@ -0,0 +1,15 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.RssStarDao
+import io.legado.app.data.entities.RssStar
+import kotlinx.coroutines.flow.Flow
+
+class RssFavoriteRepository(private val dao: RssStarDao) {
+    fun observeGroups(): Flow<List<String>> = dao.flowGroups()
+    fun observeAll(): Flow<List<RssStar>> = dao.liveAll()
+    fun observeGroup(group: String): Flow<List<RssStar>> = dao.flowByGroup(group)
+    suspend fun update(star: RssStar) = dao.update(star)
+    suspend fun delete(star: RssStar) = dao.delete(star.origin, star.link)
+    suspend fun deleteGroup(group: String) = dao.deleteByGroup(group)
+    suspend fun deleteAll() = dao.deleteAll()
+}

+ 14 - 0
app/src/main/java/io/legado/app/data/repository/RuleSubscriptionRepository.kt

@@ -0,0 +1,14 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.RuleSubDao
+import io.legado.app.data.entities.RuleSub
+import kotlinx.coroutines.flow.Flow
+
+class RuleSubscriptionRepository(private val dao: RuleSubDao) {
+    fun observeAll(): Flow<List<RuleSub>> = dao.flowAll()
+    suspend fun findByUrl(url: String): RuleSub? = dao.findByUrl(url)
+    suspend fun insert(rule: RuleSub) = dao.insert(rule)
+    suspend fun delete(rule: RuleSub) = dao.delete(rule)
+    suspend fun all(): List<RuleSub> = dao.all
+    suspend fun update(vararg rules: RuleSub) = dao.update(*rules)
+}

+ 27 - 0
app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt

@@ -3,6 +3,8 @@ package io.legado.app.data.repository
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.SearchContentHistory
+import io.legado.app.data.dao.SearchContentHistoryDao
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
@@ -18,8 +20,33 @@ import kotlinx.coroutines.flow.flowOn
 
 class SearchContentRepository(
     private val titleModeProvider: () -> Int = { 0 },
+    private val historyDao: SearchContentHistoryDao = appDb.searchContentHistoryDao,
 ) {
 
+    fun observeHistory(book: Book?, onlyThisBook: Boolean): Flow<List<SearchContentHistory>> =
+        if (onlyThisBook && book != null) {
+            historyDao.getByBook(book.name, book.author)
+        } else {
+            historyDao.getAll()
+        }
+
+    suspend fun saveHistory(book: Book, query: String) {
+        val history = historyDao.get(book.name, book.author, query)
+            ?: SearchContentHistory(bookName = book.name, bookAuthor = book.author, query = query)
+        history.time = System.currentTimeMillis()
+        historyDao.insert(history)
+    }
+
+    suspend fun deleteHistory(id: Long) = historyDao.delete(id)
+
+    suspend fun clearHistory(book: Book?, onlyThisBook: Boolean) {
+        if (onlyThisBook && book != null) {
+            historyDao.deleteByBook(book.name, book.author)
+        } else {
+            historyDao.deleteAll()
+        }
+    }
+
     private var lastSearchResults: List<SearchResult>? = null
     private var lastQueryKey: String? = null
     private var lastSearchSession: SearchSession? = null

+ 13 - 1
app/src/main/java/io/legado/app/di/appModule.kt

@@ -49,6 +49,8 @@ import io.legado.app.data.repository.ReadStyleRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.data.repository.ReplaceRuleRepository
 import io.legado.app.data.repository.RssRepository
+import io.legado.app.data.repository.RssFavoriteRepository
+import io.legado.app.data.repository.RuleSubscriptionRepository
 import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
@@ -211,6 +213,9 @@ val appModule = module {
     single { get<AppDatabase>().bookChapterDao }
     single { get<AppDatabase>().bookGroupDao }
     single { get<AppDatabase>().bookSourceDao }
+    single { get<AppDatabase>().searchContentHistoryDao }
+    single { get<AppDatabase>().rssStarDao }
+    single { get<AppDatabase>().ruleSubDao }
 
     singleOf(::ReadRecordRepository)
     single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
@@ -220,7 +225,12 @@ val appModule = module {
     singleOf(::BookshelfRepository)
     singleOf(::DictRuleRepository)
     singleOf(::TxtTocRuleRepository)
-    single { SearchContentRepository { io.legado.app.help.config.ReadBookConfig.titleMode } }
+    single {
+        SearchContentRepository(
+            titleModeProvider = { io.legado.app.help.config.ReadBookConfig.titleMode },
+            historyDao = get(),
+        )
+    }
     singleOf(::RemoteBookRepository)
     singleOf(::SettingsRepository)
     single<AppShellSettingsGateway> { AppShellSettingsRepository() }
@@ -293,6 +303,8 @@ val appModule = module {
     single<ExploreRepository> { get<ExploreRepositoryImpl>() }
     single<ExploreBooksGateway> { get<ExploreRepositoryImpl>() }
     singleOf(::RssRepository)
+    singleOf(::RssFavoriteRepository)
+    singleOf(::RuleSubscriptionRepository)
     single {
         SearchRepositoryImpl(get())
     }

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowContract.kt

@@ -20,6 +20,7 @@ data class ExploreShowUiState(
     val isEnd: Boolean = false,
     val errorMsg: String? = null,
     val sheet: ExploreShowSheet = ExploreShowSheet.None,
+    val filterStateId: Int = 0,
 )
 
 @Stable

+ 46 - 20
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -51,7 +51,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.R
 import io.legado.app.domain.model.BookShelfState
-import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.responsiveHazeEffect
@@ -90,7 +89,7 @@ private enum class BookFilterState(val id: Int) {
     ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class
 )
 @Composable
-fun ExploreShowScreen(
+fun ExploreShowRouteScreen(
     viewModel: ExploreShowViewModel = koinViewModel(),
     title: String = "",
     onBack: () -> Unit,
@@ -119,10 +118,37 @@ fun ExploreShowScreen(
         }
     }
 
+    ExploreShowScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        title = title,
+        onBack = onBack,
+        onBookClick = onBookClick,
+        sharedTransitionScope = sharedTransitionScope,
+        animatedVisibilityScope = animatedVisibilityScope,
+    )
+}
+
+@SuppressLint("LocalContextConfigurationRead", "ConfigurationScreenWidthHeight")
+@OptIn(
+    ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class,
+    ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class
+)
+@Composable
+fun ExploreShowScreen(
+    state: ExploreShowUiState,
+    onIntent: (ExploreShowIntent) -> Unit,
+    title: String = "",
+    onBack: () -> Unit,
+    onBookClick: (SearchBook, String?) -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+
     var previewBook by remember { mutableStateOf<SearchBook?>(null) }
     var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
 
-    val filterStateId = CoverConfig.exploreFilterState
+    val filterStateId = state.filterStateId
     val books = remember(state.books, filterStateId) {
         val filter = BookFilterState.fromId(filterStateId)
         when (filter) {
@@ -165,7 +191,7 @@ fun ExploreShowScreen(
         state.books.size,
     ) {
         if (shouldLoadMore && canLoadMore) {
-            viewModel.onIntent(ExploreShowIntent.LoadMore)
+            onIntent(ExploreShowIntent.LoadMore)
         }
     }
 
@@ -173,7 +199,7 @@ fun ExploreShowScreen(
     // but the ViewModel hasn't reached the end of data yet.
     LaunchedEffect(books.isEmpty(), state.isLoading, state.isEnd, state.books.size) {
         if (books.isEmpty() && !state.isLoading && !state.isEnd && state.books.isNotEmpty()) {
-            viewModel.onIntent(ExploreShowIntent.ForceLoadNext)
+            onIntent(ExploreShowIntent.ForceLoadNext)
         }
     }
 
@@ -191,7 +217,7 @@ fun ExploreShowScreen(
 
     AppModalBottomSheet(
         show = state.sheet == ExploreShowSheet.GridCount,
-        onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) }
+        onDismissRequest = { onIntent(ExploreShowIntent.DismissSheet) }
     ) {
         Row(
             modifier = Modifier
@@ -218,7 +244,7 @@ fun ExploreShowScreen(
         AppSlider(
             value = state.gridCount.toFloat(),
             onValueChange = {
-                viewModel.onIntent(ExploreShowIntent.SaveGridCount(it.toInt().coerceIn(1, 10)))
+                onIntent(ExploreShowIntent.SaveGridCount(it.toInt().coerceIn(1, 10)))
             },
             valueRange = 1f..10f,
             steps = 8,
@@ -231,11 +257,11 @@ fun ExploreShowScreen(
 
     ExploreKindSelectSheet(
         show = state.sheet == ExploreShowSheet.KindSelect,
-        onDismissRequest = { viewModel.onIntent(ExploreShowIntent.DismissSheet) },
+        onDismissRequest = { onIntent(ExploreShowIntent.DismissSheet) },
         sourceUrl = state.sourceUrl,
         onSelected = { selectedKinds ->
             selectedKinds.firstOrNull()?.let { kind ->
-                viewModel.onIntent(ExploreShowIntent.SwitchKind(kind))
+                onIntent(ExploreShowIntent.SwitchKind(kind))
             }
         }
     )
@@ -259,7 +285,7 @@ fun ExploreShowScreen(
                     ) {
                         TopBarActionButton(
                             onClick = {
-                                viewModel.onIntent(
+                                onIntent(
                                     ExploreShowIntent.ShowSheet(
                                         ExploreShowSheet.GridCount
                                     )
@@ -271,13 +297,13 @@ fun ExploreShowScreen(
                     }
 
                     TopBarActionButton(
-                        onClick = { viewModel.onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
+                        onClick = { onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
                         imageVector = Icons.Outlined.FilterAlt,
                         contentDescription = stringResource(R.string.select_or_search_category)
                     )
 
                     TopBarActionButton(
-                        onClick = { viewModel.onIntent(ExploreShowIntent.ToggleLayout) },
+                        onClick = { onIntent(ExploreShowIntent.ToggleLayout) },
                         imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
                         contentDescription = stringResource(R.string.a11y_switch_layout)
                     )
@@ -289,7 +315,7 @@ fun ExploreShowScreen(
         AppPullToRefresh(
             modifier = Modifier.fillMaxSize(),
             isRefreshing = state.isRefreshing,
-            onRefresh = { viewModel.onIntent(ExploreShowIntent.Refresh) },
+            onRefresh = { onIntent(ExploreShowIntent.Refresh) },
             topPadding = paddingValues.calculateTopPadding(),
             scrollBehavior = scrollBehavior
         ) {
@@ -326,7 +352,7 @@ fun ExploreShowScreen(
                                 book = item.book,
                                 shelfState = item.shelfState,
                                 onClick = {
-                                    viewModel.onIntent(
+                                    onIntent(
                                         ExploreShowIntent.OpenBook(
                                             item.book,
                                             sharedCoverKey
@@ -348,8 +374,8 @@ fun ExploreShowScreen(
                             item(span = { GridItemSpan(maxLineSpan) }) {
                                 ExploreShowLoadMoreFooter(
                                     state = state,
-                                    onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
-                                    onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) },
+                                    onRetry = { onIntent(ExploreShowIntent.LoadMore) },
+                                    onLoadMore = { onIntent(ExploreShowIntent.ForceLoadNext) },
                                 )
                             }
                         }
@@ -377,7 +403,7 @@ fun ExploreShowScreen(
                                 book = item.book,
                                 shelfState = item.shelfState,
                                 onClick = {
-                                    viewModel.onIntent(
+                                    onIntent(
                                         ExploreShowIntent.OpenBook(
                                             item.book,
                                             sharedCoverKey
@@ -399,8 +425,8 @@ fun ExploreShowScreen(
                             item {
                                 ExploreShowLoadMoreFooter(
                                     state = state,
-                                    onRetry = { viewModel.onIntent(ExploreShowIntent.LoadMore) },
-                                    onLoadMore = { viewModel.onIntent(ExploreShowIntent.ForceLoadNext) },
+                                    onRetry = { onIntent(ExploreShowIntent.LoadMore) },
+                                    onLoadMore = { onIntent(ExploreShowIntent.ForceLoadNext) },
                                 )
                             }
                         }
@@ -424,7 +450,7 @@ fun ExploreShowScreen(
             onBookClick(book, sharedCoverKey)
         },
         onAddToShelf = { book ->
-            viewModel.onIntent(ExploreShowIntent.AddToShelf(book))
+            onIntent(ExploreShowIntent.AddToShelf(book))
         },
     )
 }

+ 10 - 2
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt

@@ -10,6 +10,7 @@ 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.SaveSearchBooksUseCase
+import io.legado.app.domain.gateway.CoverSettingsGateway
 import android.content.res.Configuration
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.repository.SettingsRepository
@@ -52,6 +53,7 @@ class ExploreShowViewModel(
     private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
     private val localPreferencesRepository: SettingsRepository,
+    private val coverSettingsGateway: CoverSettingsGateway,
 ) : ViewModel() {
 
     private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
@@ -135,13 +137,18 @@ class ExploreShowViewModel(
 
     private fun combineUiState() {
         viewModelScope.launch {
+            val displayAndCoverSettings = combine(
+                _displayState,
+                coverSettingsGateway.settings,
+            ) { displayState, coverSettings -> displayState to coverSettings }
             combine(
                 _rawBooks,
                 _bookshelf,
                 _loadState,
                 _kindState,
-                _displayState,
-            ) { rawBooks, bookshelf, loadState, kindState, displayState ->
+                displayAndCoverSettings,
+            ) { rawBooks, bookshelf, loadState, kindState, displayAndCover ->
+                val (displayState, coverSettings) = displayAndCover
                 val books = rawBooks.map { item ->
                     ExploreBookItemUi(
                         book = item,
@@ -166,6 +173,7 @@ class ExploreShowViewModel(
                     isEnd = loadState.isEnd,
                     errorMsg = loadState.errorMsg,
                     sheet = displayState.sheet,
+                    filterStateId = coverSettings.exploreFilterState,
                 )
             }.collect { newState ->
                 _uiState.value = newState

+ 76 - 53
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt

@@ -54,7 +54,6 @@ import io.legado.app.R
 import io.legado.app.constant.AppConst
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.dialogs.selector
-import io.legado.app.ui.config.importBookConfig.ImportBookConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.AppPullToRefresh
@@ -237,7 +236,7 @@ private fun ImportBookContent(
 }
 
 @Composable
-fun ImportBookScreen(
+fun ImportBookRouteScreen(
     onBackClick: () -> Unit,
     viewModel: ImportBookViewModel = koinViewModel()
 ) {
@@ -246,7 +245,7 @@ fun ImportBookScreen(
     var showFolderPicker by remember { mutableStateOf(false) }
     var showImportFileNameDialog by remember { mutableStateOf(false) }
     var pendingSingleAddBook by remember { mutableStateOf<ImportBook?>(null) }
-    var fileNameJs by remember { mutableStateOf(ImportBookConfig.bookImportFileName.orEmpty()) }
+    var fileNameJs by remember { mutableStateOf("") }
     var pickerTarget by remember { mutableStateOf(ImportFolderPickTarget.IMPORT_FOLDER) }
     val selectDocTree = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocumentTree()
@@ -260,16 +259,12 @@ fun ImportBookScreen(
     }
     val handleBack = {
         when {
-            uiState.selectedIds.isNotEmpty() -> viewModel.clearSelection()
+            uiState.selectedIds.isNotEmpty() -> viewModel.dispatch(ImportBookIntent.ClearSelection)
             uiState.canGoBack -> viewModel.dispatch(ImportBookIntent.NavigateBack)
             else -> onBackClick()
         }
     }
 
-    BackHandler(enabled = !showFolderPicker && !showImportFileNameDialog) {
-        handleBack()
-    }
-
     LaunchedEffect(viewModel) {
         viewModel.dispatch(ImportBookIntent.Initialize)
         viewModel.effects.collect { effect ->
@@ -312,89 +307,117 @@ fun ImportBookScreen(
         }
     }
 
+    ImportBookScreen(
+        state = uiState,
+        onIntent = viewModel::dispatch,
+        onBackClick = handleBack,
+        showFolderPicker = showFolderPicker,
+        onShowFolderPickerChange = { showFolderPicker = it },
+        showImportFileNameDialog = showImportFileNameDialog,
+        onShowImportFileNameDialogChange = { showImportFileNameDialog = it },
+        fileNameJs = fileNameJs,
+        onFileNameJsChange = { fileNameJs = it },
+        pendingSingleAddBook = pendingSingleAddBook,
+        onPendingSingleAddBookChange = { pendingSingleAddBook = it },
+        onSelectBookFiles = {
+            selectBookFiles.launch(
+                arrayOf(
+                    "text/plain", "application/epub+zip", "application/pdf",
+                    "application/x-mobipocket-ebook", "application/vnd.amazon.ebook",
+                    "application/zip", "application/x-rar-compressed",
+                    "application/x-7z-compressed", "application/octet-stream",
+                )
+            )
+        },
+    )
+}
+
+@Composable
+fun ImportBookScreen(
+    state: ImportBookUiState,
+    onIntent: (ImportBookIntent) -> Unit,
+    onBackClick: () -> Unit,
+    showFolderPicker: Boolean,
+    onShowFolderPickerChange: (Boolean) -> Unit,
+    showImportFileNameDialog: Boolean,
+    onShowImportFileNameDialogChange: (Boolean) -> Unit,
+    fileNameJs: String,
+    onFileNameJsChange: (String) -> Unit,
+    pendingSingleAddBook: ImportBook?,
+    onPendingSingleAddBookChange: (ImportBook?) -> Unit,
+    onSelectBookFiles: () -> Unit,
+) {
+    BackHandler(enabled = !showFolderPicker && !showImportFileNameDialog) { onBackClick() }
+
     FilePickerSheet(
         show = showFolderPicker,
-        onDismissRequest = { showFolderPicker = false },
+        onDismissRequest = { onShowFolderPickerChange(false) },
         title = stringResource(R.string.select_folder),
         onSelectSysDir = {
-            showFolderPicker = false
-            viewModel.dispatch(ImportBookIntent.SelectFolderClick)
+            onShowFolderPickerChange(false)
+            onIntent(ImportBookIntent.SelectFolderClick)
         }
     )
 
     AppAlertDialog(
         show = showImportFileNameDialog,
-        onDismissRequest = { showImportFileNameDialog = false },
+        onDismissRequest = { onShowImportFileNameDialogChange(false) },
         title = stringResource(R.string.import_file_name),
         content = {
             AppText("Use js to parse file name from src, then assign name/author.")
             OutlinedTextField(
                 value = fileNameJs,
-                onValueChange = { fileNameJs = it },
+                onValueChange = onFileNameJsChange,
                 label = { AppText("js") }
             )
         },
         confirmText = stringResource(android.R.string.ok),
         onConfirm = {
-            ImportBookConfig.bookImportFileName = fileNameJs
-            showImportFileNameDialog = false
+            onIntent(ImportBookIntent.SetFileNameRule(fileNameJs))
+            onShowImportFileNameDialogChange(false)
         },
         dismissText = stringResource(android.R.string.cancel),
-        onDismiss = { showImportFileNameDialog = false }
+        onDismiss = { onShowImportFileNameDialogChange(false) }
     )
 
     pendingSingleAddBook?.let { book ->
         AppAlertDialog(
             show = true,
-            onDismissRequest = { pendingSingleAddBook = null },
+            onDismissRequest = { onPendingSingleAddBookChange(null) },
             title = stringResource(R.string.add_to_bookshelf),
             text = stringResource(R.string.check_add_bookshelf, book.name),
             confirmText = stringResource(android.R.string.ok),
             onConfirm = {
-                viewModel.dispatch(ImportBookIntent.AddSingleToBookshelf(book))
-                pendingSingleAddBook = null
+                onIntent(ImportBookIntent.AddSingleToBookshelf(book))
+                onPendingSingleAddBookChange(null)
             },
             dismissText = stringResource(android.R.string.cancel),
-            onDismiss = { pendingSingleAddBook = null }
+            onDismiss = { onPendingSingleAddBookChange(null) }
         )
     }
 
     ImportBookContent(
-        state = uiState,
-        onBackClick = handleBack,
-        onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
-        onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
-        onSelectFolder = { showFolderPicker = true },
-        onSelectBookFiles = {
-            selectBookFiles.launch(
-                arrayOf(
-                    "text/plain",
-                    "application/epub+zip",
-                    "application/pdf",
-                    "application/x-mobipocket-ebook",
-                    "application/vnd.amazon.ebook",
-                    "application/zip",
-                    "application/x-rar-compressed",
-                    "application/x-7z-compressed",
-                    "application/octet-stream"
-                )
-            )
-        },
-        onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) },
+        state = state,
+        onBackClick = onBackClick,
+        onSearchToggle = { onIntent(ImportBookIntent.SearchToggle(it)) },
+        onSearchQueryChange = { onIntent(ImportBookIntent.SearchQueryChange(it)) },
+        onSelectFolder = { onShowFolderPickerChange(true) },
+        onSelectBookFiles = onSelectBookFiles,
+        onScanFolder = { onIntent(ImportBookIntent.ScanFolder) },
         onImportFileName = {
-            fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
-            showImportFileNameDialog = true
+            onFileNameJsChange(state.fileNameRule)
+            onShowImportFileNameDialogChange(true)
         },
-        onSortChange = { viewModel.dispatch(ImportBookIntent.SortChange(it)) },
-        onNavigateBack = { viewModel.dispatch(ImportBookIntent.NavigateBack) },
-        onNavigateToLevel = { viewModel.dispatch(ImportBookIntent.NavigateToLevel(it)) },
-        onSelectAll = { viewModel.dispatch(ImportBookIntent.SelectAll) },
-        onClearSelection = { viewModel.clearSelection() },
-        onSelectInvert = { viewModel.dispatch(ImportBookIntent.SelectInvert) },
-        onAddToBookshelf = { viewModel.dispatch(ImportBookIntent.AddToBookshelf) },
-        onItemAddToBookshelf = { pendingSingleAddBook = it },
-        onDeleteSelection = { viewModel.dispatch(ImportBookIntent.DeleteSelection) },
-        onItemClick = { viewModel.dispatch(ImportBookIntent.ItemClick(it)) }
+        onSortChange = { onIntent(ImportBookIntent.SortChange(it)) },
+        onNavigateBack = { onIntent(ImportBookIntent.NavigateBack) },
+        onNavigateToLevel = { onIntent(ImportBookIntent.NavigateToLevel(it)) },
+        onSelectAll = { onIntent(ImportBookIntent.SelectAll) },
+        onClearSelection = { onIntent(ImportBookIntent.ClearSelection) },
+        onSelectInvert = { onIntent(ImportBookIntent.SelectInvert) },
+        onAddToBookshelf = { onIntent(ImportBookIntent.AddToBookshelf) },
+        onItemAddToBookshelf = onPendingSingleAddBookChange,
+        onDeleteSelection = { onIntent(ImportBookIntent.DeleteSelection) },
+        onItemClick = { onIntent(ImportBookIntent.ItemClick(it)) }
     )
 }
 

+ 10 - 2
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt

@@ -62,7 +62,8 @@ data class ImportBookUiState(
     val interaction: InteractionState = InteractionState(),
     val pathNames: List<String> = emptyList(),
     val canGoBack: Boolean = false,
-    val sort: Int = 0
+    val sort: Int = 0,
+    val fileNameRule: String = "",
 ) : ListUiState<ImportBook> {
     override val isSearch: Boolean get() = interaction.isSearchMode
     override val isLoading: Boolean get() = interaction.isLoading
@@ -86,12 +87,14 @@ sealed interface ImportBookIntent {
     data class NavigateToLevel(val level: Int) : ImportBookIntent
     data object SelectAll : ImportBookIntent
     data object SelectInvert : ImportBookIntent
+    data object ClearSelection : ImportBookIntent
     data object AddToBookshelf : ImportBookIntent
     data class AddSingleToBookshelf(val item: ImportBook) : ImportBookIntent
     data object DeleteSelection : ImportBookIntent
     data class ItemClick(val item: ImportBook) : ImportBookIntent
     data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent
     data class ImportArchiveConfirmed(val fileDoc: FileDoc, val fileName: String) : ImportBookIntent
+    data class SetFileNameRule(val value: String) : ImportBookIntent
 }
 
 sealed interface ImportBookEffect {
@@ -157,6 +160,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             is ImportBookIntent.NavigateToLevel -> navigateToLevel(intent.level)
             ImportBookIntent.SelectAll -> selectAllCheckable()
             ImportBookIntent.SelectInvert -> invertSelection()
+            ImportBookIntent.ClearSelection -> clearSelection()
             ImportBookIntent.AddToBookshelf -> addSelectedToBookshelf()
             is ImportBookIntent.AddSingleToBookshelf -> addSingleToBookshelf(intent.item)
             ImportBookIntent.DeleteSelection -> deleteSelectedDocs()
@@ -170,6 +174,9 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 intent.fileDoc,
                 intent.fileName
             )
+            is ImportBookIntent.SetFileNameRule -> {
+                ImportBookConfig.bookImportFileName = intent.value
+            }
         }
     }
 
@@ -220,7 +227,8 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             interaction = state.interaction,
             pathNames = pathNames,
             canGoBack = state.subDocs.isNotEmpty(),
-            sort = state.sort
+            sort = state.sort,
+            fileNameRule = ImportBookConfig.bookImportFileName.orEmpty(),
         )
     }.flowOn(Dispatchers.Default)
         .stateIn(

+ 50 - 32
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt

@@ -107,7 +107,7 @@ sealed class RemoteBookSheet {
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
 @Composable
-fun RemoteBookScreen(
+fun RemoteBookRouteScreen(
     viewModel: RemoteBookViewModel = koinViewModel(),
     onBackClick: () -> Unit
 ) {
@@ -188,7 +188,7 @@ fun RemoteBookScreen(
                         showSheet = null
                     },
                     onEdit = { showSheet = RemoteBookSheet.ServerConfig(it) },
-                    onDelete = { viewModel.deleteServer(it) },
+                    onDelete = { viewModel.dispatch(RemoteBookIntent.DeleteServer(it)) },
                     onDefault = {
                         viewModel.dispatch(RemoteBookIntent.SelectServer(AppConst.DEFAULT_WEBDAV_ID))
                         showSheet = null
@@ -200,7 +200,7 @@ fun RemoteBookScreen(
                 ServerConfigSheetContent(
                     server = state.server,
                     onSave = {
-                        viewModel.saveServer(it)
+                        viewModel.dispatch(RemoteBookIntent.SaveServer(it))
                         showSheet = RemoteBookSheet.Servers
                     },
                     onCancel = { showSheet = RemoteBookSheet.Servers }
@@ -211,8 +211,6 @@ fun RemoteBookScreen(
         }
     }
 
-    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-
     LaunchedEffect(viewModel) {
         viewModel.dispatch(RemoteBookIntent.Initialize)
         viewModel.effects.collect { effect ->
@@ -253,17 +251,37 @@ fun RemoteBookScreen(
         }
     }
 
+    RemoteBookScreen(
+        state = uiState,
+        onIntent = viewModel::dispatch,
+        onBackClick = onBackClick,
+        onOpenServers = { showSheet = RemoteBookSheet.Servers },
+        onRequestReimport = { dialogState = RemoteBookDialog.ReImport(it) },
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun RemoteBookScreen(
+    state: RemoteBookUiState,
+    onIntent: (RemoteBookIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onOpenServers: () -> Unit,
+    onRequestReimport: (RemoteBook) -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
     ListScaffold(
         title = "远程书籍",
-        state = uiState,
+        state = state,
         scrollBehavior = scrollBehavior,
         onBackClick = onBackClick,
-        onSearchToggle = { viewModel.dispatch(RemoteBookIntent.SearchToggle(it)) },
-        onSearchQueryChange = { viewModel.dispatch(RemoteBookIntent.SearchChange(it)) },
+        onSearchToggle = { onIntent(RemoteBookIntent.SearchToggle(it)) },
+        onSearchQueryChange = { onIntent(RemoteBookIntent.SearchChange(it)) },
         searchPlaceholder = "搜索",
         topBarActions = {
             TopBarActionButton(
-                onClick = { showSheet = RemoteBookSheet.Servers },
+                onClick = onOpenServers,
                 imageVector = Icons.Default.Storage,
                 contentDescription = stringResource(R.string.a11y_server_list)
             )
@@ -272,11 +290,11 @@ fun RemoteBookScreen(
             RoundDropdownMenuItem(
                 text = "按名称排序",
                 onClick = {
-                    viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Name))
+                    onIntent(RemoteBookIntent.SortToggle(RemoteBookSort.Name))
                     dismiss()
                 },
                 trailingIcon = {
-                    if (uiState.sortKey == RemoteBookSort.Name) {
+                    if (state.sortKey == RemoteBookSort.Name) {
                         Icon(Icons.Default.Check, null)
                     }
                 }
@@ -284,11 +302,11 @@ fun RemoteBookScreen(
             RoundDropdownMenuItem(
                 text = "按时间排序",
                 onClick = {
-                    viewModel.dispatch(RemoteBookIntent.SortToggle(RemoteBookSort.Default))
+                    onIntent(RemoteBookIntent.SortToggle(RemoteBookSort.Default))
                     dismiss()
                 },
                 trailingIcon = {
-                    if (uiState.sortKey == RemoteBookSort.Default) {
+                    if (state.sortKey == RemoteBookSort.Default) {
                         Icon(Icons.Default.Check, null)
                     }
                 }
@@ -296,25 +314,25 @@ fun RemoteBookScreen(
         },
         bottomContent = {
             PathNavigationBar(
-                pathNames = uiState.pathNames,
-                canGoBack = uiState.canGoBack,
-                onNavigateBack = { viewModel.dispatch(RemoteBookIntent.NavigateBack) },
-                onNavigateToLevel = { viewModel.dispatch(RemoteBookIntent.NavigateToLevel(it)) }
+                pathNames = state.pathNames,
+                canGoBack = state.canGoBack,
+                onNavigateBack = { onIntent(RemoteBookIntent.NavigateBack) },
+                onNavigateToLevel = { onIntent(RemoteBookIntent.NavigateToLevel(it)) }
             )
         },
         selectionActions = SelectionActions(
-            onClearSelection = { viewModel.clearSelection() },
-            onSelectAll = { viewModel.dispatch(RemoteBookIntent.SelectAll) },
-            onSelectInvert = { viewModel.dispatch(RemoteBookIntent.SelectInvert) },
+            onClearSelection = { onIntent(RemoteBookIntent.ClearSelection) },
+            onSelectAll = { onIntent(RemoteBookIntent.SelectAll) },
+            onSelectInvert = { onIntent(RemoteBookIntent.SelectInvert) },
             primaryAction = ActionItem(
                 text = "添加至书架",
                 icon = Icons.Default.CloudDownload,
                 onClick = {
-                    val selectedBooks = uiState.items
-                        .filter { it.id in uiState.selectedIds }
+                    val selectedBooks = state.items
+                        .filter { it.id in state.selectedIds }
                         .map { it.remoteBook }
                         .toSet()
-                    viewModel.dispatch(RemoteBookIntent.AddBooks(selectedBooks))
+                    onIntent(RemoteBookIntent.AddBooks(selectedBooks))
                 }
             ),
             secondaryActions = emptyList()
@@ -325,13 +343,13 @@ fun RemoteBookScreen(
             modifier = Modifier
                 .fillMaxSize()
                 .padding(paddingValues),
-            isRefreshing = uiState.isLoading,
-            onRefresh = { viewModel.dispatch(RemoteBookIntent.Refresh) },
+            isRefreshing = state.isLoading,
+            onRefresh = { onIntent(RemoteBookIntent.Refresh) },
             topPadding = paddingValues.calculateTopPadding(),
             scrollBehavior = scrollBehavior
         ) {
-            if (uiState.items.isEmpty()) {
-                if (uiState.isLoading) {
+            if (state.items.isEmpty()) {
+                if (state.isLoading) {
                     AppCircularProgressIndicator(modifier = Modifier
                         .fillMaxSize()
                         .wrapContentSize(Alignment.Center))
@@ -345,20 +363,20 @@ fun RemoteBookScreen(
                 }
             } else {
                 LazyColumn(modifier = Modifier.fillMaxSize()) {
-                    items(uiState.items, key = { it.id }) { itemUi ->
+                    items(state.items, key = { it.id }) { itemUi ->
                         val book = itemUi.remoteBook
                         RemoteBookItem(
                             modifier = Modifier.animateItem(),
                             book = book,
-                            isSelected = itemUi.id in uiState.selectedIds,
+                            isSelected = itemUi.id in state.selectedIds,
                             onClick = {
-                                viewModel.dispatch(RemoteBookIntent.OpenItem(book))
+                                onIntent(RemoteBookIntent.OpenItem(book))
                             },
                             onAddClick = { remoteBook ->
-                                viewModel.dispatch(RemoteBookIntent.AddBooks(setOf(remoteBook)))
+                                onIntent(RemoteBookIntent.AddBooks(setOf(remoteBook)))
                             },
                             onUpdateClick = { remoteBook ->
-                                dialogState = RemoteBookDialog.ReImport(remoteBook)
+                                onRequestReimport(remoteBook)
                             }
                         )
                     }

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt

@@ -85,6 +85,7 @@ sealed interface RemoteBookIntent {
     data class SortToggle(val sort: RemoteBookSort) : RemoteBookIntent
     data object SelectAll : RemoteBookIntent
     data object SelectInvert : RemoteBookIntent
+    data object ClearSelection : RemoteBookIntent
     data class ToggleSelection(val id: String) : RemoteBookIntent
     data class OpenItem(val book: RemoteBook) : RemoteBookIntent
     data object NavigateBack : RemoteBookIntent
@@ -95,6 +96,8 @@ sealed interface RemoteBookIntent {
     data class BookFolderPicked(val uri: android.net.Uri?) : RemoteBookIntent
     data class ArchiveEntrySelected(val fileDoc: FileDoc, val fileName: String) : RemoteBookIntent
     data class ImportArchiveConfirmed(val fileDoc: FileDoc, val fileName: String) : RemoteBookIntent
+    data class DeleteServer(val server: Server) : RemoteBookIntent
+    data class SaveServer(val server: Server) : RemoteBookIntent
 }
 
 sealed interface RemoteBookEffect {
@@ -139,6 +142,7 @@ class RemoteBookViewModel(
             is RemoteBookIntent.SortToggle -> toggleSort(intent.sort)
             RemoteBookIntent.SelectAll -> selectAllCheckable()
             RemoteBookIntent.SelectInvert -> invertSelection()
+            RemoteBookIntent.ClearSelection -> clearSelection()
             is RemoteBookIntent.ToggleSelection -> toggleSelection(intent.id)
             is RemoteBookIntent.OpenItem -> onOpenItem(intent.book)
             RemoteBookIntent.NavigateBack -> navigateBack()
@@ -156,6 +160,8 @@ class RemoteBookViewModel(
                 intent.fileDoc,
                 intent.fileName
             )
+            is RemoteBookIntent.DeleteServer -> deleteServer(intent.server)
+            is RemoteBookIntent.SaveServer -> saveServer(intent.server)
         }
     }
 

+ 30 - 12
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewScreen.kt

@@ -76,12 +76,30 @@ import java.time.format.DateTimeFormatter
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
-fun ReadRecordOverviewScreen(
+fun ReadRecordOverviewRouteScreen(
     viewModel: ReadRecordOverviewViewModel = koinViewModel(),
     onBackClick: () -> Unit,
     onBookClick: (String, String) -> Unit
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    ReadRecordOverviewScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        loadBookCover = viewModel::getBookCover,
+        onBackClick = onBackClick,
+        onBookClick = onBookClick,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReadRecordOverviewScreen(
+    state: ReadRecordOverviewUiState,
+    onIntent: (ReadRecordOverviewIntent) -> Unit,
+    loadBookCover: suspend (String, String) -> String?,
+    onBackClick: () -> Unit,
+    onBookClick: (String, String) -> Unit,
+) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
 
     AppScaffold(
@@ -103,14 +121,14 @@ fun ReadRecordOverviewScreen(
         ) {
             PeriodSelector(
                 selectedPeriod = state.period,
-                onPeriodSelected = { viewModel.setPeriod(it) }
+                onPeriodSelected = { onIntent(ReadRecordOverviewIntent.SetPeriod(it)) }
             )
 
             DateNavigator(
                 period = state.period,
                 referenceDate = state.referenceDate,
-                onPrevClick = { viewModel.prevDate() },
-                onNextClick = { viewModel.nextDate() }
+                onPrevClick = { onIntent(ReadRecordOverviewIntent.PreviousDate) },
+                onNextClick = { onIntent(ReadRecordOverviewIntent.NextDate) }
             )
 
             LazyColumn(
@@ -142,12 +160,12 @@ fun ReadRecordOverviewScreen(
 
                 if (state.topBooks.isNotEmpty()) {
                     item {
-                        TopReadingListCard(state.topBooks, viewModel, onBookClick)
+                        TopReadingListCard(state.topBooks, loadBookCover, onBookClick)
                     }
                 }
 
                 item {
-                    ReadingCalendarCard(state, viewModel)
+                    ReadingCalendarCard(state, loadBookCover)
                 }
             }
         }
@@ -272,7 +290,7 @@ fun HeatmapCard(state: ReadRecordOverviewUiState) {
 @Composable
 fun TopReadingListCard(
     topBooks: List<ReadBookRanking>,
-    viewModel: ReadRecordOverviewViewModel,
+    loadBookCover: suspend (String, String) -> String?,
     onBookClick: (String, String) -> Unit
 ) {
     GlassCard(
@@ -302,7 +320,7 @@ fun TopReadingListCard(
             topBooks.forEachIndexed { index, book ->
                 var coverPath by remember { mutableStateOf<String?>(null) }
                 LaunchedEffect(book.bookName, book.bookAuthor) {
-                    coverPath = viewModel.getBookCover(book.bookName, book.bookAuthor)
+                    coverPath = loadBookCover(book.bookName, book.bookAuthor)
                 }
                 val rankingDescription = stringResource(
                     R.string.a11y_reading_ranking_item,
@@ -366,7 +384,7 @@ fun TopReadingListCard(
 @Composable
 fun ReadingCalendarCard(
     state: ReadRecordOverviewUiState,
-    viewModel: ReadRecordOverviewViewModel
+    loadBookCover: suspend (String, String) -> String?,
 ) {
     val currentMonth = YearMonth.from(state.referenceDate)
     val daysInMonth = currentMonth.lengthOfMonth()
@@ -421,7 +439,7 @@ fun ReadingCalendarCard(
                         ) {
                             if (dayOfMonth in 1..daysInMonth) {
                                 val date = currentMonth.atDay(dayOfMonth)
-                                CalendarDayCell(date, state, viewModel)
+                                CalendarDayCell(date, state, loadBookCover)
                             }
                         }
                     }
@@ -435,14 +453,14 @@ fun ReadingCalendarCard(
 fun CalendarDayCell(
     date: LocalDate,
     state: ReadRecordOverviewUiState,
-    viewModel: ReadRecordOverviewViewModel
+    loadBookCover: suspend (String, String) -> String?,
 ) {
     val topBook = state.dailyTopBook[date]
     var coverPath by remember { mutableStateOf<String?>(null) }
     
     LaunchedEffect(topBook) {
         topBook?.let { (name, author) ->
-            coverPath = viewModel.getBookCover(name, author)
+            coverPath = loadBookCover(name, author)
         }
     }
     val dayDescription = if (topBook != null) {

+ 18 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordOverviewViewModel.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.readRecord
 
 import androidx.lifecycle.ViewModel
+import androidx.compose.runtime.Stable
 import androidx.lifecycle.viewModelScope
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.ReadRecordRepository
@@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import java.time.LocalDate
 
+@Stable
 data class ReadRecordOverviewUiState(
     val period: ReadPeriod = ReadPeriod.DAY,
     val referenceDate: LocalDate = LocalDate.now(),
@@ -62,6 +64,14 @@ class ReadRecordOverviewViewModel(
         initialValue = ReadRecordOverviewUiState()
     )
 
+    fun onIntent(intent: ReadRecordOverviewIntent) {
+        when (intent) {
+            is ReadRecordOverviewIntent.SetPeriod -> setPeriod(intent.period)
+            ReadRecordOverviewIntent.NextDate -> nextDate()
+            ReadRecordOverviewIntent.PreviousDate -> prevDate()
+        }
+    }
+
     fun setPeriod(period: ReadPeriod) {
         _period.value = period
     }
@@ -90,3 +100,11 @@ class ReadRecordOverviewViewModel(
 
     suspend fun getBookCover(name: String, author: String) = bookRepository.getBookCoverByNameAndAuthor(name, author)
 }
+
+sealed interface ReadRecordOverviewIntent {
+    data class SetPeriod(val period: ReadPeriod) : ReadRecordOverviewIntent
+    data object NextDate : ReadRecordOverviewIntent
+    data object PreviousDate : ReadRecordOverviewIntent
+}
+
+sealed interface ReadRecordOverviewEffect

+ 79 - 40
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -114,6 +114,7 @@ import io.legado.app.utils.StringUtils.formatFriendlyDate
 import io.legado.app.utils.formatReadDuration
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import java.time.LocalDate
 import java.time.format.DateTimeFormatter
 import java.util.Date
@@ -125,19 +126,43 @@ data class TimelineItem(
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun ReadRecordScreen(
+fun ReadRecordRouteScreen(
     viewModel: ReadRecordViewModel = koinViewModel(),
     onBackClick: () -> Unit,
     onBookClick: (String, String) -> Unit,
     onSummaryClick: () -> Unit
+) {
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    ReadRecordScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        loadBookCover = viewModel::getBookCover,
+        loadChapterTitle = viewModel::getChapterTitle,
+        loadMergeCandidates = viewModel::getMergeCandidates,
+        onBackClick = onBackClick,
+        onBookClick = onBookClick,
+        onSummaryClick = onSummaryClick,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ReadRecordScreen(
+    state: ReadRecordUiState,
+    onIntent: (ReadRecordIntent) -> Unit,
+    loadBookCover: suspend (String, String) -> String?,
+    loadChapterTitle: suspend (String, String, Long) -> String?,
+    loadMergeCandidates: suspend (ReadRecord) -> List<ReadRecord>,
+    onBackClick: () -> Unit,
+    onBookClick: (String, String) -> Unit,
+    onSummaryClick: () -> Unit,
 ) {
     val snackbarHostState = remember { SnackbarHostState() }
     val scope = rememberCoroutineScope()
     val context = LocalContext.current
 
-    val state by viewModel.uiState.collectAsState()
-    val displayMode by viewModel.displayMode.collectAsState()
-    val readRecordEnabled by viewModel.readRecordEnabled.collectAsState()
+    val displayMode = state.displayMode
+    val readRecordEnabled = state.readRecordEnabled
     var showSearch by remember { mutableStateOf(false) }
     var showCalendar by remember { mutableStateOf(false) }
     var showActionsSheet by remember { mutableStateOf(false) }
@@ -239,7 +264,7 @@ fun ReadRecordScreen(
                                         deleteSelectedReadRecords(
                                             state = state,
                                             selectedItemKeys = selectedItemKeys,
-                                            viewModel = viewModel
+                                            onIntent = onIntent
                                         )
                                         selectedItemKeys = emptySet()
                                     }
@@ -258,7 +283,7 @@ fun ReadRecordScreen(
                                     DisplayMode.TIMELINE -> DisplayMode.LATEST
                                     DisplayMode.LATEST -> DisplayMode.AGGREGATE
                                 }
-                                viewModel.setDisplayMode(newMode)
+                                onIntent(ReadRecordIntent.SetDisplayMode(newMode))
                                 selectedItemKeys = emptySet()
                             }) {
                                 val icon = when (displayMode) {
@@ -293,7 +318,7 @@ fun ReadRecordScreen(
                 ) {
                     SearchBar(
                         query = state.searchKey ?: "",
-                        onQueryChange = { viewModel.setSearchKey(it) }
+                        onQueryChange = { onIntent(ReadRecordIntent.Search(it)) }
                     )
                 }
             }
@@ -347,12 +372,14 @@ fun ReadRecordScreen(
                             )
                         ) {
                             item(key = "summary_card") {
-                                SummarySection(state, viewModel, onSummaryClick)
+                                SummarySection(state, loadBookCover, onSummaryClick)
                             }
                             renderListByMode(
                                 displayMode = displayMode,
                                 state = state,
-                                viewModel = viewModel,
+                                onIntent = onIntent,
+                                loadBookCover = loadBookCover,
+                                loadChapterTitle = loadChapterTitle,
                                 onBookClick = onBookClick,
                                 onConfirmDelete = onConfirmDelete,
                                 selectedItemKeys = selectedItemKeys,
@@ -369,7 +396,7 @@ fun ReadRecordScreen(
                                 },
                                 onMergeClick = { record ->
                                     scope.launch {
-                                        val candidates = viewModel.getMergeCandidates(record)
+                                        val candidates = loadMergeCandidates(record)
                                         if (candidates.isEmpty()) {
                                             snackbarHostState.showSnackbar(
                                                 context.getString(R.string.no_merge_candidates)
@@ -423,12 +450,12 @@ fun ReadRecordScreen(
             showActionsSheet = false
         },
         onReadRecordEnabledChange = { checked ->
-            viewModel.setReadRecordEnabled(checked)
+            onIntent(ReadRecordIntent.SetEnabled(checked))
         },
         onClearReadRecords = {
             showActionsSheet = false
             onConfirmDelete(-1) {
-                viewModel.clearReadRecords()
+                onIntent(ReadRecordIntent.ClearRecords)
                 selectedItemKeys = emptySet()
             }
         }
@@ -488,7 +515,7 @@ fun ReadRecordScreen(
         endAction = {
             HeatmapCalendarEndAction(
                 onClearDate = {
-                    viewModel.setSelectedDate(null)
+                    onIntent(ReadRecordIntent.SelectDate(null))
                     showCalendar = false
                 }
             )
@@ -500,7 +527,7 @@ fun ReadRecordScreen(
             currentMode = heatmapMode,
             selectedDate = state.selectedDate,
             onDateSelected = { date ->
-                viewModel.setSelectedDate(date)
+                onIntent(ReadRecordIntent.SelectDate(date))
                 showCalendar = false
             }
         )
@@ -561,9 +588,11 @@ fun ReadRecordScreen(
         },
         confirmText = stringResource(R.string.merge),
         onConfirm = { (targetRecord, candidates) ->
-            viewModel.mergeReadRecords(
-                targetRecord,
-                candidates.filter { selectedMergeKeys.contains(it.mergeKey()) }
+            onIntent(
+                ReadRecordIntent.MergeRecords(
+                    targetRecord,
+                    candidates.filter { selectedMergeKeys.contains(it.mergeKey()) },
+                )
             )
             mergeDialogData = null
         },
@@ -637,17 +666,17 @@ private fun ReadRecord.selectionKey(): String {
 private fun deleteSelectedReadRecords(
     state: ReadRecordUiState,
     selectedItemKeys: Set<String>,
-    viewModel: ReadRecordViewModel
+    onIntent: (ReadRecordIntent) -> Unit,
 ) {
     state.groupedRecords.values.flatten()
         .filter { selectedItemKeys.contains(it.selectionKey()) }
-        .forEach(viewModel::deleteDetail)
+        .forEach { onIntent(ReadRecordIntent.DeleteDetail(it)) }
     state.timelineRecords.values.flatten()
         .filter { selectedItemKeys.contains(it.selectionKey()) }
-        .forEach(viewModel::deleteSession)
+        .forEach { onIntent(ReadRecordIntent.DeleteSession(it)) }
     state.latestRecords
         .filter { selectedItemKeys.contains(it.selectionKey()) }
-        .forEach(viewModel::deleteReadRecord)
+        .forEach { onIntent(ReadRecordIntent.DeleteRecord(it)) }
 }
 
 @Composable
@@ -678,7 +707,7 @@ private fun SelectionCheckmark(
 @Composable
 fun SummarySection(
     state: ReadRecordUiState,
-    viewModel: ReadRecordViewModel,
+    loadBookCover: suspend (String, String) -> String?,
     onSummaryClick: () -> Unit
 ) {
     val selectedDate = state.selectedDate
@@ -700,7 +729,7 @@ fun SummarySection(
                 bookCount = distinctBooks.size,
                 totalTimeMillis = dailyTime,
                 bookNamesForCover = distinctBooks.take(3),
-                viewModel = viewModel,
+                loadBookCover = loadBookCover,
                 onClick = onSummaryClick
             )
         }
@@ -714,7 +743,7 @@ fun SummarySection(
                 bookCount = allBooksCount,
                 totalTimeMillis = totalTime,
                 bookNamesForCover = state.latestRecords.take(5).map { it.bookName to it.bookAuthor },
-                viewModel = viewModel,
+                loadBookCover = loadBookCover,
                 onClick = onSummaryClick
             )
         }
@@ -803,7 +832,9 @@ fun HeatmapCalendarSection(
 fun LazyListScope.renderListByMode(
     displayMode: DisplayMode,
     state: ReadRecordUiState,
-    viewModel: ReadRecordViewModel,
+    onIntent: (ReadRecordIntent) -> Unit,
+    loadBookCover: suspend (String, String) -> String?,
+    loadChapterTitle: suspend (String, String, Long) -> String?,
     onBookClick: (String, String) -> Unit,
     onConfirmDelete: (Int, () -> Unit) -> Unit,
     selectedItemKeys: Set<String>,
@@ -828,7 +859,7 @@ fun LazyListScope.renderListByMode(
                     val itemContent: @Composable (Modifier) -> Unit = { modifier ->
                         ReadRecordItem(
                             detail,
-                            viewModel,
+                            loadBookCover,
                             onClick = {
                                 if (inSelectionMode) {
                                     onToggleSelection(itemKey)
@@ -852,7 +883,9 @@ fun LazyListScope.renderListByMode(
                                 icon = Icons.Default.Delete,
                                 background = LegadoTheme.colorScheme.error,
                                 onSwipe = {
-                                    onConfirmDelete(1) { viewModel.deleteDetail(detail) }
+                                    onConfirmDelete(1) {
+                                        onIntent(ReadRecordIntent.DeleteDetail(detail))
+                                    }
                                 },
                                 contentDescription = deleteActionDescription
                             )
@@ -883,7 +916,8 @@ fun LazyListScope.renderListByMode(
                             onLongClick = { onEnterSelection(itemKey) },
                             inSelectionMode = inSelectionMode,
                             isSelected = isSelected,
-                            viewModel = viewModel,
+                            loadBookCover = loadBookCover,
+                            loadChapterTitle = loadChapterTitle,
                             modifier = modifier
                         )
                     }
@@ -897,7 +931,9 @@ fun LazyListScope.renderListByMode(
                                 icon = Icons.Default.Delete,
                                 background = LegadoTheme.colorScheme.error,
                                 onSwipe = {
-                                    onConfirmDelete(1) { viewModel.deleteSession(session) }
+                                    onConfirmDelete(1) {
+                                        onIntent(ReadRecordIntent.DeleteSession(session))
+                                    }
                                 },
                                 contentDescription = deleteActionDescription
                             )
@@ -916,7 +952,7 @@ fun LazyListScope.renderListByMode(
                 val itemContent: @Composable (Modifier) -> Unit = { modifier ->
                     LatestReadItem(
                         record = record,
-                        viewModel = viewModel,
+                        loadBookCover = loadBookCover,
                         onClick = {
                             if (inSelectionMode) {
                                 onToggleSelection(itemKey)
@@ -941,7 +977,9 @@ fun LazyListScope.renderListByMode(
                             icon = Icons.Default.Delete,
                             background = LegadoTheme.colorScheme.error,
                             onSwipe = {
-                                onConfirmDelete(1) { viewModel.deleteReadRecord(record) }
+                                onConfirmDelete(1) {
+                                    onIntent(ReadRecordIntent.DeleteRecord(record))
+                                }
                             },
                             contentDescription = deleteActionDescription
                         ),
@@ -965,7 +1003,7 @@ fun LazyListScope.renderListByMode(
 @Composable
 fun LatestReadItem(
     record: ReadRecord,
-    viewModel: ReadRecordViewModel,
+    loadBookCover: suspend (String, String) -> String?,
     onClick: () -> Unit,
     onLongClick: () -> Unit = {},
     inSelectionMode: Boolean = false,
@@ -975,7 +1013,7 @@ fun LatestReadItem(
     var coverPath by remember { mutableStateOf<String?>(null) }
 
     LaunchedEffect(record.bookName, record.bookAuthor) {
-        coverPath = viewModel.getBookCover(record.bookName, record.bookAuthor)
+        coverPath = loadBookCover(record.bookName, record.bookAuthor)
     }
     val unknownAuthor = stringResource(R.string.unknown_author)
     val author = record.bookAuthor.ifBlank { unknownAuthor }
@@ -1056,7 +1094,8 @@ fun LatestReadItem(
 @Composable
 fun TimelineSessionItem(
     item: TimelineItem,
-    viewModel: ReadRecordViewModel,
+    loadBookCover: suspend (String, String) -> String?,
+    loadChapterTitle: suspend (String, String, Long) -> String?,
     onBookClick: (String, String) -> Unit,
     onLongClick: () -> Unit = {},
     inSelectionMode: Boolean = false,
@@ -1070,8 +1109,8 @@ fun TimelineSessionItem(
     var chapterTitle by remember { mutableStateOf<String?>(loadingText) }
 
     LaunchedEffect(session.bookName, session.bookAuthor, session.words, fallbackChapterTitle) {
-        coverPath = viewModel.getBookCover(session.bookName, session.bookAuthor)
-        val title = viewModel.getChapterTitle(session.bookName, session.bookAuthor, session.words)
+        coverPath = loadBookCover(session.bookName, session.bookAuthor)
+        val title = loadChapterTitle(session.bookName, session.bookAuthor, session.words)
         chapterTitle = title ?: fallbackChapterTitle
     }
 
@@ -1185,7 +1224,7 @@ fun TimelineSessionItem(
 @Composable
 fun ReadRecordItem(
     detail: ReadRecordDetail,
-    viewModel: ReadRecordViewModel,
+    loadBookCover: suspend (String, String) -> String?,
     onClick: () -> Unit,
     onLongClick: () -> Unit = {},
     inSelectionMode: Boolean = false,
@@ -1195,7 +1234,7 @@ fun ReadRecordItem(
     var coverPath by remember { mutableStateOf<String?>(null) }
 
     LaunchedEffect(detail.bookName, detail.bookAuthor) {
-        coverPath = viewModel.getBookCover(detail.bookName, detail.bookAuthor)
+        coverPath = loadBookCover(detail.bookName, detail.bookAuthor)
     }
     val unknownAuthor = stringResource(R.string.unknown_author)
     val author = detail.bookAuthor.ifBlank { unknownAuthor }
@@ -1288,13 +1327,13 @@ fun ReadingSummaryCard(
     bookCount: Int,
     totalTimeMillis: Long,
     bookNamesForCover: List<Pair<String, String>>,
-    viewModel: ReadRecordViewModel,
+    loadBookCover: suspend (String, String) -> String?,
     onClick: () -> Unit
 ) {
 
     val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
         value = bookNamesForCover.map { (name, author) ->
-            viewModel.getBookCover(name, author)
+            loadBookCover(name, author)
         }
     }
 

+ 40 - 4
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.readRecord
 
 import androidx.lifecycle.ViewModel
+import androidx.compose.runtime.Stable
 import androidx.lifecycle.viewModelScope
 import cn.hutool.core.date.DateUtil
 import io.legado.app.data.entities.readRecord.ReadRecord
@@ -24,6 +25,7 @@ import java.time.LocalDate
 import java.time.format.DateTimeFormatter
 import java.util.Date
 
+@Stable
 data class ReadRecordUiState(
     val isLoading: Boolean = true,
     val totalReadTime: Long = 0,
@@ -33,7 +35,9 @@ data class ReadRecordUiState(
     val selectedDate: LocalDate? = null,
     val searchKey: String? = null,
     val dailyReadCounts: Map<LocalDate, Int> = emptyMap(),
-    val dailyReadTimes: Map<LocalDate, Long> = emptyMap()
+    val dailyReadTimes: Map<LocalDate, Long> = emptyMap(),
+    val displayMode: DisplayMode = DisplayMode.AGGREGATE,
+    val readRecordEnabled: Boolean = true,
 )
 
 enum class DisplayMode {
@@ -87,8 +91,10 @@ class ReadRecordViewModel(
     val uiState: StateFlow<ReadRecordUiState> = combine(
         loadedDataFlow,
         _selectedDate,
-        _searchKey
-    ) { data, selectedDate, searchKey ->
+        _searchKey,
+        _displayMode,
+        readRecordEnabled,
+    ) { data, selectedDate, searchKey, displayMode, enabled ->
         val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
 
         val dailyCounts = data.details
@@ -131,7 +137,9 @@ class ReadRecordViewModel(
             selectedDate = selectedDate,
             searchKey = searchKey,
             dailyReadCounts = dailyCounts,
-            dailyReadTimes = dailyTimes
+            dailyReadTimes = dailyTimes,
+            displayMode = displayMode,
+            readRecordEnabled = enabled,
         )
     }.stateIn(
         scope = viewModelScope,
@@ -139,6 +147,20 @@ class ReadRecordViewModel(
         initialValue = ReadRecordUiState(isLoading = true)
     )
 
+    fun onIntent(intent: ReadRecordIntent) {
+        when (intent) {
+            is ReadRecordIntent.Search -> setSearchKey(intent.query)
+            is ReadRecordIntent.SetDisplayMode -> setDisplayMode(intent.mode)
+            is ReadRecordIntent.SelectDate -> setSelectedDate(intent.date)
+            is ReadRecordIntent.DeleteDetail -> deleteDetail(intent.detail)
+            is ReadRecordIntent.DeleteSession -> deleteSession(intent.session)
+            is ReadRecordIntent.DeleteRecord -> deleteReadRecord(intent.record)
+            ReadRecordIntent.ClearRecords -> clearReadRecords()
+            is ReadRecordIntent.SetEnabled -> setReadRecordEnabled(intent.enabled)
+            is ReadRecordIntent.MergeRecords -> mergeReadRecords(intent.target, intent.sources)
+        }
+    }
+
     fun setSearchKey(query: String) {
         _searchKey.value = query
     }
@@ -224,3 +246,17 @@ class ReadRecordViewModel(
         val sessions: List<ReadRecordSession>
     )
 }
+
+sealed interface ReadRecordIntent {
+    data class Search(val query: String) : ReadRecordIntent
+    data class SetDisplayMode(val mode: DisplayMode) : ReadRecordIntent
+    data class SelectDate(val date: LocalDate?) : ReadRecordIntent
+    data class DeleteDetail(val detail: ReadRecordDetail) : ReadRecordIntent
+    data class DeleteSession(val session: ReadRecordSession) : ReadRecordIntent
+    data class DeleteRecord(val record: ReadRecord) : ReadRecordIntent
+    data object ClearRecords : ReadRecordIntent
+    data class SetEnabled(val enabled: Boolean) : ReadRecordIntent
+    data class MergeRecords(val target: ReadRecord, val sources: List<ReadRecord>) : ReadRecordIntent
+}
+
+sealed interface ReadRecordEffect

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

@@ -31,7 +31,7 @@ class SearchActivity : BaseComposeActivity() {
 
     @Composable
     override fun Content() {
-        SearchScreen(
+        SearchRouteScreen(
             viewModel = viewModel,
             onBack = { finish() },
             onOpenBookInfo = { name, author, bookUrl, origin, coverPath, _ ->

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -54,6 +54,7 @@ data class SearchUiState(
     val expandedSourceError: String? = null,
     val expandedSourcePage: Int = 1,
     val showExpandedSource: Boolean = false,
+    val layoutMode: Int = 0,
     val expandedSourceSavedScrollIndex: Int = 0,
     val expandedSourceSavedScrollOffset: Int = 0,
 )
@@ -74,6 +75,7 @@ sealed interface SearchIntent {
     data object ResumeEngine : SearchIntent
     data class UseHistoryKeyword(val keyword: String) : SearchIntent
     data class OpenSearchBook(val book: SearchBook, val sharedCoverKey: String?) : SearchIntent
+    data class AddToShelf(val book: SearchBook) : SearchIntent
     data class OpenBookshelfBook(val book: BookShelfItem) : SearchIntent
     data class ExpandSource(val sourceUrl: String, val sourceName: String) : SearchIntent
     data object DismissExpandedSource : SearchIntent
@@ -85,6 +87,7 @@ sealed interface SearchIntent {
     data object ConfirmClearHistory : SearchIntent
     data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
     data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
+    data class SetLayoutMode(val mode: Int) : SearchIntent
     data class ToggleSourceType(val type: Int) : SearchIntent
     data object ClearAllSourceTypes : SearchIntent
     data object SelectAllScope : SearchIntent

+ 95 - 90
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -93,7 +93,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
 
 @OptIn(FlowPreview::class, ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
 @Composable
-fun SearchScreen(
+fun SearchRouteScreen(
     viewModel: SearchViewModel,
     onBack: () -> Unit,
     onOpenBookInfo: (name: String, author: String, bookUrl: String, origin: String?, coverPath: String?, sharedCoverKey: String?) -> Unit,
@@ -103,7 +103,45 @@ fun SearchScreen(
 ) {
     val context = LocalContext.current
     val state by viewModel.uiState.collectAsStateWithLifecycle()
-    val searchLayoutMode by viewModel.searchLayoutMode.collectAsStateWithLifecycle()
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is SearchEffect.OpenBookInfo -> {
+                    onOpenBookInfo(
+                        effect.name,
+                        effect.author,
+                        effect.bookUrl,
+                        effect.origin,
+                        effect.coverPath,
+                        effect.sharedCoverKey
+                    )
+                }
+
+                SearchEffect.OpenSourceManage -> onOpenSourceManage()
+                is SearchEffect.ShowMessage -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    SearchScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBack = onBack,
+        sharedTransitionScope = sharedTransitionScope,
+        animatedVisibilityScope = animatedVisibilityScope,
+    )
+}
+
+@OptIn(FlowPreview::class, ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
+@Composable
+fun SearchScreen(
+    state: SearchUiState,
+    onIntent: (SearchIntent) -> Unit,
+    onBack: () -> Unit,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+) {
+    val searchLayoutMode = state.layoutMode
     val isSourceGroupedMode = searchLayoutMode == 1
     var previewBook by remember { mutableStateOf<SearchBook?>(null) }
     var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
@@ -120,7 +158,6 @@ fun SearchScreen(
     } else {
         M3GlassScrollBehavior(TopAppBarDefaults.enterAlwaysScrollBehavior())
     }
-
     val shouldLoadMore by remember {
         derivedStateOf {
             val totalCount = listState.layoutInfo.totalItemsCount
@@ -128,7 +165,6 @@ fun SearchScreen(
             totalCount > 0 && lastVisible >= totalCount - 3
         }
     }
-
     val shouldLoadMoreGrouped by remember {
         derivedStateOf {
             val totalCount = groupedListState.layoutInfo.totalItemsCount
@@ -136,13 +172,9 @@ fun SearchScreen(
             totalCount > 0 && lastVisible >= totalCount - 3
         }
     }
-
     LaunchedEffect(state.query) {
-        if (state.query != queryInput) {
-            queryInput = state.query
-        }
+        if (state.query != queryInput) queryInput = state.query
     }
-
     LaunchedEffect(Unit) {
         snapshotFlow { queryInput }
             .distinctUntilChanged()
@@ -152,12 +184,9 @@ fun SearchScreen(
                     ignoreNextDebouncedQuery = null
                     return@collect
                 }
-                if (newQuery != latestQuery) {
-                    viewModel.onIntent(SearchIntent.UpdateQuery(newQuery))
-                }
+                if (newQuery != latestQuery) onIntent(SearchIntent.UpdateQuery(newQuery))
             }
     }
-
     LaunchedEffect(
         shouldLoadMore,
         shouldLoadMoreGrouped,
@@ -167,42 +196,18 @@ fun SearchScreen(
         state.showSuggestions,
         isSourceGroupedMode,
     ) {
-        val readyToLoad = !state.isSearching &&
-            state.hasMore &&
-            !state.isManualStop &&
-            !state.showSuggestions
+        val readyToLoad = !state.isSearching && state.hasMore &&
+            !state.isManualStop && !state.showSuggestions
         val nearEnd = if (isSourceGroupedMode) shouldLoadMoreGrouped else shouldLoadMore
-        if (readyToLoad && nearEnd) {
-            viewModel.onIntent(SearchIntent.LoadMore)
-        }
-    }
-
-    LaunchedEffect(viewModel) {
-        viewModel.effects.collect { effect ->
-            when (effect) {
-                is SearchEffect.OpenBookInfo -> {
-                    onOpenBookInfo(
-                        effect.name,
-                        effect.author,
-                        effect.bookUrl,
-                        effect.origin,
-                        effect.coverPath,
-                        effect.sharedCoverKey
-                    )
-                }
-
-                SearchEffect.OpenSourceManage -> onOpenSourceManage()
-                is SearchEffect.ShowMessage -> context.toastOnUi(effect.message)
-            }
-        }
+        if (readyToLoad && nearEnd) onIntent(SearchIntent.LoadMore)
     }
 
     // Activity lifecycle (e.g., Home button, switching apps)
-    DisposableEffect(lifecycleOwner, viewModel) {
+    DisposableEffect(lifecycleOwner) {
         val observer = LifecycleEventObserver { _, event ->
             when (event) {
-                Lifecycle.Event.ON_RESUME -> viewModel.onIntent(SearchIntent.ResumeEngine)
-                Lifecycle.Event.ON_PAUSE -> viewModel.onIntent(SearchIntent.PauseEngine)
+                Lifecycle.Event.ON_RESUME -> onIntent(SearchIntent.ResumeEngine)
+                Lifecycle.Event.ON_PAUSE -> onIntent(SearchIntent.PauseEngine)
                 else -> Unit
             }
         }
@@ -213,23 +218,23 @@ fun SearchScreen(
     }
 
     // Composition lifecycle (e.g., navigating to BookInfo and back)
-    DisposableEffect(viewModel) {
+    DisposableEffect(Unit) {
         onDispose {
-            viewModel.onIntent(SearchIntent.PauseEngine)
+            onIntent(SearchIntent.PauseEngine)
         }
     }
 
-    LaunchedEffect(viewModel) {
-        viewModel.onIntent(SearchIntent.ResumeEngine)
+    LaunchedEffect(Unit) {
+        onIntent(SearchIntent.ResumeEngine)
     }
 
     // Save scroll position before composable leaves composition (e.g., navigating to BookInfo)
-    DisposableEffect(viewModel) {
+    DisposableEffect(Unit) {
         onDispose {
             val first = listState.firstVisibleItemIndex
             val offset = listState.firstVisibleItemScrollOffset
             if (first > 0 || offset > 0) {
-                viewModel.onIntent(SearchIntent.SaveScrollState(first, offset))
+                onIntent(SearchIntent.SaveScrollState(first, offset))
             }
         }
     }
@@ -240,7 +245,7 @@ fun SearchScreen(
         val off = state.savedScrollOffset
         if (idx > 0 || off > 0) {
             listState.scrollToItem(idx, off)
-            viewModel.onIntent(SearchIntent.SaveScrollState(0, 0))
+            onIntent(SearchIntent.SaveScrollState(0, 0))
         }
     }
 
@@ -281,9 +286,9 @@ fun SearchScreen(
             ignoreNextDebouncedQuery = normalized
             queryInput = normalized
             if (normalized != state.query) {
-                viewModel.onIntent(SearchIntent.UpdateQuery(normalized))
+                onIntent(SearchIntent.UpdateQuery(normalized))
             }
-            viewModel.onIntent(SearchIntent.SubmitSearch)
+            onIntent(SearchIntent.SubmitSearch)
         }
     }
 
@@ -311,7 +316,7 @@ fun SearchScreen(
                         TopBarAnimatedActionButton(
                             checked = isSourceGroupedMode || state.selectedSourceTypes.isNotEmpty(),
                             onCheckedChange = {
-                                viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(true))
+                                onIntent(SearchIntent.SetSettingsSheetVisible(true))
                             },
                             iconChecked = AppIcons.Settings,
                             iconUnchecked = AppIcons.Settings,
@@ -322,7 +327,7 @@ fun SearchScreen(
                             checked = state.matchMode == MatchMode.EXACT,
                             onCheckedChange = {
                                 val newMode = if (state.matchMode == MatchMode.EXACT) MatchMode.DEFAULT else MatchMode.EXACT
-                                viewModel.onIntent(SearchIntent.SetMatchMode(newMode))
+                                onIntent(SearchIntent.SetMatchMode(newMode))
                             },
                             iconChecked = AppIcons.PrecisionSearch,
                             iconUnchecked = AppIcons.UnPrecisionSearch,
@@ -332,7 +337,7 @@ fun SearchScreen(
                         TopBarAnimatedActionButton(
                             checked = !state.isAllScope,
                             onCheckedChange = {
-                                viewModel.onIntent(SearchIntent.SetScopeSheetVisible(true))
+                                onIntent(SearchIntent.SetScopeSheetVisible(true))
                             },
                             iconChecked = AppIcons.Filter,
                             iconUnchecked = AppIcons.Filter,
@@ -360,7 +365,7 @@ fun SearchScreen(
                                     modifier = Modifier.padding(horizontal = 8.dp),
                                     onClick = {
                                         queryInput = ""
-                                        viewModel.onIntent(SearchIntent.UpdateQuery(""))
+                                        onIntent(SearchIntent.UpdateQuery(""))
                                     },
                                     icon = AppIcons.Close,
                                     contentDescription = stringResource(R.string.clear)
@@ -377,9 +382,9 @@ fun SearchScreen(
                 AppFloatingActionButton(
                     onClick = {
                         if (state.isSearching) {
-                            viewModel.onIntent(SearchIntent.StopSearch)
+                            onIntent(SearchIntent.StopSearch)
                         } else {
-                            viewModel.onIntent(SearchIntent.LoadMore)
+                            onIntent(SearchIntent.LoadMore)
                         }
                     },
                     icon = if (state.isSearching) Icons.Default.Stop else Icons.Default.PlayArrow,
@@ -403,14 +408,14 @@ fun SearchScreen(
                         state = state,
                         onUseHistory = { keyword ->
                             queryInput = keyword
-                            viewModel.onIntent(SearchIntent.UseHistoryKeyword(keyword))
+                            onIntent(SearchIntent.UseHistoryKeyword(keyword))
                         },
-                        onDeleteHistory = { viewModel.onIntent(SearchIntent.DeleteHistory(it)) },
+                        onDeleteHistory = { onIntent(SearchIntent.DeleteHistory(it)) },
                         onOpenBook = {
-                            viewModel.onIntent(SearchIntent.OpenBookshelfBook(it))
+                            onIntent(SearchIntent.OpenBookshelfBook(it))
                         },
                         onClearHistory = {
-                            viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(true))
+                            onIntent(SearchIntent.SetClearHistoryDialogVisible(true))
                         },
                         modifier = Modifier.fillMaxSize(),
                     )
@@ -429,7 +434,7 @@ fun SearchScreen(
                                         hasMore = state.hasMore,
                                         hasResult = false,
                                         committedQuery = state.committedQuery,
-                                        onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
+                                        onLoadMore = { onIntent(SearchIntent.LoadMore) },
                                     )
                                 }
                             }
@@ -470,7 +475,7 @@ fun SearchScreen(
                                                     sourceName = group.sourceName,
                                                     items = group.items,
                                                     onClickBook = { book, coverKey ->
-                                                        viewModel.onIntent(
+                                                        onIntent(
                                                             SearchIntent.OpenSearchBook(
                                                                 book,
                                                                 coverKey
@@ -482,7 +487,7 @@ fun SearchScreen(
                                                         previewSharedCoverKey = coverKey
                                                     },
                                                     onViewAll = {
-                                                        viewModel.onIntent(
+                                                        onIntent(
                                                             SearchIntent.ExpandSource(
                                                                 group.origin,
                                                                 group.sourceName
@@ -502,7 +507,7 @@ fun SearchScreen(
                                                 hasMore = state.hasMore,
                                                 hasResult = true,
                                                 committedQuery = state.committedQuery,
-                                                onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
+                                                onLoadMore = { onIntent(SearchIntent.LoadMore) },
                                             )
                                         }
                                     }
@@ -528,7 +533,7 @@ fun SearchScreen(
                                                 book = item.book,
                                                 shelfState = item.shelfState,
                                                 onClick = {
-                                                    viewModel.onIntent(
+                                                    onIntent(
                                                         SearchIntent.OpenSearchBook(
                                                             item.book,
                                                             sharedCoverKey
@@ -552,7 +557,7 @@ fun SearchScreen(
                                                 hasMore = state.hasMore,
                                                 hasResult = true,
                                                 committedQuery = state.committedQuery,
-                                                onLoadMore = { viewModel.onIntent(SearchIntent.LoadMore) },
+                                                onLoadMore = { onIntent(SearchIntent.LoadMore) },
                                             )
                                         }
                                     }
@@ -599,22 +604,22 @@ fun SearchScreen(
     AppAlertDialog(
         show = state.showClearHistoryDialog,
         onDismissRequest = {
-            viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
+            onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
         },
         title = stringResource(R.string.draw),
         text = stringResource(R.string.sure_clear_search_history),
         confirmText = stringResource(R.string.ok),
-        onConfirm = { viewModel.onIntent(SearchIntent.ConfirmClearHistory) },
+        onConfirm = { onIntent(SearchIntent.ConfirmClearHistory) },
         dismissText = stringResource(R.string.cancel),
         onDismiss = {
-            viewModel.onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
+            onIntent(SearchIntent.SetClearHistoryDialogVisible(false))
         },
     )
 
     AppAlertDialog(
         data = state.emptyScopeAction,
         onDismissRequest = {
-            viewModel.onIntent(SearchIntent.DismissEmptyScopeAction)
+            onIntent(SearchIntent.DismissEmptyScopeAction)
         },
         title = stringResource(R.string.draw),
         textProvider = {
@@ -625,26 +630,26 @@ fun SearchScreen(
             }
         },
         confirmText = stringResource(R.string.ok),
-        onConfirm = { viewModel.onIntent(SearchIntent.ConfirmEmptyScopeAction) },
+        onConfirm = { onIntent(SearchIntent.ConfirmEmptyScopeAction) },
         dismissText = stringResource(R.string.cancel),
-        onDismiss = { viewModel.onIntent(SearchIntent.DismissEmptyScopeAction) },
+        onDismiss = { onIntent(SearchIntent.DismissEmptyScopeAction) },
     )
 
     ScopeSelectSheet(
         show = state.showScopeSheet,
-        onDismissRequest = { viewModel.onIntent(SearchIntent.SetScopeSheetVisible(false)) },
+        onDismissRequest = { onIntent(SearchIntent.SetScopeSheetVisible(false)) },
         isAll = state.isAllScope,
-        onSelectAll = { viewModel.onIntent(SearchIntent.SelectAllScope) },
+        onSelectAll = { onIntent(SearchIntent.SelectAllScope) },
         groups = state.enabledGroups,
         selectedGroups = state.scopeDisplayNames,
-        onToggleGroup = { viewModel.onIntent(SearchIntent.ToggleScopeGroup(it)) },
+        onToggleGroup = { onIntent(SearchIntent.ToggleScopeGroup(it)) },
         sources = state.enabledSources,
         selectedSources = state.selectedScopeSourceUrls,
-        onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
+        onToggleSource = { onIntent(SearchIntent.ToggleScopeSource(it)) },
         isSourceScope = state.isSourceScope,
-        onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
+        onConfirm = { onIntent(SearchIntent.OpenSourceManage) },
         onApplyScope = { selection ->
-            viewModel.onIntent(
+            onIntent(
                 SearchIntent.ApplyScopeSelection(
                     groupNames = selection.groupNames,
                     sources = selection.sources,
@@ -656,7 +661,7 @@ fun SearchScreen(
 
     AppModalBottomSheet(
         show = state.showSettingsSheet,
-        onDismissRequest = { viewModel.onIntent(SearchIntent.SetSettingsSheetVisible(false)) },
+        onDismissRequest = { onIntent(SearchIntent.SetSettingsSheetVisible(false)) },
         title = stringResource(R.string.setting),
     ) {
         Column(
@@ -673,7 +678,7 @@ fun SearchScreen(
                 imageVector = if (isSourceGroupedMode) Icons.Default.GridView else Icons.AutoMirrored.Outlined.FormatListBulleted,
                 onValueChange = { newValue ->
                     if (newValue.toInt() != searchLayoutMode) {
-                        viewModel.toggleSearchLayout()
+                        onIntent(SearchIntent.SetLayoutMode(newValue.toInt()))
                     }
                 }
             )
@@ -703,7 +708,7 @@ fun SearchScreen(
                     onToggleSelection = {
                         if (state.selectedSourceTypes.isNotEmpty()) {
                             state.selectedSourceTypes.forEach {
-                                viewModel.onIntent(SearchIntent.ToggleSourceType(it))
+                                onIntent(SearchIntent.ToggleSourceType(it))
                             }
                         }
                     }
@@ -720,7 +725,7 @@ fun SearchScreen(
                         containerColor = LegadoTheme.colorScheme.onSheetContent,
                         inSelectionMode = true,
                         onToggleSelection = {
-                            viewModel.onIntent(SearchIntent.ToggleSourceType(type))
+                            onIntent(SearchIntent.ToggleSourceType(type))
                         }
                     )
                 }
@@ -743,10 +748,10 @@ fun SearchScreen(
         onDismissRequest = { previewBook = null },
         onOpenDetail = { book, sharedCoverKey ->
             previewBook = null
-            viewModel.onIntent(SearchIntent.OpenSearchBook(book, sharedCoverKey))
+            onIntent(SearchIntent.OpenSearchBook(book, sharedCoverKey))
         },
         onAddToShelf = { book ->
-            viewModel.onAddToShelf(book)
+            onIntent(SearchIntent.AddToShelf(book))
         },
     )
 
@@ -759,13 +764,13 @@ fun SearchScreen(
         errorMsg = state.expandedSourceError,
         savedScrollIndex = state.expandedSourceSavedScrollIndex,
         savedScrollOffset = state.expandedSourceSavedScrollOffset,
-        onDismiss = { viewModel.onIntent(SearchIntent.DismissExpandedSource) },
-        onLoadMore = { viewModel.onIntent(SearchIntent.LoadMoreExpandedSource) },
+        onDismiss = { onIntent(SearchIntent.DismissExpandedSource) },
+        onLoadMore = { onIntent(SearchIntent.LoadMoreExpandedSource) },
         onSaveScrollState = { index, offset ->
-            viewModel.onIntent(SearchIntent.SaveExpandedSourceScrollState(index, offset))
+            onIntent(SearchIntent.SaveExpandedSourceScrollState(index, offset))
         },
         onBookClick = { book, coverKey ->
-            viewModel.onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey))
+            onIntent(SearchIntent.OpenExpandedSourceBook(book, coverKey))
         },
         onBookLongClick = { book, coverKey ->
             previewBook = book

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

@@ -52,7 +52,7 @@ class SearchViewModel(
     private val localPreferencesRepository: SettingsRepository,
 ) : ViewModel() {
 
-    val searchLayoutMode = localPreferencesRepository
+    private val searchLayoutMode = localPreferencesRepository
         .getPreference(LocalPreferencesKeys.SEARCH_LAYOUT_MODE, 0)
         .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
 
@@ -111,6 +111,9 @@ class SearchViewModel(
         observeQueryHistory()
         observeQueryBookshelfHints()
         observeMatchMode()
+        viewModelScope.launch {
+            searchLayoutMode.collect { mode -> _uiState.update { it.copy(layoutMode = mode) } }
+        }
     }
 
     fun onAddToShelf(book: SearchBook) {
@@ -160,6 +163,10 @@ class SearchViewModel(
                 )
             }
 
+            is SearchIntent.AddToShelf -> viewModelScope.launch {
+                addToBookshelfUseCase.execute(intent.book)
+            }
+
             is SearchIntent.OpenBookshelfBook -> {
                 emitEffect(
                     SearchEffect.OpenBookInfo(
@@ -196,6 +203,16 @@ class SearchViewModel(
                 _uiState.update { it.copy(showSettingsSheet = intent.visible) }
             }
 
+            is SearchIntent.SetLayoutMode -> {
+                _uiState.update { it.copy(layoutMode = intent.mode) }
+                viewModelScope.launch {
+                    localPreferencesRepository.updatePreference(
+                        LocalPreferencesKeys.SEARCH_LAYOUT_MODE,
+                        intent.mode,
+                    )
+                }
+            }
+
             is SearchIntent.ToggleSourceType -> {
                 _uiState.update { state ->
                     val current = state.selectedSourceTypes

+ 51 - 29
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -39,7 +39,6 @@ import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.snapshotFlow
@@ -51,6 +50,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.buildAnnotatedString
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.data.entities.SearchContentHistory
 import io.legado.app.ui.theme.LegadoTheme
@@ -77,30 +77,50 @@ import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun SearchContentScreen(
+fun SearchContentRouteScreen(
     onBack: () -> Unit,
     viewModel: SearchContentViewModel = koinViewModel()
 ) {
-    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 state by viewModel.uiState.collectAsStateWithLifecycle()
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                SearchContentEffect.NavigateBack -> onBack()
+            }
+        }
+    }
+    DisposableEffect(viewModel) {
+        onDispose { viewModel.onIntent(SearchContentIntent.LeaveSearch) }
+    }
+    SearchContentScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBack = onBack,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SearchContentScreen(
+    state: SearchContentUiState,
+    onIntent: (SearchContentIntent) -> Unit,
+    onBack: () -> Unit,
+) {
+    val searchQuery = state.searchQuery
+    val replaceEnabled = state.replaceEnabled
+    val regexReplace = state.regexReplace
+    val searchHistory = state.searchHistory
+    val historyOnlyThisBook = state.historyOnlyThisBook
 
-    val isSearching = uiState.isSearching
-    val searchResults = uiState.searchResults
-    val durChapterIndex = uiState.durChapterIndex
-    val error = uiState.error
+    val isSearching = state.isSearching
+    val searchResults = state.searchResults
+    val durChapterIndex = state.durChapterIndex
+    val error = state.error
 
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val listState = rememberLazyListState()
     val scope = rememberCoroutineScope()
 
-    DisposableEffect(viewModel) {
-        onDispose { viewModel.leaveSearch() }
-    }
-
     val scrollToCurrentChapter = {
         val targetIndex = searchResults.indexOfFirst { it.chapterIndex == durChapterIndex }
         if (targetIndex != -1) {
@@ -111,13 +131,13 @@ fun SearchContentScreen(
     }
 
     LaunchedEffect(searchResults) {
-        if (searchResults.isNotEmpty() && viewModel.shouldAutoScroll()) {
+        if (searchResults.isNotEmpty() && state.shouldAutoScroll) {
             val targetIndex = searchResults.indexOfFirst { it.chapterIndex == durChapterIndex }
             if (targetIndex != -1) {
                 snapshotFlow { listState.layoutInfo.totalItemsCount }.collect { count ->
                     if (count > targetIndex) {
                         listState.animateScrollToItem(targetIndex)
-                        viewModel.markScrollDone()
+                        onIntent(SearchContentIntent.MarkAutoScrollDone)
                         return@collect
                     }
                 }
@@ -148,7 +168,7 @@ fun SearchContentScreen(
                         ) {
                             TopBarAnimatedActionButton(
                                 checked = replaceEnabled,
-                                onCheckedChange = { viewModel.toggleReplace(it) },
+                                onCheckedChange = { onIntent(SearchContentIntent.ToggleReplace(it)) },
                                 iconChecked = Icons.Default.FindReplace,
                                 iconUnchecked = Icons.Default.FindReplace,
                                 activeText = "替换开启",
@@ -157,7 +177,7 @@ fun SearchContentScreen(
 
                             TopBarAnimatedActionButton(
                                 checked = regexReplace,
-                                onCheckedChange = { viewModel.toggleRegex(it) },
+                                onCheckedChange = { onIntent(SearchContentIntent.ToggleRegex(it)) },
                                 iconChecked = Icons.Default.Code,
                                 iconUnchecked = Icons.Default.Code,
                                 activeText = "正则开启",
@@ -173,7 +193,7 @@ fun SearchContentScreen(
                     SearchBar(
                         query = searchQuery,
                         scrollState = listState,
-                        onQueryChange = { viewModel.onQueryChange(it) }
+                        onQueryChange = { onIntent(SearchContentIntent.UpdateQuery(it)) }
                     )
                 }
 
@@ -191,7 +211,7 @@ fun SearchContentScreen(
                 ),
                 onClick = {
                     if (isSearching) {
-                        viewModel.stopSearch()
+                        onIntent(SearchContentIntent.StopSearch)
                     } else {
                         scrollToCurrentChapter()
                     }
@@ -238,10 +258,14 @@ fun SearchContentScreen(
                         SearchHistoryList(
                             history = searchHistory,
                             onlyThisBook = historyOnlyThisBook,
-                            onHistoryClick = { viewModel.onQueryChange(it.query) },
-                            onDeleteHistory = { viewModel.deleteHistory(it) },
-                            onClearHistory = { viewModel.clearHistory() },
-                            onToggleScope = { viewModel.toggleHistoryScope() }
+                            onHistoryClick = {
+                                onIntent(SearchContentIntent.UpdateQuery(it.query))
+                            },
+                            onDeleteHistory = {
+                                onIntent(SearchContentIntent.DeleteHistory(it))
+                            },
+                            onClearHistory = { onIntent(SearchContentIntent.ClearHistory) },
+                            onToggleScope = { onIntent(SearchContentIntent.ToggleHistoryScope) }
                         )
                     }
                     SearchContentState.EmptyResult -> {
@@ -264,9 +288,7 @@ fun SearchContentScreen(
                                     result = result,
                                     isCurrentChapter = result.chapterIndex == durChapterIndex,
                                     onClick = {
-                                        if (viewModel.onSearchResultClick(result)) {
-                                            onBack()
-                                        }
+                                        onIntent(SearchContentIntent.OpenResult(result))
                                     }
                                 )
                             }

+ 129 - 130
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt

@@ -1,34 +1,58 @@
 package io.legado.app.ui.book.searchContent
 
+import androidx.compose.runtime.Stable
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.SearchContentHistory
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.SearchContentRepository
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onStart
-import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
-data class SearchUiState(
+@Stable
+data class SearchContentUiState(
     val isSearching: Boolean = false,
-    val searchResults: List<SearchResult> = emptyList(),
+    val searchResults: ImmutableList<SearchResult> = persistentListOf(),
     val durChapterIndex: Int = -1,
     val book: Book? = null,
-    val error: Throwable? = null
+    val error: Throwable? = null,
+    val searchQuery: String = "",
+    val replaceEnabled: Boolean = false,
+    val regexReplace: Boolean = false,
+    val searchHistory: ImmutableList<SearchContentHistory> = persistentListOf(),
+    val historyOnlyThisBook: Boolean = true,
+    val shouldAutoScroll: Boolean = false,
 )
 
+sealed interface SearchContentIntent {
+    data class UpdateQuery(val value: String) : SearchContentIntent
+    data class ToggleReplace(val enabled: Boolean) : SearchContentIntent
+    data class ToggleRegex(val enabled: Boolean) : SearchContentIntent
+    data object ToggleHistoryScope : SearchContentIntent
+    data class DeleteHistory(val history: SearchContentHistory) : SearchContentIntent
+    data object ClearHistory : SearchContentIntent
+    data object StopSearch : SearchContentIntent
+    data object MarkAutoScrollDone : SearchContentIntent
+    data class OpenResult(val result: SearchResult) : SearchContentIntent
+    data object LeaveSearch : SearchContentIntent
+}
+
+sealed interface SearchContentEffect {
+    data object NavigateBack : SearchContentEffect
+}
+
 sealed interface SearchContentState {
     data object Loading : SearchContentState
     data object History : SearchContentState
@@ -36,194 +60,169 @@ sealed interface SearchContentState {
     data class Error(val throwable: Throwable) : SearchContentState
 }
 
-
 class SearchContentViewModel(
-    val bookUrl: String,
+    private val bookUrl: String,
     initialSearchWord: String?,
     private val searchResultIndex: Int,
     private val bookRepository: BookRepository,
-    private val searchContentRepository: SearchContentRepository
+    private val searchContentRepository: SearchContentRepository,
 ) : ViewModel() {
-
     private val restoredSession = if (initialSearchWord == null) {
         searchContentRepository.getLastSession(bookUrl)
-    } else {
-        null
-    }
-
-    private val _searchQuery = MutableStateFlow(initialSearchWord ?: restoredSession?.query.orEmpty())
-    val searchQuery = _searchQuery.asStateFlow()
-
-    private val _replaceEnabled = MutableStateFlow(restoredSession?.replaceEnabled ?: false)
-    val replaceEnabled = _replaceEnabled.asStateFlow()
-
-    private val _regexReplace = MutableStateFlow(restoredSession?.regexReplace ?: false)
-    val regexReplace = _regexReplace.asStateFlow()
-
-    private val _uiState = MutableStateFlow(SearchUiState())
+    } else null
+
+    private val _uiState = MutableStateFlow(
+        SearchContentUiState(
+            searchQuery = initialSearchWord ?: restoredSession?.query.orEmpty(),
+            replaceEnabled = restoredSession?.replaceEnabled ?: false,
+            regexReplace = restoredSession?.regexReplace ?: false,
+            shouldAutoScroll = searchResultIndex > 0,
+        )
+    )
     val uiState = _uiState.asStateFlow()
 
-    private val _historyOnlyThisBook = MutableStateFlow(true)
-    val historyOnlyThisBook = _historyOnlyThisBook.asStateFlow()
-
-    @OptIn(ExperimentalCoroutinesApi::class)
-    val searchHistory = combine(_historyOnlyThisBook, _uiState) { onlyThisBook, uiState ->
-        onlyThisBook to uiState.book
-    }.flatMapLatest { (onlyThisBook, book) ->
-        if (onlyThisBook && book != null) {
-            appDb.searchContentHistoryDao.getByBook(book.name, book.author)
-        } else {
-            appDb.searchContentHistoryDao.getAll()
-        }
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+    private val _effects = MutableSharedFlow<SearchContentEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
-    private var hasAutoScrolled = false
     private var searchJob: Job? = null
+    private var historyJob: Job? = null
 
     init {
         initBook()
     }
 
+    fun onIntent(intent: SearchContentIntent) {
+        when (intent) {
+            is SearchContentIntent.UpdateQuery -> {
+                _uiState.update { it.copy(searchQuery = intent.value) }
+                executeSearch()
+            }
+            is SearchContentIntent.ToggleReplace -> {
+                _uiState.update { it.copy(replaceEnabled = intent.enabled) }
+                executeSearch()
+            }
+            is SearchContentIntent.ToggleRegex -> {
+                _uiState.update { it.copy(regexReplace = intent.enabled) }
+                executeSearch()
+            }
+            SearchContentIntent.ToggleHistoryScope -> {
+                _uiState.update { it.copy(historyOnlyThisBook = !it.historyOnlyThisBook) }
+                observeHistory()
+            }
+            is SearchContentIntent.DeleteHistory -> viewModelScope.launch {
+                searchContentRepository.deleteHistory(intent.history.id)
+            }
+            SearchContentIntent.ClearHistory -> viewModelScope.launch {
+                val state = _uiState.value
+                searchContentRepository.clearHistory(state.book, state.historyOnlyThisBook)
+            }
+            SearchContentIntent.StopSearch -> stopSearch()
+            SearchContentIntent.MarkAutoScrollDone ->
+                _uiState.update { it.copy(shouldAutoScroll = false) }
+            is SearchContentIntent.OpenResult -> openResult(intent.result)
+            SearchContentIntent.LeaveSearch -> leaveSearch()
+        }
+    }
+
     private fun initBook() {
         viewModelScope.launch {
+            val state = _uiState.value
             val book = bookRepository.getBook(bookUrl)
             val cachedResults = searchContentRepository.getCache(
                 bookUrl,
-                _searchQuery.value,
-                _replaceEnabled.value,
-                _regexReplace.value
+                state.searchQuery,
+                state.replaceEnabled,
+                state.regexReplace,
             )
-
             _uiState.update {
                 it.copy(
-                book = book,
+                    book = book,
                     durChapterIndex = book?.durChapterIndex ?: -1,
-                    searchResults = cachedResults ?: it.searchResults
+                    searchResults = cachedResults?.toImmutableList() ?: it.searchResults,
                 )
             }
-
-            if ((cachedResults == null || cachedResults.isEmpty()) && _searchQuery.value.isNotBlank()) {
-                executeSearch()
-            }
+            observeHistory()
+            if (cachedResults.isNullOrEmpty() && state.searchQuery.isNotBlank()) executeSearch()
         }
     }
 
-    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 observeHistory() {
+        historyJob?.cancel()
+        val state = _uiState.value
+        historyJob = viewModelScope.launch {
+            searchContentRepository.observeHistory(state.book, state.historyOnlyThisBook)
+                .collect { history ->
+                    _uiState.update { it.copy(searchHistory = history.toImmutableList()) }
+                }
+        }
     }
 
     private fun executeSearch() {
         searchJob?.cancel()
-        val query = _searchQuery.value
-        val replace = _replaceEnabled.value
-        val regex = _regexReplace.value
-
-        if (query.isBlank()) {
+        val state = _uiState.value
+        if (state.searchQuery.isBlank()) {
             searchContentRepository.clearSession(bookUrl)
             _uiState.update {
-                it.copy(
-                    isSearching = false,
-                    searchResults = emptyList(),
-                    error = null
-                )
+                it.copy(isSearching = false, searchResults = persistentListOf(), error = null)
             }
             return
         }
-
-        searchContentRepository.beginSearch(bookUrl, query, replace, regex)
+        searchContentRepository.beginSearch(
+            bookUrl,
+            state.searchQuery,
+            state.replaceEnabled,
+            state.regexReplace,
+        )
         searchJob = viewModelScope.launch {
-            _uiState.value.book?.let { book ->
-                saveSearchHistory(book, query)
-                searchContentRepository
-                    .search(book, query, replace, regex)
+            state.book?.let { book ->
+                searchContentRepository.saveHistory(book, state.searchQuery)
+                searchContentRepository.search(
+                    book,
+                    state.searchQuery,
+                    state.replaceEnabled,
+                    state.regexReplace,
+                )
                     .onStart { _uiState.update { it.copy(isSearching = true, error = null) } }
                     .onCompletion { _uiState.update { it.copy(isSearching = false) } }
-                    .catch { e -> _uiState.update { it.copy(isSearching = false, error = e) } }
+                    .catch { error -> _uiState.update { it.copy(isSearching = false, error = error) } }
                     .collect { results ->
-                        _uiState.update { it.copy(searchResults = results) }
+                        _uiState.update { it.copy(searchResults = results.toImmutableList()) }
                     }
             }
         }
     }
 
-    private suspend fun saveSearchHistory(book: Book, query: String) {
-        val history = appDb.searchContentHistoryDao.get(book.name, book.author, query)
-            ?: SearchContentHistory(bookName = book.name, bookAuthor = book.author, query = query)
-        history.time = System.currentTimeMillis()
-        appDb.searchContentHistoryDao.insert(history)
-    }
-
-    fun deleteHistory(history: SearchContentHistory) {
-        viewModelScope.launch {
-            appDb.searchContentHistoryDao.delete(history.id)
-        }
-    }
-
-    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() {
+    private fun stopSearch() {
         searchJob?.cancel()
         _uiState.update { it.copy(isSearching = false) }
     }
 
-    fun leaveSearch() {
+    private fun leaveSearch() {
         searchJob?.cancel()
-        if (_searchQuery.value.isBlank()) {
-            SearchContentResult.clearResults(bookUrl)
-        }
-        _searchQuery.value = ""
+        if (_uiState.value.searchQuery.isBlank()) SearchContentResult.clearResults(bookUrl)
         _uiState.update {
             it.copy(
+                searchQuery = "",
                 isSearching = false,
-                searchResults = emptyList(),
-                error = null
+                searchResults = persistentListOf(),
+                error = null,
             )
         }
     }
 
-    fun shouldAutoScroll(): Boolean = searchResultIndex > 0 && !hasAutoScrolled
-
-    fun markScrollDone() {
-        hasAutoScrolled = true
-    }
-
-    fun onSearchResultClick(searchResult: SearchResult): Boolean {
+    private fun openResult(result: SearchResult) {
         stopSearch()
-        val results = uiState.value.searchResults
-        val index = results.indexOf(searchResult)
-        if (index < 0) return false
+        val results = _uiState.value.searchResults
+        val index = results.indexOf(result)
+        if (index < 0) return
         SearchContentResult.emitResult(
             SearchContentResult.Result(
                 bookUrl = bookUrl,
                 searchResults = results,
                 index = index,
-                query = searchResult.query
+                query = result.query,
             )
         )
-        return true
+        _effects.tryEmit(SearchContentEffect.NavigateBack)
     }
 }

+ 280 - 521
app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt

@@ -1,6 +1,5 @@
 package io.legado.app.ui.config.backupConfig
 
-import android.content.Context
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.layout.Arrangement
@@ -23,13 +22,7 @@ import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
@@ -39,9 +32,9 @@ import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.text.input.PasswordVisualTransformation
 import androidx.compose.ui.text.input.VisualTransformation
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.help.storage.ImportOldData
-import io.legado.app.help.storage.Restore
 import io.legado.app.lib.permission.Permissions
 import io.legado.app.lib.permission.PermissionsCompat
 import io.legado.app.ui.theme.LegadoTheme
@@ -50,7 +43,6 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
@@ -61,543 +53,369 @@ import io.legado.app.ui.widget.components.settingItem.InputSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 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.TopBarNavigationButton
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.takePersistablePermissionSafely
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
-import top.yukonga.miuix.kmp.theme.MiuixTheme
 
-@OptIn(ExperimentalMaterial3Api::class)
 @Composable
-fun BackupConfigScreen(
+fun BackupConfigRouteScreen(
     onBackClick: () -> Unit,
-    viewModel: BackupConfigViewModel = koinViewModel()
+    viewModel: BackupConfigViewModel = koinViewModel(),
 ) {
-    val context by rememberUpdatedState(LocalContext.current)
-    val scope = rememberCoroutineScope()
-    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+    val state = viewModel.uiState.collectAsStateWithLifecycle().value
     val snackbarHostState = remember { SnackbarHostState() }
-
-    var showWebDavAuthDialog by remember { mutableStateOf(false) }
-    var showBackupIgnoreDialog by remember { mutableStateOf(false) }
-    var showConfirmDialog by remember { mutableStateOf(false) }
-    var confirmDialogTitle by remember { mutableStateOf("") }
-    var confirmDialogText by remember { mutableStateOf("") }
-    var onConfirmAction by remember { mutableStateOf<(() -> Unit)?>(null) }
-
-    var tempAccount by remember { mutableStateOf("") }
-    var tempPassword by remember { mutableStateOf("") }
-    var passwordVisible by remember { mutableStateOf(false) }
-
-    var showBackupFilePicker by remember { mutableStateOf(false) }
-    var showRestoreFilePicker by remember { mutableStateOf(false) }
-    var showRestoreSheet by remember { mutableStateOf(false) }
-    var showRestoreOptionSheet by remember { mutableStateOf(false) }
-    var showBackupOptionSheet by remember { mutableStateOf(false) }
-    var backupNames by remember { mutableStateOf<List<String>>(emptyList()) }
-
-    var showLoadingDialog by remember { mutableStateOf(false) }
-    var loadingText by remember { mutableStateOf("") }
-
-    val webDavUrl = BackupConfig.webDavUrl
-    val webDavDir = BackupConfig.webDavDir
-    val webDavAccount = BackupConfig.webDavAccount
-    val webDavPassword = BackupConfig.webDavPassword
-    var webDavSyncReady by remember { mutableStateOf(false) }
-
-    LaunchedEffect(webDavUrl, webDavDir, webDavAccount, webDavPassword) {
-        if (webDavSyncReady) {
-            viewModel.refreshWebDavConfig()
-        } else {
-            webDavSyncReady = true
-        }
-    }
-
     val selectBackupPathLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocumentTree()
+        ActivityResultContracts.OpenDocumentTree()
     ) { uri ->
-        uri?.let {
-            it.takePersistablePermissionSafely(context)
-            if (it.isContentScheme()) {
-                BackupConfig.backupPath = it.toString()
-            } else {
-                BackupConfig.backupPath = it.path
-            }
-        }
+        uri ?: return@rememberLauncherForActivityResult
+        uri.takePersistablePermissionSafely(context)
+        val path = if (uri.isContentScheme()) uri.toString() else uri.path.orEmpty()
+        viewModel.onIntent(BackupConfigIntent.BackupDirectorySelected(path, runBackup = false))
     }
-
     val backupAndSelectLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocumentTree()
+        ActivityResultContracts.OpenDocumentTree()
     ) { uri ->
-        uri?.let {
-            it.takePersistablePermissionSafely(context)
-            val path = if (it.isContentScheme()) {
-                it.toString()
-            } else {
-                it.path ?: ""
-            }
-            BackupConfig.backupPath = path
-            if (path.isNotEmpty()) {
-                startBackup(path, context, viewModel, "both", {
-                    showLoadingDialog = false
-                    scope.launch {
-                        snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
-                    }
-                }, { error ->
-                    showLoadingDialog = false
-                    scope.launch {
-                        snackbarHostState.showSnackbar(
-                            context.getString(
-                                R.string.backup_fail,
-                                error
-                            )
-                        )
-                    }
-                })
-            }
-        }
+        uri ?: return@rememberLauncherForActivityResult
+        uri.takePersistablePermissionSafely(context)
+        val path = if (uri.isContentScheme()) uri.toString() else uri.path.orEmpty()
+        viewModel.onIntent(BackupConfigIntent.BackupDirectorySelected(path, runBackup = true))
     }
-
     val restoreFileLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocument()
-    ) { uri ->
-        uri?.let {
-            showLoadingDialog = true
-            loadingText = context.getString(R.string.on_restore)
-            viewModel.restore(
-                uri = uri,
-                onSuccess = {
-                    showLoadingDialog = false
-                    scope.launch {
-                        snackbarHostState.showSnackbar(context.getString(R.string.restore_success))
-                    }
-                },
-                onError = { error ->
-                    showLoadingDialog = false
-                    scope.launch {
-                        snackbarHostState.showSnackbar(
-                            context.getString(R.string.restore_fail_with_error, error)
-                        )
-                    }
+        ActivityResultContracts.OpenDocument()
+    ) { uri -> uri?.let { viewModel.onIntent(BackupConfigIntent.RestoreLocal(it.toString())) } }
+    val importOldLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocument()
+    ) { uri -> uri?.let { ImportOldData.importUri(context, it) } }
+
+    LaunchedEffect(Unit) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                BackupConfigEffect.LaunchBackupDirectoryPicker -> selectBackupPathLauncher.launch(null)
+                BackupConfigEffect.LaunchBackupAndRunDirectoryPicker ->
+                    backupAndSelectLauncher.launch(null)
+                BackupConfigEffect.LaunchRestoreFilePicker ->
+                    restoreFileLauncher.launch(arrayOf("application/zip"))
+                BackupConfigEffect.LaunchImportOldDataPicker -> importOldLauncher.launch(arrayOf("*/*"))
+                is BackupConfigEffect.RequestStoragePermission -> {
+                    PermissionsCompat.Builder()
+                        .addPermissions(*Permissions.Group.STORAGE)
+                        .rationale(R.string.tip_perm_request_storage)
+                        .onGranted {
+                            viewModel.onIntent(
+                                BackupConfigIntent.PerformBackup(effect.path, effect.mode)
+                            )
+                        }
+                        .request()
                 }
-            )
+                is BackupConfigEffect.ShowMessage -> {
+                    val message = effect.argument?.let {
+                        context.getString(effect.messageRes, it)
+                    } ?: context.getString(effect.messageRes)
+                    snackbarHostState.showSnackbar(message)
+                }
+            }
         }
     }
 
-    val importOldLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocument()
-    ) { uri ->
-        uri?.let {
-            ImportOldData.importUri(context, uri)
-        }
-    }
+    BackupConfigScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+        snackbarHostState = snackbarHostState,
+    )
+}
 
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BackupConfigScreen(
+    state: BackupConfigUiState,
+    onIntent: (BackupConfigIntent) -> Unit,
+    onBackClick: () -> Unit,
+    snackbarHostState: SnackbarHostState,
+) {
+    val settings = state.settings
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
-        snackbarHost = {
-            SnackbarHost(
-                hostState = snackbarHostState
-            )
-        },
+        snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
         topBar = {
             GlassMediumFlexibleTopAppBar(
                 title = stringResource(R.string.backup_restore),
                 scrollBehavior = scrollBehavior,
-                navigationIcon = {
-                    TopBarNavigationButton(onClick = onBackClick)
-                }
+                navigationIcon = { TopBarNavigationButton(onClick = onBackClick) },
             )
-        }
+        },
     ) { paddingValues ->
         LazyColumn(
             modifier = Modifier.fillMaxSize(),
             contentPadding = adaptiveContentPadding(
                 top = paddingValues.calculateTopPadding(),
-                bottom = 120.dp
-            )
+                bottom = 120.dp,
+            ),
         ) {
             item {
                 SplicedColumnGroup(title = stringResource(R.string.web_dav_set)) {
                     InputSettingItem(
                         title = stringResource(R.string.web_dav_url),
                         description = stringResource(R.string.web_dav_url_s),
-                        value = BackupConfig.webDavUrl,
+                        value = settings.webDavUrl,
                         defaultValue = "",
-                        onConfirm = { BackupConfig.webDavUrl = it }
+                        onConfirm = { onIntent(BackupConfigIntent.SetWebDavUrl(it)) },
                     )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.web_dav_account),
-                    description = stringResource(R.string.web_dav_account_d),
-                    onClick = {
-                        tempAccount = viewModel.getWebDavAccount()
-                        tempPassword = viewModel.getWebDavPassword()
-                        showWebDavAuthDialog = true
-                    }
-                )
-
-                InputSettingItem(
-                    title = stringResource(R.string.sub_dir),
-                    value = BackupConfig.webDavDir,
-                    defaultValue = "legado",
-                    onConfirm = { BackupConfig.webDavDir = it }
-                )
-
-                InputSettingItem(
-                    title = stringResource(R.string.webdav_device_name),
-                    value = BackupConfig.webDavDeviceName,
-                    defaultValue = "",
-                    onConfirm = { BackupConfig.webDavDeviceName = it }
-                )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.test_sync_t),
-                    description = stringResource(R.string.test_sync_d),
-                    
-                    onClick = {
-                        scope.launch {
-                            showLoadingDialog = true
-                            loadingText = context.getString(R.string.test_sync_loading_text)
-                            val success = viewModel.testWebDav()
-                            showLoadingDialog = false
-                            if (success) {
-                                snackbarHostState.showSnackbar(context.getString(R.string.test_sync_status_success))
-                            } else {
-                                snackbarHostState.showSnackbar(context.getString(R.string.test_sync_status_fail))
-                            }
-                        }
-                    }
-                )
-
-                SwitchSettingItem(
-                    title = stringResource(R.string.sync_book_progress_t),
-                    description = stringResource(R.string.sync_book_progress_s),
-                    checked = BackupConfig.syncBookProgress,
-                    onCheckedChange = {
-                        BackupConfig.syncBookProgress = it
-                        if (!it) {
-                            BackupConfig.syncBookProgressPlus = false
-                        }
+                    ClickableSettingItem(
+                        title = stringResource(R.string.web_dav_account),
+                        description = stringResource(R.string.web_dav_account_d),
+                        onClick = { onIntent(BackupConfigIntent.OpenWebDavAuth) },
+                    )
+                    InputSettingItem(
+                        title = stringResource(R.string.sub_dir),
+                        value = settings.webDavDir,
+                        defaultValue = "legado",
+                        onConfirm = { onIntent(BackupConfigIntent.SetWebDavDir(it)) },
+                    )
+                    InputSettingItem(
+                        title = stringResource(R.string.webdav_device_name),
+                        value = settings.webDavDeviceName,
+                        defaultValue = "",
+                        onConfirm = { onIntent(BackupConfigIntent.SetWebDavDeviceName(it)) },
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.test_sync_t),
+                        description = stringResource(R.string.test_sync_d),
+                        onClick = { onIntent(BackupConfigIntent.TestWebDav) },
+                    )
+                    SwitchSettingItem(
+                        title = stringResource(R.string.sync_book_progress_t),
+                        description = stringResource(R.string.sync_book_progress_s),
+                        checked = settings.syncBookProgress,
+                        onCheckedChange = { onIntent(BackupConfigIntent.SetSyncBookProgress(it)) },
+                    )
+                    if (settings.syncBookProgress) {
+                        SwitchSettingItem(
+                            title = stringResource(R.string.sync_book_progress_plus_t),
+                            description = stringResource(R.string.sync_book_progress_plus_s),
+                            checked = settings.syncBookProgressPlus,
+                            onCheckedChange = {
+                                onIntent(BackupConfigIntent.SetSyncBookProgressPlus(it))
+                            },
+                        )
                     }
-                )
-
-                if (BackupConfig.syncBookProgress) {
                     SwitchSettingItem(
-                        title = stringResource(R.string.sync_book_progress_plus_t),
-                        description = stringResource(R.string.sync_book_progress_plus_s),
-                        checked = BackupConfig.syncBookProgressPlus,
-                        onCheckedChange = { BackupConfig.syncBookProgressPlus = it }
+                        title = stringResource(R.string.auto_check_new_backup_t),
+                        description = stringResource(R.string.auto_check_new_backup_s),
+                        checked = settings.autoCheckNewBackup,
+                        onCheckedChange = {
+                            onIntent(BackupConfigIntent.SetAutoCheckNewBackup(it))
+                        },
+                    )
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.backup_sync_mode),
+                        description = stringResource(R.string.backup_sync_mode_summary),
+                        selectedValue = settings.backupSyncMode,
+                        displayEntries = stringArrayResource(R.array.backup_sync_mode),
+                        entryValues = stringArrayResource(R.array.backup_sync_mode_value),
+                        onValueChange = { onIntent(BackupConfigIntent.SetBackupSyncMode(it)) },
                     )
                 }
-
-                SwitchSettingItem(
-                    title = stringResource(R.string.auto_check_new_backup_t),
-                    description = stringResource(R.string.auto_check_new_backup_s),
-                    checked = BackupConfig.autoCheckNewBackup,
-                    onCheckedChange = { BackupConfig.autoCheckNewBackup = it }
-                )
-
-                DropdownListSettingItem(
-                    title = stringResource(R.string.backup_sync_mode),
-                    description = stringResource(R.string.backup_sync_mode_summary),
-                    selectedValue = BackupConfig.backupSyncMode,
-                    displayEntries = stringArrayResource(R.array.backup_sync_mode),
-                    entryValues = stringArrayResource(R.array.backup_sync_mode_value),
-                    onValueChange = { BackupConfig.backupSyncMode = it }
-                )
-            }
-
                 SplicedColumnGroup(title = stringResource(R.string.backup_restore)) {
-                ClickableSettingItem(
-                    title = stringResource(R.string.backup_path),
-                    description = BackupConfig.backupPath
-                        ?: stringResource(R.string.select_backup_path),
-                    onClick = { showBackupFilePicker = true }
-                )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.backup),
-                    description = stringResource(R.string.backup_summary),
-                    onClick = { showBackupOptionSheet = true }
-                )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.restore),
-                    description = stringResource(R.string.restore_summary),
-                    onClick = { showRestoreOptionSheet = true }
-                )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.restore_ignore),
-                    description = stringResource(R.string.restore_ignore_summary),
-                    onClick = { showBackupIgnoreDialog = true }
-                )
-
-                ClickableSettingItem(
-                    title = stringResource(R.string.menu_import_old_version),
-                    description = stringResource(R.string.import_old_summary),
-                    onClick = {
-                        importOldLauncher.launch(arrayOf("*/*"))
-                    }
-                )
-
-                SwitchSettingItem(
-                    title = stringResource(R.string.only_latest_backup_t),
-                    description = stringResource(R.string.only_latest_backup_s),
-                    checked = BackupConfig.onlyLatestBackup,
-                    onCheckedChange = { BackupConfig.onlyLatestBackup = it }
-                )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.backup_path),
+                        description = settings.backupPath ?: stringResource(R.string.select_backup_path),
+                        onClick = {
+                            onIntent(BackupConfigIntent.OpenSheet(BackupConfigSheet.ChooseBackupPath))
+                        },
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.backup),
+                        description = stringResource(R.string.backup_summary),
+                        onClick = {
+                            onIntent(BackupConfigIntent.OpenSheet(BackupConfigSheet.BackupOptions))
+                        },
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.restore),
+                        description = stringResource(R.string.restore_summary),
+                        onClick = {
+                            onIntent(BackupConfigIntent.OpenSheet(BackupConfigSheet.RestoreOptions))
+                        },
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.restore_ignore),
+                        description = stringResource(R.string.restore_ignore_summary),
+                        onClick = { onIntent(BackupConfigIntent.OpenIgnoreDialog) },
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.menu_import_old_version),
+                        description = stringResource(R.string.import_old_summary),
+                        onClick = { onIntent(BackupConfigIntent.RequestImportOldData) },
+                    )
+                    SwitchSettingItem(
+                        title = stringResource(R.string.only_latest_backup_t),
+                        description = stringResource(R.string.only_latest_backup_s),
+                        checked = settings.onlyLatestBackup,
+                        onCheckedChange = { onIntent(BackupConfigIntent.SetOnlyLatestBackup(it)) },
+                    )
                 }
             }
         }
     }
 
-    AppAlertDialog(
-        show = showWebDavAuthDialog,
-        onDismissRequest = { showWebDavAuthDialog = false },
-        title = stringResource(R.string.web_dav_account),
-        content = {
-            Column {
-                AppTextField(
-                    value = tempAccount,
-                    onValueChange = { tempAccount = it },
-                    backgroundColor = LegadoTheme.colorScheme.surface,
-                    label = stringResource(R.string.web_dav_account)
-                )
-                Spacer(modifier = Modifier.height(12.dp))
-                AppTextField(
-                    value = tempPassword,
-                    onValueChange = { tempPassword = it },
-                    backgroundColor = LegadoTheme.colorScheme.surface,
-                    label = stringResource(R.string.web_dav_pw),
-                    visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
-                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
-                    trailingIcon = {
-                        val image =
-                            if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
-                        val description = if (passwordVisible) {
-                            stringResource(R.string.hide_password)
-                        } else {
-                            stringResource(R.string.show_password)
-                        }
-                        IconButton(onClick = { passwordVisible = !passwordVisible }) {
-                            Icon(imageVector = image, contentDescription = description)
-                        }
-                    }
-                )
-            }
-        },
-        confirmText = stringResource(R.string.ok),
-        onConfirm = {
-            viewModel.setWebDavAccount(tempAccount, tempPassword)
-            showWebDavAuthDialog = false
-            scope.launch {
-                showLoadingDialog = true
-                loadingText = context.getString(R.string.test_sync_loading_text)
-                val success = viewModel.testWebDav()
-                showLoadingDialog = false
-                if (success) {
-                    snackbarHostState.showSnackbar(context.getString(R.string.test_sync_status_success))
-                } else {
-                    snackbarHostState.showSnackbar(context.getString(R.string.test_sync_status_fail))
-                }
-            }
-        },
-        dismissText = stringResource(R.string.cancel),
-        onDismiss = {
-            showWebDavAuthDialog = false
-        }
-    )
-
-    ConfirmDialog(
-        show = showConfirmDialog,
-        title = confirmDialogTitle,
-        text = confirmDialogText,
-        onConfirm = {
-            onConfirmAction?.invoke()
-            showConfirmDialog = false
-        },
-        onDismiss = { showConfirmDialog = false }
-    )
+    BackupConfigSheets(state, onIntent)
+    BackupConfigDialogs(state, onIntent)
+}
 
+@Composable
+private fun BackupConfigSheets(
+    state: BackupConfigUiState,
+    onIntent: (BackupConfigIntent) -> Unit,
+) {
     FilePickerSheet(
-        show = showBackupFilePicker,
-        onDismissRequest = { showBackupFilePicker = false },
-        onSelectSysDir = {
-            showBackupFilePicker = false
-            try {
-                selectBackupPathLauncher.launch(null)
-            } catch (e: Exception) {
-            }
-        }
+        show = state.activeSheet == BackupConfigSheet.ChooseBackupPath,
+        onDismissRequest = { onIntent(BackupConfigIntent.DismissSheet) },
+        onSelectSysDir = { onIntent(BackupConfigIntent.SelectBackupDirectory) },
     )
-
-
     FilePickerSheet(
-        show = showRestoreFilePicker,
-        onDismissRequest = { showRestoreFilePicker = false },
-        onSelectSysDir = {
-            showRestoreFilePicker = false
-            try {
-                backupAndSelectLauncher.launch(null)
-            } catch (e: Exception) {
-            }
-        }
+        show = state.activeSheet == BackupConfigSheet.ChooseBackupAndRun,
+        onDismissRequest = { onIntent(BackupConfigIntent.DismissSheet) },
+        onSelectSysDir = { onIntent(BackupConfigIntent.SelectBackupAndRunDirectory) },
     )
-
     BackupOptionSheet(
-        show = showBackupOptionSheet,
-        onDismissRequest = { showBackupOptionSheet = false },
-        onBackupToLocal = {
-            showBackupOptionSheet = false
-            executeBackup("local", context, viewModel, scope, snackbarHostState, {
-                showLoadingDialog = false
-            }, { showLoadingDialog = true; loadingText = it })
-        },
-        onBackupToNetwork = {
-            showBackupOptionSheet = false
-            executeBackup("webdav", context, viewModel, scope, snackbarHostState, {
-                showLoadingDialog = false
-            }, { showLoadingDialog = true; loadingText = it })
-        },
-        onBackupToLocalAndNetwork = {
-            showBackupOptionSheet = false
-            executeBackup("both", context, viewModel, scope, snackbarHostState, {
-                showLoadingDialog = false
-            }, { showLoadingDialog = true; loadingText = it })
-        },
+        show = state.activeSheet == BackupConfigSheet.BackupOptions,
+        onDismissRequest = { onIntent(BackupConfigIntent.DismissSheet) },
+        onBackupToLocal = { onIntent(BackupConfigIntent.RequestBackup("local")) },
+        onBackupToNetwork = { onIntent(BackupConfigIntent.RequestBackup("webdav")) },
+        onBackupToLocalAndNetwork = { onIntent(BackupConfigIntent.RequestBackup("both")) },
     )
-
     RestoreOptionSheet(
-        show = showRestoreOptionSheet,
-        onDismissRequest = { showRestoreOptionSheet = false },
-        onRestoreFromLocal = {
-            showRestoreOptionSheet = false
-            restoreFileLauncher.launch(arrayOf("application/zip"))
-        },
-        onRestoreFromNetwork = {
-            showRestoreOptionSheet = false
-            scope.launch {
-                showLoadingDialog = true
-                loadingText = context.getString(R.string.loading)
-                try {
-                    val names = viewModel.getBackupNames()
-                    backupNames = names
-                    showRestoreSheet = true
-                } catch (e: Exception) {
-                    confirmDialogTitle = context.getString(R.string.restore)
-                    confirmDialogText =
-                        context.getString(
-                            R.string.webdav_restore_fallback_message,
-                            e.localizedMessage
-                        )
-                    onConfirmAction = {
-                        restoreFileLauncher.launch(arrayOf("application/zip"))
-                    }
-                    showConfirmDialog = true
-                } finally {
-                    showLoadingDialog = false
-                }
-            }
-        },
+        show = state.activeSheet == BackupConfigSheet.RestoreOptions,
+        onDismissRequest = { onIntent(BackupConfigIntent.DismissSheet) },
+        onRestoreFromLocal = { onIntent(BackupConfigIntent.RequestLocalRestore) },
+        onRestoreFromNetwork = { onIntent(BackupConfigIntent.RequestNetworkRestore) },
     )
-
     AppModalBottomSheet(
-        show = showRestoreSheet && backupNames.isNotEmpty(),
-        onDismissRequest = { showRestoreSheet = false },
-        title = stringResource(R.string.select_restore_file)
+        show = state.activeSheet == BackupConfigSheet.RestoreFiles && state.backupNames.isNotEmpty(),
+        onDismissRequest = { onIntent(BackupConfigIntent.DismissSheet) },
+        title = stringResource(R.string.select_restore_file),
     ) {
         LazyColumn(
-            modifier = Modifier
-                .fillMaxWidth()
-                .padding(bottom = 16.dp),
-            verticalArrangement = Arrangement.spacedBy(8.dp)
+            modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
         ) {
-            items(backupNames) {
+            items(state.backupNames, key = { it }) { name ->
                 SelectionItemCard(
-                    title = it,
+                    title = name,
                     containerColor = LegadoTheme.colorScheme.surface,
-                    onToggleSelection = {
-                        showRestoreSheet = false
-                        showLoadingDialog = true
-                        loadingText = context.getString(R.string.on_restore)
-                        viewModel.restoreWebDav(
-                            it,
-                            {
-                                showLoadingDialog = false
-                                scope.launch {
-                                    snackbarHostState.showSnackbar(context.getString(R.string.restore_success))
-                                }
-                            },
-                            { error ->
-                                showLoadingDialog = false
-                                scope.launch {
-                                    snackbarHostState.showSnackbar(
-                                        context.getString(R.string.webdav_restore_fail, error)
-                                    )
-                                }
-                            }
-                        )
-                    }
+                    onToggleSelection = { onIntent(BackupConfigIntent.RestoreNetwork(name)) },
                 )
             }
         }
     }
+}
 
-    val checkedItems = remember(showBackupIgnoreDialog) {
-        val keys = io.legado.app.help.storage.BackupConfig.ignoreKeys
-        val config = io.legado.app.help.storage.BackupConfig.ignoreConfig
-
-        // 初始化可观察的 List 状态
-        mutableStateListOf(*Array(keys.size) { index ->
-            config[keys[index]] ?: false
-        })
-    }
-
+@Composable
+private fun BackupConfigDialogs(
+    state: BackupConfigUiState,
+    onIntent: (BackupConfigIntent) -> Unit,
+) {
+    val dialog = state.activeDialog
+    val auth = dialog as? BackupConfigDialog.WebDavAuth
     AppAlertDialog(
-        show = showBackupIgnoreDialog,
-        onDismissRequest = {
-            io.legado.app.help.storage.BackupConfig.saveIgnoreConfig()
-            showBackupIgnoreDialog = false
+        show = auth != null,
+        onDismissRequest = { onIntent(BackupConfigIntent.DismissDialog) },
+        title = stringResource(R.string.web_dav_account),
+        content = {
+            auth?.let {
+                Column {
+                    AppTextField(
+                        value = it.account,
+                        onValueChange = { value ->
+                            onIntent(BackupConfigIntent.EditWebDavAccount(value))
+                        },
+                        backgroundColor = LegadoTheme.colorScheme.surface,
+                        label = stringResource(R.string.web_dav_account),
+                    )
+                    Spacer(modifier = Modifier.height(12.dp))
+                    AppTextField(
+                        value = it.password,
+                        onValueChange = { value ->
+                            onIntent(BackupConfigIntent.EditWebDavPassword(value))
+                        },
+                        backgroundColor = LegadoTheme.colorScheme.surface,
+                        label = stringResource(R.string.web_dav_pw),
+                        visualTransformation = if (it.passwordVisible) {
+                            VisualTransformation.None
+                        } else {
+                            PasswordVisualTransformation()
+                        },
+                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+                        trailingIcon = {
+                            IconButton(onClick = {
+                                onIntent(BackupConfigIntent.TogglePasswordVisibility)
+                            }) {
+                                Icon(
+                                    imageVector = if (it.passwordVisible) {
+                                        Icons.Filled.Visibility
+                                    } else {
+                                        Icons.Filled.VisibilityOff
+                                    },
+                                    contentDescription = stringResource(
+                                        if (it.passwordVisible) R.string.hide_password
+                                        else R.string.show_password
+                                    ),
+                                )
+                            }
+                        },
+                    )
+                }
+            }
         },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { onIntent(BackupConfigIntent.SaveWebDavAuth) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(BackupConfigIntent.DismissDialog) },
+    )
+
+    AppAlertDialog(
+        show = dialog == BackupConfigDialog.IgnoreRestoreItems,
+        onDismissRequest = { onIntent(BackupConfigIntent.SaveIgnoreItems) },
         title = stringResource(R.string.restore_ignore),
         content = {
-            Column(
-                verticalArrangement = Arrangement.spacedBy(4.dp)
-            ) {
-                io.legado.app.help.storage.BackupConfig.ignoreTitle.forEachIndexed { index, title ->
+            Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+                state.ignoreItems.forEach { item ->
                     CheckboxItem(
-                        title = title,
-                        checked = checkedItems[index],
-                        onCheckedChange = { isChecked ->
-                            checkedItems[index] = isChecked
-                            io.legado.app.help.storage.BackupConfig.ignoreConfig[
-                                io.legado.app.help.storage.BackupConfig.ignoreKeys[index]
-                            ] = isChecked
-                        }
+                        title = item.title,
+                        checked = item.checked,
+                        onCheckedChange = {
+                            onIntent(BackupConfigIntent.ToggleIgnoreItem(item.key, it))
+                        },
                     )
                 }
             }
         },
         confirmText = stringResource(R.string.ok),
-        onConfirm = {
-            io.legado.app.help.storage.BackupConfig.saveIgnoreConfig()
-            showBackupIgnoreDialog = false
-        },
+        onConfirm = { onIntent(BackupConfigIntent.SaveIgnoreItems) },
         dismissText = stringResource(R.string.cancel),
-        onDismiss = {
-            io.legado.app.help.storage.BackupConfig.saveIgnoreConfig()
-            showBackupIgnoreDialog = false
-        }
+        onDismiss = { onIntent(BackupConfigIntent.SaveIgnoreItems) },
     )
 
+    val fallback = dialog as? BackupConfigDialog.ConfirmLocalRestoreFallback
+    ConfirmDialog(
+        show = fallback != null,
+        title = stringResource(R.string.restore),
+        text = stringResource(R.string.webdav_restore_fallback_message, fallback?.error.orEmpty()),
+        onConfirm = { onIntent(BackupConfigIntent.ConfirmLocalRestoreFallback) },
+        onDismiss = { onIntent(BackupConfigIntent.DismissDialog) },
+    )
 
+    val loading = dialog as? BackupConfigDialog.Loading
     AppAlertDialog(
-        show = showLoadingDialog,
-        onDismissRequest = { showLoadingDialog = false },
-        title = loadingText
+        show = loading != null,
+        onDismissRequest = { onIntent(BackupConfigIntent.DismissDialog) },
+        title = loading?.let { stringResource(it.titleRes) }.orEmpty(),
     )
-
 }
 
 @Composable
@@ -606,7 +424,7 @@ fun ConfirmDialog(
     title: String,
     text: String,
     onConfirm: () -> Unit,
-    onDismiss: () -> Unit
+    onDismiss: () -> Unit,
 ) {
     AppAlertDialog(
         show = show,
@@ -616,65 +434,6 @@ fun ConfirmDialog(
         confirmText = stringResource(R.string.ok),
         onConfirm = onConfirm,
         dismissText = stringResource(R.string.cancel),
-        onDismiss = onDismiss
+        onDismiss = onDismiss,
     )
 }
-
-private fun startBackup(
-    path: String,
-    context: Context,
-    viewModel: BackupConfigViewModel,
-    mode: String = "both",
-    onSuccess: () -> Unit,
-    onError: (String) -> Unit
-) {
-    viewModel.backup(path, mode, onSuccess, onError)
-}
-
-private fun executeBackup(
-    mode: String,
-    context: Context,
-    viewModel: BackupConfigViewModel,
-    scope: kotlinx.coroutines.CoroutineScope,
-    snackbarHostState: SnackbarHostState,
-    hideLoading: () -> Unit,
-    showLoading: (String) -> Unit
-) {
-    val backupPath = BackupConfig.backupPath
-    if (backupPath.isNullOrEmpty() && mode != "webdav") {
-        return
-    }
-    val path = backupPath ?: ""
-    showLoading(context.getString(R.string.backup))
-    if (path.isNotEmpty() && !path.isContentScheme()) {
-        PermissionsCompat.Builder()
-            .addPermissions(*Permissions.Group.STORAGE)
-            .rationale(R.string.tip_perm_request_storage)
-            .onGranted {
-                startBackup(path, context, viewModel, mode, {
-                    hideLoading()
-                    scope.launch {
-                        snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
-                    }
-                }, { error ->
-                    hideLoading()
-                    scope.launch {
-                        snackbarHostState.showSnackbar(context.getString(R.string.backup_fail, error))
-                    }
-                })
-            }
-            .request()
-    } else {
-        startBackup(path, context, viewModel, mode, {
-            hideLoading()
-            scope.launch {
-                snackbarHostState.showSnackbar(context.getString(R.string.backup_success))
-            }
-        }, { error ->
-            hideLoading()
-            scope.launch {
-                snackbarHostState.showSnackbar(context.getString(R.string.backup_fail, error))
-            }
-        })
-    }
-}

+ 48 - 0
app/src/main/java/io/legado/app/ui/main/MainContract.kt

@@ -0,0 +1,48 @@
+package io.legado.app.ui.main
+
+import androidx.compose.runtime.Stable
+import io.legado.app.ui.main.my.PrefClickEvent
+import kotlinx.collections.immutable.ImmutableList
+
+@Stable
+data class MainUiState(
+    val destinations: ImmutableList<MainDestination> = MainDestination.mainDestinations,
+    val defaultHomePage: String = "bookshelf",
+    val showBottomView: Boolean = true,
+    val useFloatingBottomBar: Boolean = false,
+    val useFloatingBottomBarLiquidGlass: Boolean = false,
+    val labelVisibilityMode: String = "auto",
+    val navExtended: Boolean = false,
+    val navIconHome: String = "",
+    val navIconBookshelf: String = "",
+    val navIconExplore: String = "",
+    val navIconRss: String = "",
+    val navIconMy: String = "",
+    val deepPersonalizationActive: Boolean = false,
+    val secondaryThemeColor: Int = 0,
+    val secondaryThemeColorNight: Int = 0,
+) {
+    fun customIconPath(destination: MainDestination): String = when (destination) {
+        MainDestination.Home -> navIconHome
+        MainDestination.Bookshelf -> navIconBookshelf
+        MainDestination.Explore -> navIconExplore
+        MainDestination.Rss -> navIconRss
+        MainDestination.My -> navIconMy
+    }
+}
+
+sealed interface MainUiIntent {
+    data class SetNavigationRailExpanded(val expanded: Boolean) : MainUiIntent
+    data class HandlePreferenceClick(val event: PrefClickEvent) : MainUiIntent
+}
+
+sealed interface MainEffect {
+    data class OpenUrl(val url: String) : MainEffect
+    data class CopyUrl(val url: String) : MainEffect
+    data class ShowMarkdown(val title: String, val path: String) : MainEffect
+    data class StartActivity(val destination: Class<*>, val configTag: String? = null) : MainEffect
+    data object ExitApp : MainEffect
+    data object NavigateToReadRecord : MainEffect
+    data object NavigateToHighlightTagRule : MainEffect
+    data object NavigateToAbout : MainEffect
+}

+ 0 - 10
app/src/main/java/io/legado/app/ui/main/MainDestination.kt

@@ -2,7 +2,6 @@ package io.legado.app.ui.main
 
 import androidx.annotation.StringRes
 import io.legado.app.R
-import io.legado.app.ui.config.themeConfig.ThemeConfig
 import kotlinx.collections.immutable.persistentListOf
 
 sealed class MainDestination(
@@ -48,12 +47,3 @@ sealed class MainDestination(
         }
     }
 }
-
-val MainDestination.customIconPath: String
-    get() = when (this) {
-        MainDestination.Home -> ThemeConfig.navIconHome
-        MainDestination.Bookshelf -> ThemeConfig.navIconBookshelf
-        MainDestination.Explore -> ThemeConfig.navIconExplore
-        MainDestination.Rss -> ThemeConfig.navIconRss
-        MainDestination.My -> ThemeConfig.navIconMy
-    }

+ 20 - 20
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -27,10 +27,10 @@ import io.legado.app.ui.about.AboutViewModel
 import io.legado.app.ui.ai.chat.AiChatRouteScreen
 import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
 import io.legado.app.ui.book.explore.ExploreShowIntent
-import io.legado.app.ui.book.explore.ExploreShowScreen
+import io.legado.app.ui.book.explore.ExploreShowRouteScreen
 import io.legado.app.ui.book.explore.ExploreShowViewModel
-import io.legado.app.ui.book.import.local.ImportBookScreen
-import io.legado.app.ui.book.import.remote.RemoteBookScreen
+import io.legado.app.ui.book.import.local.ImportBookRouteScreen
+import io.legado.app.ui.book.import.remote.RemoteBookRouteScreen
 import io.legado.app.ui.book.info.BookInfoRouteScreen
 import io.legado.app.ui.book.info.BookInfoViewModel
 import io.legado.app.ui.book.manage.BookshelfManageRouteScreen
@@ -38,12 +38,12 @@ import io.legado.app.ui.book.read.ReadBookController
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookRouteScreen
 import io.legado.app.ui.book.read.ReadBookViewModel
-import io.legado.app.ui.book.readRecord.ReadRecordOverviewScreen
-import io.legado.app.ui.book.readRecord.ReadRecordScreen
+import io.legado.app.ui.book.readRecord.ReadRecordOverviewRouteScreen
+import io.legado.app.ui.book.readRecord.ReadRecordRouteScreen
 import io.legado.app.ui.book.search.SearchIntent
-import io.legado.app.ui.book.search.SearchScreen
+import io.legado.app.ui.book.search.SearchRouteScreen
 import io.legado.app.ui.book.search.SearchViewModel
-import io.legado.app.ui.book.searchContent.SearchContentScreen
+import io.legado.app.ui.book.searchContent.SearchContentRouteScreen
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.source.manage.BookSourceActivity
 import io.legado.app.ui.config.ConfigNavScreen
@@ -51,7 +51,7 @@ import io.legado.app.ui.config.ai.AiConfigRouteScreen
 import io.legado.app.ui.config.ai.AiModelEditRouteScreen
 import io.legado.app.ui.config.ai.AiProviderEditRouteScreen
 import io.legado.app.ui.config.ai.summary.AiSummaryConfigRouteScreen
-import io.legado.app.ui.config.backupConfig.BackupConfigScreen
+import io.legado.app.ui.config.backupConfig.BackupConfigRouteScreen
 import io.legado.app.ui.config.coverConfig.CoverAlbumManageRouteScreen
 import io.legado.app.ui.config.coverConfig.CoverConfigRouteScreen
 import io.legado.app.ui.config.customTheme.CustomThemeRouteScreen
@@ -65,10 +65,10 @@ import io.legado.app.ui.config.translation.TranslationConfigRouteScreen
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleScreen
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
-import io.legado.app.ui.rss.favorites.RssFavoritesScreen
+import io.legado.app.ui.rss.favorites.RssFavoritesRouteScreen
 import io.legado.app.ui.rss.read.MainRouteRssRead
 import io.legado.app.ui.rss.read.RssReadRouteScreen
-import io.legado.app.ui.rss.subscription.RuleSubScreen
+import io.legado.app.ui.rss.subscription.RuleSubRouteScreen
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.startActivityForBook
@@ -231,7 +231,7 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteSettingsBackup> {
-        BackupConfigScreen(onBackClick = { onNavigateBack() })
+        BackupConfigRouteScreen(onBackClick = { onNavigateBack() })
     }
 
     entry<MainRouteSettingsAi> {
@@ -315,13 +315,13 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteImportLocal> {
-        ImportBookScreen(
+        ImportBookRouteScreen(
             onBackClick = { onNavigateBack() }
         )
     }
 
     entry<MainRouteImportRemote> {
-        RemoteBookScreen(
+        RemoteBookRouteScreen(
             onBackClick = { onNavigateBack() }
         )
     }
@@ -454,7 +454,7 @@ fun MainActivity.mainEntryProvider(
             key = "SearchContent:${route.bookUrl}",
             parameters = { parametersOf(route) }
         )
-        SearchContentScreen(
+        SearchContentRouteScreen(
             viewModel = viewModel,
             onBack = { onNavigateBack() },
         )
@@ -472,7 +472,7 @@ fun MainActivity.mainEntryProvider(
             )
         }
 
-        SearchScreen(
+        SearchRouteScreen(
             viewModel = searchViewModel,
             onBack = {
                 onNavigateBack()
@@ -553,7 +553,7 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteRssFavorites> {
-        RssFavoritesScreen(
+        RssFavoritesRouteScreen(
             onBackClick = { onNavigateBack() },
             onOpenRead = { title, origin, link, openUrl ->
                 onNavigateToRoute(
@@ -569,13 +569,13 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteRuleSub> {
-        RuleSubScreen(
+        RuleSubRouteScreen(
             onBackClick = { onNavigateBack() }
         )
     }
 
     entry<MainRouteReadRecord> {
-        ReadRecordScreen(
+        ReadRecordRouteScreen(
             onBackClick = { onNavigateBack() },
             onBookClick = { name, author ->
                 lifecycleScope.launch {
@@ -595,7 +595,7 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteReadRecordOverview> {
-        ReadRecordOverviewScreen(
+        ReadRecordOverviewRouteScreen(
             onBackClick = { onNavigateBack() },
             onBookClick = { name, author ->
                 lifecycleScope.launch {
@@ -697,7 +697,7 @@ fun MainActivity.mainEntryProvider(
             )
         }
 
-        ExploreShowScreen(
+        ExploreShowRouteScreen(
             viewModel = exploreViewModel,
             title = route.title ?: "探索",
             onBack = { onNavigateBack() },

+ 25 - 18
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -77,14 +77,13 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
 import io.legado.app.R
-import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.main.bookshelf.BookshelfScreen
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
-import io.legado.app.ui.main.explore.ExploreScreen
+import io.legado.app.ui.main.explore.ExploreRouteScreen
 import io.legado.app.ui.main.home.HomeRouteScreen
-import io.legado.app.ui.main.my.MyScreen
+import io.legado.app.ui.main.my.MyRouteScreen
 import io.legado.app.ui.main.my.PrefClickEvent
-import io.legado.app.ui.main.rss.RssScreen
+import io.legado.app.ui.main.rss.RssRouteScreen
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.AppScaffold
@@ -192,8 +191,13 @@ fun MainScreen(
     }
 
     val hazeState = remember { HazeState() }
-    val customSecondaryColor = ThemeConfig.customThemeColors(LegadoTheme.isDark).secondary
-    val floatingBarSurfaceColor = if (ThemeConfig.isDeepPersonalizationActive && customSecondaryColor != 0) {
+    val customSecondaryColor = if (LegadoTheme.isDark) {
+        mainUiState.secondaryThemeColorNight.takeIf { it != 0 }
+            ?: mainUiState.secondaryThemeColor
+    } else {
+        mainUiState.secondaryThemeColor
+    }
+    val floatingBarSurfaceColor = if (mainUiState.deepPersonalizationActive && customSecondaryColor != 0) {
         Color(customSecondaryColor)
     } else {
         LegadoTheme.colorScheme.surface
@@ -275,7 +279,7 @@ fun MainScreen(
                     }
                 )
                 LaunchedEffect(miuixNavState.currentValue) {
-                    viewModel.setNavExtended(miuixNavState.isExpanded)
+                    viewModel.onIntent(MainUiIntent.SetNavigationRailExpanded(miuixNavState.isExpanded))
                 }
                 MiuixNavigationRail(
                     state = miuixNavState,
@@ -355,7 +359,7 @@ fun MainScreen(
                                     val targetExpanded = !expanded
                                     if (targetExpanded) navState.expand()
                                     else navState.collapse()
-                                    viewModel.setNavExtended(targetExpanded)
+                                    viewModel.onIntent(MainUiIntent.SetNavigationRailExpanded(targetExpanded))
                                 }
                             }
                         ) {
@@ -402,6 +406,7 @@ fun MainScreen(
                                 NavigationIcon(
                                     destination = destination,
                                     selected = selected,
+                                    customIconPath = mainUiState.customIconPath(destination),
                                     modifier = if (destination == MainDestination.Bookshelf) {
                                         Modifier.combinedClickable(
                                             interactionSource = remember { MutableInteractionSource() },
@@ -431,7 +436,7 @@ fun MainScreen(
                             }
                         },
                         label = if (labelVisibilityMode != "unlabeled") {
-                            val hasCustomIcon = destination.customIconPath.isNotEmpty()
+                            val hasCustomIcon = mainUiState.customIconPath(destination).isNotEmpty()
                             if (hasCustomIcon) null else {{ AppText(stringResource(destination.labelId)) }}
                         } else null
                     )
@@ -449,7 +454,7 @@ fun MainScreen(
                     ) {
                         destinations.forEachIndexed { index, destination ->
                             val selected = pagerState.targetPage == index
-                            val customIconPath = destination.customIconPath
+                            val customIconPath = mainUiState.customIconPath(destination)
                             val destinationLabel = stringResource(destination.labelId)
                             AppNavigationBarItem(
                                 modifier = Modifier.semantics(mergeDescendants = true) {
@@ -464,7 +469,8 @@ fun MainScreen(
                                 m3Icon = {
                                     NavigationIcon(
                                         destination = destination,
-                                        selected = selected
+                                        selected = selected,
+                                        customIconPath = mainUiState.customIconPath(destination),
                                     )
                                 },
                                 m3IndicatorColor = GlassDefaults.glassColor(
@@ -565,10 +571,10 @@ fun MainScreen(
                                 animatedVisibilityScope = animatedVisibilityScope,
                             )
 
-                            MainDestination.Explore -> ExploreScreen(
+                            MainDestination.Explore -> ExploreRouteScreen(
                                 onOpenExploreShow = onNavigateToExploreShow,
                             )
-                            MainDestination.Rss -> RssScreen(
+                            MainDestination.Rss -> RssRouteScreen(
                                 onOpenSort = { sourceUrl, sortUrl, key ->
                                     onNavigateToRssSort(sourceUrl, sortUrl, key)
                                 },
@@ -578,14 +584,14 @@ fun MainScreen(
                                 onOpenFavorites = onNavigateToRssFavorites,
                                 onOpenRuleSub = onNavigateToRuleSub
                             )
-                            MainDestination.My -> MyScreen(
+                            MainDestination.My -> MyRouteScreen(
                                 onOpenSettings = onOpenSettings,
                                 onNavigateToChat = onNavigateToChat,
                                 onNavigate = { event ->
                                     when (event) {
                                         PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage()
                                         PrefClickEvent.OpenReadRecord -> onNavigateToReadRecord()
-                                        else -> viewModel.onPrefClickEvent(event)
+                                        else -> viewModel.onIntent(MainUiIntent.HandlePreferenceClick(event))
                                     }
                                 }
                             )
@@ -629,12 +635,12 @@ fun MainScreen(
                             tabsCount = destinations.size,
                             isBlurEnabled = useLiquidGlass,
                             hasCustomIcons = destinations.any { dest ->
-                                dest.customIconPath.isNotEmpty()
+                                mainUiState.customIconPath(dest).isNotEmpty()
                             }
                         ) {
                             destinations.forEachIndexed { index, destination ->
                                 val selected = pagerState.targetPage == index
-                                val hasCustomIcon = destination.customIconPath.isNotEmpty()
+                                val hasCustomIcon = mainUiState.customIconPath(destination).isNotEmpty()
                                 val destinationLabel = stringResource(destination.labelId)
                                 FloatingBottomBarItem(
                                     onClick = {
@@ -648,6 +654,7 @@ fun MainScreen(
                                 ) {
                                     NavigationIcon(
                                         destination = destination,
+                                        customIconPath = mainUiState.customIconPath(destination),
                                         selected = selected
                                     )
                                     if (!hasCustomIcon && showLabel && (alwaysShowLabel || selected)) {
@@ -756,9 +763,9 @@ private fun BookshelfRailGroupMenu(
 private fun NavigationIcon(
     destination: MainDestination,
     selected: Boolean,
+    customIconPath: String,
     modifier: Modifier = Modifier
 ) {
-    val customIconPath = destination.customIconPath
     if (customIconPath.isNotEmpty()) {
         AsyncImage(
             model = customIconPath,

+ 77 - 93
app/src/main/java/io/legado/app/ui/main/MainViewModel.kt

@@ -1,46 +1,60 @@
 package io.legado.app.ui.main
 
-import android.app.Application
-import androidx.compose.runtime.Stable
-import androidx.compose.runtime.snapshotFlow
+import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
-import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.EventBus
+import io.legado.app.domain.gateway.AppShellBooleanSetting
+import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.AppShellSettingsUpdate
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.model.settings.AppShellSettings
+import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
-import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.main.my.PrefClickEvent
 import io.legado.app.utils.eventBus.FlowEventBus
-import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.launch
 
 class MainViewModel(
-    application: Application,
     private val appStartupMaintenanceUseCase: AppStartupMaintenanceUseCase,
     private val webDavBackupUseCase: WebDavBackupUseCase,
-) : BaseViewModel(application) {
-
-    private val _uiState = MutableStateFlow(readMainUiState())
+    private val appShellSettingsGateway: AppShellSettingsGateway,
+    private val themeSettingsGateway: ThemeSettingsGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        buildMainUiState(
+            appShellSettingsGateway.currentSettings,
+            themeSettingsGateway.currentSettings,
+        )
+    )
     val uiState = _uiState.asStateFlow()
-    private val _effects = MutableSharedFlow<MainEffect>(extraBufferCapacity = 8)
+
+    private val _effects = MutableSharedFlow<MainEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
 
     init {
-        // 通过 snapshotFlow 直接观察 ThemeConfig 的 Compose State
         viewModelScope.launch {
-            snapshotFlow {
-                readMainUiState()
-            }.collect { newState ->
-                _uiState.value = newState
-            }
+            combine(
+                appShellSettingsGateway.settings,
+                themeSettingsGateway.settings,
+                ::buildMainUiState,
+            ).collect { _uiState.value = it }
+        }
+        viewModelScope.launch { appStartupMaintenanceUseCase.deleteNotShelfBooks() }
+    }
+
+    fun onIntent(intent: MainUiIntent) {
+        when (intent) {
+            is MainUiIntent.SetNavigationRailExpanded -> setNavExtended(intent.expanded)
+            is MainUiIntent.HandlePreferenceClick -> handlePrefClick(intent.event)
         }
-        deleteNotShelfBook()
     }
 
     fun upAllBookToc() {
@@ -48,107 +62,77 @@ class MainViewModel(
     }
 
     fun postLoad() {
-        execute {
-            appStartupMaintenanceUseCase.ensureDefaultHttpTts()
-        }
+        viewModelScope.launch { appStartupMaintenanceUseCase.ensureDefaultHttpTts() }
     }
 
     fun restoreWebDav(name: String) {
-        execute {
-            webDavBackupUseCase.restore(name)
-        }
+        viewModelScope.launch { webDavBackupUseCase.restore(name) }
     }
 
     suspend fun getLatestWebDavBackup() = webDavBackupUseCase.getLatestBackup()
 
-    private fun deleteNotShelfBook() {
-        execute {
-            appStartupMaintenanceUseCase.deleteNotShelfBooks()
-        }
-    }
-
-    fun setNavExtended(expanded: Boolean) {
+    private fun setNavExtended(expanded: Boolean) {
         if (_uiState.value.navExtended == expanded) return
-        _uiState.update { it.copy(navExtended = expanded) }
-        ThemeConfig.navExtended = expanded
+        viewModelScope.launch {
+            appShellSettingsGateway.update(
+                AppShellSettingsUpdate.BooleanValue(
+                    AppShellBooleanSetting.NavExtended,
+                    expanded,
+                )
+            )
+        }
     }
 
-    fun onPrefClickEvent(event: PrefClickEvent) {
+    private fun handlePrefClick(event: PrefClickEvent) {
         when (event) {
             is PrefClickEvent.OpenUrl -> _effects.tryEmit(MainEffect.OpenUrl(event.url))
             is PrefClickEvent.CopyUrl -> _effects.tryEmit(MainEffect.CopyUrl(event.url))
             is PrefClickEvent.ShowMd -> _effects.tryEmit(
-                MainEffect.ShowMarkdown(
-                    title = event.title,
-                    path = event.path
-                )
+                MainEffect.ShowMarkdown(title = event.title, path = event.path)
             )
-
-            is PrefClickEvent.StartActivity -> {
-                _effects.tryEmit(
-                    MainEffect.StartActivity(
-                        destination = event.destination,
-                        configTag = event.configTag
-                    )
+            is PrefClickEvent.StartActivity -> _effects.tryEmit(
+                MainEffect.StartActivity(
+                    destination = event.destination,
+                    configTag = event.configTag,
                 )
-            }
-
+            )
             PrefClickEvent.ExitApp -> _effects.tryEmit(MainEffect.ExitApp)
-
             PrefClickEvent.OpenReadRecord -> _effects.tryEmit(MainEffect.NavigateToReadRecord)
-
-            PrefClickEvent.OpenHighlightTagRule -> _effects.tryEmit(MainEffect.NavigateToHighlightTagRule)
-
+            PrefClickEvent.OpenHighlightTagRule ->
+                _effects.tryEmit(MainEffect.NavigateToHighlightTagRule)
             PrefClickEvent.OpenAbout -> _effects.tryEmit(MainEffect.NavigateToAbout)
-
             else -> Unit
         }
     }
-
 }
 
-sealed interface MainEffect {
-    data class OpenUrl(val url: String) : MainEffect
-    data class CopyUrl(val url: String) : MainEffect
-    data class ShowMarkdown(val title: String, val path: String) : MainEffect
-    data class StartActivity(
-        val destination: Class<*>,
-        val configTag: String? = null
-    ) : MainEffect
-
-    data object ExitApp : MainEffect
-    data object NavigateToReadRecord : MainEffect
-    data object NavigateToHighlightTagRule : MainEffect
-    data object NavigateToAbout : MainEffect
-}
-
-@Stable
-data class MainUiState(
-    val destinations: ImmutableList<MainDestination> = MainDestination.mainDestinations,
-    val defaultHomePage: String = "bookshelf",
-    val showBottomView: Boolean = true,
-    val useFloatingBottomBar: Boolean = false,
-    val useFloatingBottomBarLiquidGlass: Boolean = false,
-    val labelVisibilityMode: String = "auto",
-    val navExtended: Boolean = false,
-)
-
-private fun MainViewModel.readMainUiState(): MainUiState {
-    val destinations = MainDestination.ordered(ThemeConfig.mainNavigationOrder).filter {
+private fun buildMainUiState(
+    appShell: AppShellSettings,
+    theme: ThemeSettings,
+): MainUiState {
+    val destinations = MainDestination.ordered(appShell.mainNavigationOrder).filter {
         when (it) {
-            MainDestination.Home -> ThemeConfig.showHome
-            MainDestination.Explore -> ThemeConfig.showDiscovery
-            MainDestination.Rss -> ThemeConfig.showRss
+            MainDestination.Home -> appShell.showHome
+            MainDestination.Explore -> appShell.showDiscovery
+            MainDestination.Rss -> appShell.showRss
             else -> true
         }
-    }.toImmutableList()
+    }
     return MainUiState(
-        destinations = destinations,
-        defaultHomePage = ThemeConfig.defaultHomePage,
-        showBottomView = ThemeConfig.showBottomView,
-        useFloatingBottomBar = ThemeConfig.useFloatingBottomBar,
-        useFloatingBottomBarLiquidGlass = ThemeConfig.useFloatingBottomBarLiquidGlass,
-        labelVisibilityMode = ThemeConfig.labelVisibilityMode,
-        navExtended = ThemeConfig.navExtended,
+        destinations = destinations.toImmutableList(),
+        defaultHomePage = appShell.defaultHomePage,
+        showBottomView = appShell.showBottomView,
+        useFloatingBottomBar = appShell.useFloatingBottomBar,
+        useFloatingBottomBarLiquidGlass = appShell.useFloatingBottomBarLiquidGlass,
+        labelVisibilityMode = appShell.labelVisibilityMode,
+        navExtended = appShell.navExtended,
+        navIconHome = appShell.navIconHome,
+        navIconBookshelf = appShell.navIconBookshelf,
+        navIconExplore = appShell.navIconExplore,
+        navIconRss = appShell.navIconRss,
+        navIconMy = appShell.navIconMy,
+        deepPersonalizationActive = theme.appTheme == "12" && theme.enableDeepPersonalization,
+        secondaryThemeColor = theme.secondaryThemeColor,
+        secondaryThemeColorNight = theme.secondaryThemeColorNight,
     )
 }

+ 76 - 55
app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt

@@ -88,35 +88,18 @@ import org.koin.androidx.compose.koinViewModel
 import org.koin.compose.koinInject
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
-@Composable
-fun ExploreScreen(
-    onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
-) {
-    ExploreDiscoveryScreen(onOpenExploreShow = onOpenExploreShow)
-}
-
 @OptIn(
     ExperimentalMaterial3Api::class,
     ExperimentalFoundationApi::class,
 )
 @Composable
-private fun ExploreDiscoveryScreen(
+fun ExploreRouteScreen(
     viewModel: ExploreViewModel = koinViewModel(),
     onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
 ) {
     val context = LocalContext.current
     val activity = context as? AppCompatActivity
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-    val listItems by remember(uiState.items, uiState.expandedId, uiState.exploreKinds) {
-        derivedStateOf { viewModel.buildExploreListItems(uiState) }
-    }
-    var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
-    val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) {
-        uiState.items.firstOrNull { it.bookSourceUrl == sourceToDeleteUrl }
-    }
-    val listState = rememberLazyListState()
-    val scope = rememberCoroutineScope()
     val exploreKindUseCase: ExploreKindUiUseCase = koinInject()
 
     LaunchedEffect(viewModel, activity, exploreKindUseCase) {
@@ -130,19 +113,62 @@ private fun ExploreDiscoveryScreen(
                         sourceUrl = effect.sourceUrl,
                         infoMap = infoMap,
                         activity = activity,
-                        onRefreshKinds = { viewModel.refreshExploreKinds(effect.sourceUrl) }
+                        onRefreshKinds = {
+                            uiState.items.firstOrNull { it.bookSourceUrl == effect.sourceUrl }
+                                ?.let { viewModel.onIntent(ExploreIntent.RefreshKinds(it)) }
+                        }
                     )
                 }
+                is ExploreEffect.OpenEdit -> {
+                    context.startActivity<BookSourceEditActivity> {
+                        putExtra("sourceUrl", effect.sourceUrl)
+                    }
+                }
+                is ExploreEffect.OpenSearch -> {
+                    context.startActivity<SearchActivity> {
+                        putExtra("searchScope", SearchScope(effect.source).toString())
+                    }
+                }
+                is ExploreEffect.OpenLogin -> {
+                    context.startActivity<SourceLoginActivity> {
+                        putExtra("type", "bookSource")
+                        putExtra("key", effect.sourceUrl)
+                    }
+                }
             }
         }
     }
 
-    val stickyHeaderSource by remember(listItems, uiState.items) {
+    ExploreScreen(
+        state = uiState,
+        onIntent = viewModel::onIntent,
+        onOpenExploreShow = onOpenExploreShow,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun ExploreScreen(
+    state: ExploreViewModel.ExploreUiState,
+    onIntent: (ExploreIntent) -> Unit,
+    onOpenExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit,
+) {
+    val listItems by remember(state.items, state.expandedId, state.exploreKinds) {
+        derivedStateOf { buildExploreListItems(state) }
+    }
+    var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
+    val sourceToDelete = remember(sourceToDeleteUrl, state.items) {
+        state.items.firstOrNull { it.bookSourceUrl == sourceToDeleteUrl }
+    }
+    val listState = rememberLazyListState()
+    val scope = rememberCoroutineScope()
+
+    val stickyHeaderSource by remember(listItems, state.items) {
         derivedStateOf {
             val firstIndex = listState.firstVisibleItemIndex
             val item = listItems.getOrNull(firstIndex)
             if (item is ExploreListItem.KindRow) {
-                uiState.items.find { it.bookSourceUrl == item.sourceUrl }
+                state.items.find { it.bookSourceUrl == item.sourceUrl }
             } else {
                 null
             }
@@ -153,29 +179,29 @@ private fun ExploreDiscoveryScreen(
 
     ListScaffold(
         title = stringResource(R.string.discovery),
-        state = uiState,
-        subtitle = uiState.selectedGroup.ifEmpty { stringResource(R.string.all) },
-        onSearchQueryChange = { viewModel.search(it) },
-        onSearchToggle = { viewModel.toggleSearchVisible(it) },
+        state = state,
+        subtitle = state.selectedGroup.ifEmpty { stringResource(R.string.all) },
+        onSearchQueryChange = { onIntent(ExploreIntent.Search(it)) },
+        onSearchToggle = { onIntent(ExploreIntent.ToggleSearch(it)) },
         searchPlaceholder = stringResource(R.string.search),
         dropDownMenuContent = { dismiss ->
             RoundDropdownMenuItem(
                 leadingIcon = { MenuItemIcon(Icons.Default.Group) },
                 text = stringResource(R.string.all),
-                onClick = { viewModel.setGroup(""); dismiss() }
+                onClick = { onIntent(ExploreIntent.SetGroup("")); dismiss() }
             )
-            uiState.groups.forEach { group ->
+            state.groups.forEach { group ->
                 RoundDropdownMenuItem(
                     leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Outlined.Label) },
                     text = group,
-                    onClick = { viewModel.setGroup(group); dismiss() }
+                    onClick = { onIntent(ExploreIntent.SetGroup(group)); dismiss() }
                 )
             }
         },
         contentWindowInsets = WindowInsets(0)
     ) { paddingValues ->
         Box(modifier = Modifier.fillMaxSize()) {
-            if (uiState.items.isEmpty()) {
+            if (state.items.isEmpty()) {
                 EmptyMessage(
                     modifier = Modifier
                         .fillMaxSize()
@@ -203,31 +229,18 @@ private fun ExploreDiscoveryScreen(
                     when (listItem) {
                         is ExploreListItem.Header -> {
                             val item = listItem.source
-                            val isExpanded = uiState.expandedId == item.bookSourceUrl
+                            val isExpanded = state.expandedId == item.bookSourceUrl
                         ExploreSourceHeader(
                             modifier = Modifier.animateItem(),
                             item = item,
                             isExpanded = isExpanded,
-                            loadingKinds = if (isExpanded) uiState.loadingKinds else false,
-                            onClick = { viewModel.toggleExpand(item) },
-                            onTop = { viewModel.topSource(item) },
-                            onEdit = {
-                                context.startActivity<BookSourceEditActivity> {
-                                    putExtra("sourceUrl", item.bookSourceUrl)
-                                }
-                            },
-                            onSearch = {
-                                context.startActivity<SearchActivity> {
-                                    putExtra("searchScope", SearchScope(item).toString())
-                                }
-                            },
-                            onLogin = {
-                                context.startActivity<SourceLoginActivity> {
-                                    putExtra("type", "bookSource")
-                                    putExtra("key", item.bookSourceUrl)
-                                }
-                            },
-                            onRefresh = { viewModel.refreshExploreKinds(item) },
+                            loadingKinds = if (isExpanded) state.loadingKinds else false,
+                            onClick = { onIntent(ExploreIntent.ToggleExpand(item)) },
+                            onTop = { onIntent(ExploreIntent.TopSource(item)) },
+                            onEdit = { onIntent(ExploreIntent.OpenEdit(item)) },
+                            onSearch = { onIntent(ExploreIntent.OpenSearch(item)) },
+                            onLogin = { onIntent(ExploreIntent.OpenLogin(item)) },
+                            onRefresh = { onIntent(ExploreIntent.RefreshKinds(item)) },
                             onDelete = { sourceToDeleteUrl = item.bookSourceUrl },
                             isMiuix = composeEngine
                         )
@@ -250,13 +263,21 @@ private fun ExploreDiscoveryScreen(
                                         },
                                         modifier = Modifier.weight(span.toFloat()),
                                         isMiuix = composeEngine,
-                                        displayNameOverride = uiState.kindDisplayNames[kind.title],
-                                        valueOverride = uiState.kindValues[kind.title],
+                                        displayNameOverride = state.kindDisplayNames[kind.title],
+                                        valueOverride = state.kindValues[kind.title],
                                         onValueChange = { value ->
-                                            viewModel.updateKindValue(listItem.sourceUrl, kind, value)
+                                            onIntent(
+                                                ExploreIntent.UpdateKindValue(
+                                                    listItem.sourceUrl,
+                                                    kind,
+                                                    value,
+                                                )
+                                            )
                                         },
                                         onRunAction = {
-                                            viewModel.requestKindAction(listItem.sourceUrl, kind)
+                                            onIntent(
+                                                ExploreIntent.RunKindAction(listItem.sourceUrl, kind)
+                                            )
                                         }
                                     )
                                 }
@@ -309,7 +330,7 @@ private fun ExploreDiscoveryScreen(
         title = stringResource(R.string.sure_del),
         confirmText = stringResource(android.R.string.ok),
         onConfirm = { source ->
-            viewModel.deleteSource(source)
+            onIntent(ExploreIntent.DeleteSource(source))
             sourceToDeleteUrl = null
         },
         dismissText = stringResource(android.R.string.cancel),

+ 70 - 26
app/src/main/java/io/legado/app/ui/main/explore/ExploreViewModel.kt

@@ -53,6 +53,28 @@ class ExploreViewModel(
         observeExplore()
     }
 
+    fun onIntent(intent: ExploreIntent) {
+        when (intent) {
+            is ExploreIntent.Search -> search(intent.query)
+            is ExploreIntent.ToggleSearch -> toggleSearchVisible(intent.visible)
+            is ExploreIntent.SetGroup -> setGroup(intent.group)
+            is ExploreIntent.ToggleExpand -> toggleExpand(intent.source)
+            is ExploreIntent.TopSource -> topSource(intent.source)
+            is ExploreIntent.RefreshKinds -> refreshExploreKinds(intent.source)
+            is ExploreIntent.DeleteSource -> deleteSource(intent.source)
+            is ExploreIntent.UpdateKindValue ->
+                updateKindValue(intent.sourceUrl, intent.kind, intent.value)
+            is ExploreIntent.RunKindAction -> requestKindAction(intent.sourceUrl, intent.kind)
+            is ExploreIntent.OpenEdit -> _effects.tryEmit(
+                ExploreEffect.OpenEdit(intent.source.bookSourceUrl)
+            )
+            is ExploreIntent.OpenSearch -> _effects.tryEmit(ExploreEffect.OpenSearch(intent.source))
+            is ExploreIntent.OpenLogin -> _effects.tryEmit(
+                ExploreEffect.OpenLogin(intent.source.bookSourceUrl)
+            )
+        }
+    }
+
     private fun observeGroups() {
         viewModelScope.launch {
             exploreRepository.getExploreGroups()
@@ -201,32 +223,6 @@ class ExploreViewModel(
         val loadingKinds: Boolean = false
     ) : ListUiState<BookSourcePart>
 
-    fun buildExploreListItems(state: ExploreUiState): ImmutableList<ExploreListItem> {
-        if (state.items.isEmpty()) return persistentListOf()
-        val expandedId = state.expandedId
-        val kindRows = if (expandedId != null) {
-            calculateExploreKindRows(state.exploreKinds, 6)
-        } else {
-            emptyList()
-        }
-        return buildList {
-            state.items.forEach { source ->
-                add(ExploreListItem.Header(source))
-                if (source.bookSourceUrl == expandedId) {
-                    kindRows.forEachIndexed { index, row ->
-                        add(
-                            ExploreListItem.KindRow(
-                                sourceUrl = source.bookSourceUrl,
-                                rowIndex = index,
-                                rowItems = row.toImmutableList()
-                            )
-                        )
-                    }
-                }
-            }
-        }.toImmutableList()
-    }
-
     private fun buildKindValues(
         kinds: List<ExploreKind>,
         sourceUrl: String
@@ -285,4 +281,52 @@ sealed interface ExploreEffect {
         val sourceUrl: String,
         val kind: ExploreKind
     ) : ExploreEffect
+    data class OpenEdit(val sourceUrl: String) : ExploreEffect
+    data class OpenSearch(val source: BookSourcePart) : ExploreEffect
+    data class OpenLogin(val sourceUrl: String) : ExploreEffect
+}
+
+sealed interface ExploreIntent {
+    data class Search(val query: String) : ExploreIntent
+    data class ToggleSearch(val visible: Boolean) : ExploreIntent
+    data class SetGroup(val group: String) : ExploreIntent
+    data class ToggleExpand(val source: BookSourcePart) : ExploreIntent
+    data class TopSource(val source: BookSourcePart) : ExploreIntent
+    data class RefreshKinds(val source: BookSourcePart) : ExploreIntent
+    data class DeleteSource(val source: BookSourcePart) : ExploreIntent
+    data class UpdateKindValue(
+        val sourceUrl: String,
+        val kind: ExploreKind,
+        val value: String,
+    ) : ExploreIntent
+    data class RunKindAction(val sourceUrl: String, val kind: ExploreKind) : ExploreIntent
+    data class OpenEdit(val source: BookSourcePart) : ExploreIntent
+    data class OpenSearch(val source: BookSourcePart) : ExploreIntent
+    data class OpenLogin(val source: BookSourcePart) : ExploreIntent
+}
+
+fun buildExploreListItems(state: ExploreViewModel.ExploreUiState): ImmutableList<ExploreListItem> {
+    if (state.items.isEmpty()) return persistentListOf()
+    val expandedId = state.expandedId
+    val kindRows = if (expandedId != null) {
+        calculateExploreKindRows(state.exploreKinds, 6)
+    } else {
+        emptyList()
+    }
+    return buildList {
+        state.items.forEach { source ->
+            add(ExploreListItem.Header(source))
+            if (source.bookSourceUrl == expandedId) {
+                kindRows.forEachIndexed { index, row ->
+                    add(
+                        ExploreListItem.KindRow(
+                            sourceUrl = source.bookSourceUrl,
+                            rowIndex = index,
+                            rowItems = row.toImmutableList(),
+                        )
+                    )
+                }
+            }
+        }
+    }.toImmutableList()
 }

+ 21 - 4
app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt

@@ -65,14 +65,31 @@ import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun MyScreen(
+fun MyRouteScreen(
     viewModel: MyViewModel = koinViewModel(),
     onOpenSettings: () -> Unit,
     onNavigateToChat: () -> Unit,
     onNavigate: (PrefClickEvent) -> Unit
 ) {
-
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    MyScreen(
+        state = uiState,
+        onIntent = viewModel::onIntent,
+        onOpenSettings = onOpenSettings,
+        onNavigateToChat = onNavigateToChat,
+        onNavigate = onNavigate,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MyScreen(
+    state: MyUiState,
+    onIntent: (MyIntent) -> Unit,
+    onOpenSettings: () -> Unit,
+    onNavigateToChat: () -> Unit,
+    onNavigate: (PrefClickEvent) -> Unit,
+) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -114,9 +131,9 @@ fun MyScreen(
                 title = ""
             ) {
                 WebServiceSettingBlock(
-                    uiState = uiState,
+                    uiState = state,
                     onToggleWebService = {
-                        viewModel.onEvent(PrefClickEvent.ToggleWebService)
+                        onIntent(MyIntent.ToggleWebService)
                     },
                     onNavigate = onNavigate
                 )

+ 13 - 4
app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt

@@ -8,8 +8,10 @@ import io.legado.app.constant.EventBus
 import io.legado.app.service.WebService
 import io.legado.app.utils.eventBus.FlowEventBus
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
@@ -32,6 +34,12 @@ sealed class PrefClickEvent {
     object ExitApp : PrefClickEvent()
 }
 
+sealed interface MyIntent {
+    data object ToggleWebService : MyIntent
+}
+
+sealed interface MyEffect
+
 class MyViewModel(
     application: Application
 ) : BaseViewModel(application) {
@@ -43,6 +51,8 @@ class MyViewModel(
         )
     )
     val uiState: StateFlow<MyUiState> = _uiState.asStateFlow()
+    private val _effects = MutableSharedFlow<MyEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     init {
         viewModelScope.launch {
@@ -58,9 +68,9 @@ class MyViewModel(
         }
     }
 
-    fun onEvent(event: PrefClickEvent) {
-        when (event) {
-            PrefClickEvent.ToggleWebService -> {
+    fun onIntent(intent: MyIntent) {
+        when (intent) {
+            MyIntent.ToggleWebService -> {
                 val currentIsRun = _uiState.value.isWebServiceRun
 
                 if (!currentIsRun) {
@@ -71,7 +81,6 @@ class MyViewModel(
                 }
 
             }
-            else -> Unit
         }
     }
 

+ 35 - 22
app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt

@@ -74,7 +74,7 @@ import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
-fun RssScreen(
+fun RssRouteScreen(
     viewModel: RssViewModel = koinViewModel(),
     onOpenSort: (sourceUrl: String, sortUrl: String?, key: String?) -> Unit,
     onOpenRead: (
@@ -89,10 +89,6 @@ fun RssScreen(
 ) {
     val context = LocalContext.current
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-    var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
-    val sourceToDelete = remember(sourceToDeleteUrl, uiState.items) {
-        uiState.items.firstOrNull { it.sourceUrl == sourceToDeleteUrl }
-    }
     val currentContext by rememberUpdatedState(context)
     val currentOnOpenSort by rememberUpdatedState(onOpenSort)
     val currentOnOpenRead by rememberUpdatedState(onOpenRead)
@@ -148,18 +144,35 @@ fun RssScreen(
         }
     }
 
+    RssScreen(
+        state = uiState,
+        onIntent = viewModel::onIntent,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RssScreen(
+    state: RssUiState,
+    onIntent: (RssIntent) -> Unit,
+) {
+    var sourceToDeleteUrl by rememberSaveable { mutableStateOf<String?>(null) }
+    val sourceToDelete = remember(sourceToDeleteUrl, state.items) {
+        state.items.firstOrNull { it.sourceUrl == sourceToDeleteUrl }
+    }
+
     ListScaffold(
             title = stringResource(R.string.rss),
-            state = uiState,
-            subtitle = uiState.group.ifEmpty { stringResource(R.string.all) },
+            state = state,
+            subtitle = state.group.ifEmpty { stringResource(R.string.all) },
             onBackClick = null,
-            onSearchToggle = { viewModel.toggleSearchVisible(it) },
-            onSearchQueryChange = { viewModel.search(it) },
+            onSearchToggle = { onIntent(RssIntent.ToggleSearch(it)) },
+            onSearchQueryChange = { onIntent(RssIntent.Search(it)) },
             searchPlaceholder = stringResource(R.string.search_rss_source),
             dropDownMenuContent = { dismiss ->
                 RoundDropdownMenuItem(
                     onClick = {
-                        viewModel.openSourceManage()
+                        onIntent(RssIntent.OpenSourceManage)
                         dismiss()
                     },
                     text = stringResource(R.string.rss_feed_management),
@@ -168,15 +181,15 @@ fun RssScreen(
                 RoundDropdownMenuItem(
                     text = stringResource(R.string.all),
                     onClick = {
-                        viewModel.setGroup("")
+                        onIntent(RssIntent.SetGroup(""))
                         dismiss()
                     }
                 )
-                uiState.groups.forEach { group ->
+                state.groups.forEach { group ->
                     RoundDropdownMenuItem(
                         text = group,
                         onClick = {
-                            viewModel.setGroup(group)
+                            onIntent(RssIntent.SetGroup(group))
                             dismiss()
                         }
                     )
@@ -208,7 +221,7 @@ fun RssScreen(
                                     contentDescription = ruleSubscriptionLabel
                                     role = Role.Button
                                 },
-                            onClick = { viewModel.openRuleSub() }
+                            onClick = { onIntent(RssIntent.OpenRuleSub) }
                         ) {
                             Row(
                                 modifier = Modifier
@@ -235,7 +248,7 @@ fun RssScreen(
                                     contentDescription = favoriteLabel
                                     role = Role.Button
                                 },
-                            onClick = { viewModel.openFavorites() }
+                            onClick = { onIntent(RssIntent.OpenFavorites) }
                         ) {
                             Row(
                                 modifier = Modifier
@@ -258,16 +271,16 @@ fun RssScreen(
                     }
                 }
 
-                items(uiState.items, key = { it.sourceUrl }) { source ->
+                items(state.items, key = { it.sourceUrl }) { source ->
                     RssSourceGridItem(
                         modifier = Modifier.animateItem(),
                         source = source,
-                        onClick = { viewModel.openSource(source) },
-                        onTop = { viewModel.topSource(source) },
-                        onEdit = { viewModel.openSourceEdit(source) },
+                        onClick = { onIntent(RssIntent.OpenSource(source)) },
+                        onTop = { onIntent(RssIntent.TopSource(source)) },
+                        onEdit = { onIntent(RssIntent.EditSource(source)) },
                         onDelete = { sourceToDeleteUrl = source.sourceUrl },
-                        onDisable = { viewModel.disable(source) },
-                        onLogin = { viewModel.login(source) }
+                        onDisable = { onIntent(RssIntent.DisableSource(source)) },
+                        onLogin = { onIntent(RssIntent.Login(source)) }
                     )
                 }
             }
@@ -279,7 +292,7 @@ fun RssScreen(
         title = stringResource(R.string.draw),
         confirmText = stringResource(R.string.yes),
         onConfirm = { source ->
-            viewModel.del(source)
+            onIntent(RssIntent.DeleteSource(source))
             sourceToDeleteUrl = null
         },
         dismissText = stringResource(R.string.no),

+ 32 - 0
app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt

@@ -77,6 +77,23 @@ class RssViewModel(
         _uiState.update { it.copy(searchKey = key, isSearch = key.isNotEmpty()) }
     }
 
+    fun onIntent(intent: RssIntent) {
+        when (intent) {
+            is RssIntent.Search -> search(intent.query)
+            is RssIntent.ToggleSearch -> toggleSearchVisible(intent.visible)
+            is RssIntent.SetGroup -> setGroup(intent.group)
+            is RssIntent.OpenSource -> openSource(intent.source)
+            is RssIntent.TopSource -> topSource(intent.source)
+            is RssIntent.EditSource -> openSourceEdit(intent.source)
+            is RssIntent.DeleteSource -> del(intent.source)
+            is RssIntent.DisableSource -> disable(intent.source)
+            is RssIntent.Login -> login(intent.source)
+            RssIntent.OpenRuleSub -> openRuleSub()
+            RssIntent.OpenFavorites -> openFavorites()
+            RssIntent.OpenSourceManage -> openSourceManage()
+        }
+    }
+
     fun setGroup(group: String) {
         groupFlow.value = group
         searchKeyFlow.value = ""
@@ -225,3 +242,18 @@ sealed interface RssEffect {
     data object OpenFavorites : RssEffect
     data object OpenSourceManage : RssEffect
 }
+
+sealed interface RssIntent {
+    data class Search(val query: String) : RssIntent
+    data class ToggleSearch(val visible: Boolean) : RssIntent
+    data class SetGroup(val group: String) : RssIntent
+    data class OpenSource(val source: RssSource) : RssIntent
+    data class TopSource(val source: RssSource) : RssIntent
+    data class EditSource(val source: RssSource) : RssIntent
+    data class DeleteSource(val source: RssSource) : RssIntent
+    data class DisableSource(val source: RssSource) : RssIntent
+    data class Login(val source: RssSource) : RssIntent
+    data object OpenRuleSub : RssIntent
+    data object OpenFavorites : RssIntent
+    data object OpenSourceManage : RssIntent
+}

+ 35 - 20
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt

@@ -41,16 +41,33 @@ import io.legado.app.ui.widget.components.image.sourceIcon.SourceIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import org.koin.androidx.compose.koinViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
-fun RssFavoritesScreen(
+fun RssFavoritesRouteScreen(
     onBackClick: () -> Unit,
     onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit,
     viewModel: RssFavoritesViewModel = koinViewModel()
 ) {
-    val state by viewModel.state.collectAsState()
-    val groups by viewModel.groups.collectAsState()
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    RssFavoritesScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+        onOpenRead = onOpenRead,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RssFavoritesScreen(
+    state: RssFavoritesUiState,
+    onIntent: (RssFavoritesIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onOpenRead: (title: String?, origin: String, link: String?, openUrl: String?) -> Unit,
+) {
+    val groups = state.groups
     val snackbarHostState = remember { SnackbarHostState() }
 
     var showAddToGroupDialog by remember { mutableStateOf(false) }
@@ -64,9 +81,8 @@ fun RssFavoritesScreen(
         suggestions = groups,
         onDismissRequest = { showAddToGroupDialog = false },
         onConfirm = { text ->
-            viewModel.selectionAddToGroups(state.selectedIds, text)
+            onIntent(RssFavoritesIntent.AddSelectedToGroup(text))
             showAddToGroupDialog = false
-            viewModel.clearSelection()
         }
     )
 
@@ -77,9 +93,8 @@ fun RssFavoritesScreen(
         suggestions = groups,
         onDismissRequest = { showRemoveFromGroupDialog = false },
         onConfirm = { text ->
-            viewModel.selectionRemoveFromGroups(state.selectedIds, text)
+            onIntent(RssFavoritesIntent.RemoveSelectedFromGroup(text))
             showRemoveFromGroupDialog = false
-            viewModel.clearSelection()
         }
     )
 
@@ -91,7 +106,7 @@ fun RssFavoritesScreen(
         suggestions = groups,
         onDismissRequest = { showSetGroupDialog = null },
         onConfirm = { rssStar, text ->
-            viewModel.updateGroup(rssStar, text)
+            onIntent(RssFavoritesIntent.UpdateGroup(rssStar, text))
             showSetGroupDialog = null
         }
     )
@@ -101,12 +116,12 @@ fun RssFavoritesScreen(
         subtitle = state.currentGroup.ifEmpty { stringResource(R.string.all) },
         state = state,
         onBackClick = onBackClick,
-        onSearchToggle = viewModel::onSearchToggle,
-        onSearchQueryChange = viewModel::onSearchQueryChange,
-        onClearSelection = viewModel::clearSelection,
-        onSelectAll = viewModel::selectAll,
-        onSelectInvert = viewModel::selectInvert,
-        onDeleteSelected = { viewModel.deleteSelected() },
+        onSearchToggle = { onIntent(RssFavoritesIntent.ToggleSearch(it)) },
+        onSearchQueryChange = { onIntent(RssFavoritesIntent.Search(it)) },
+        onClearSelection = { onIntent(RssFavoritesIntent.ClearSelection) },
+        onSelectAll = { onIntent(RssFavoritesIntent.SelectAll) },
+        onSelectInvert = { onIntent(RssFavoritesIntent.InvertSelection) },
+        onDeleteSelected = { onIntent(RssFavoritesIntent.DeleteSelected) },
         snackbarHostState = snackbarHostState,
         selectionSecondaryActions = listOf(
             ActionItem(
@@ -128,7 +143,7 @@ fun RssFavoritesScreen(
                     }
                 },
                 onClick = {
-                    viewModel.onGroupChange("")
+                    onIntent(RssFavoritesIntent.ChangeGroup(""))
                     dismiss()
                 }
             )
@@ -145,7 +160,7 @@ fun RssFavoritesScreen(
                         }
                     },
                     onClick = {
-                        viewModel.onGroupChange(group)
+                        onIntent(RssFavoritesIntent.ChangeGroup(group))
                         dismiss()
                     }
                 )
@@ -159,7 +174,7 @@ fun RssFavoritesScreen(
                     }
                 },
                 onClick = {
-                    viewModel.deleteGroup(state.currentGroup)
+                    onIntent(RssFavoritesIntent.DeleteGroup(state.currentGroup))
                     dismiss()
                 }
             )
@@ -171,7 +186,7 @@ fun RssFavoritesScreen(
                     }
                 },
                 onClick = {
-                    viewModel.deleteAll()
+                    onIntent(RssFavoritesIntent.DeleteAll)
                     dismiss()
                 }
             )
@@ -211,7 +226,7 @@ fun RssFavoritesScreen(
                         isSelected = isSelected,
                         inSelectionMode = state.selectedIds.isNotEmpty(),
                         onToggleSelection = {
-                            viewModel.toggleSelection(rssStar)
+                            onIntent(RssFavoritesIntent.ToggleSelection(rssStar))
                         },
                         leadingContent = if (!rssStar.image.isNullOrBlank()) {
                             {
@@ -246,7 +261,7 @@ fun RssFavoritesScreen(
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.delete),
                                 onClick = {
-                                    viewModel.deleteStar(rssStar)
+                                    onIntent(RssFavoritesIntent.DeleteStar(rssStar))
                                     dismiss()
                                 }
                             )

+ 129 - 143
app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesViewModel.kt

@@ -1,16 +1,23 @@
 package io.legado.app.ui.rss.favorites
 
-import android.app.Application
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssStar
+import io.legado.app.data.repository.RssFavoriteRepository
 import io.legado.app.ui.widget.components.list.ListUiState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOn
@@ -18,160 +25,139 @@ import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 
-class RssFavoritesViewModel(application: Application) : BaseViewModel(application) {
-
-    private val _searchKey = MutableStateFlow("")
-    private val _isSearch = MutableStateFlow(false)
-    private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
-    private val _currentGroup = MutableStateFlow("")
-
-    val groups = appDb.rssStarDao.flowGroups()
-        .flowOn(IO)
-        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+class RssFavoritesViewModel(
+    private val repository: RssFavoriteRepository,
+) : ViewModel() {
+    private val searchKey = MutableStateFlow("")
+    private val isSearch = MutableStateFlow(false)
+    private val selectedIds = MutableStateFlow<Set<String>>(emptySet())
+    private val currentGroup = MutableStateFlow("")
+    private val _effects = MutableSharedFlow<RssFavoritesEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     @OptIn(ExperimentalCoroutinesApi::class)
-    val state: StateFlow<RssFavoritesUiState> = combine(
-        _currentGroup,
-        _searchKey,
-        _isSearch,
-        _selectedIds,
-        _currentGroup.flatMapLatest { group ->
-            if (group.isEmpty()) {
-                appDb.rssStarDao.liveAll()
-            } else {
-                appDb.rssStarDao.flowByGroup(group)
-            }
-        }
-    ) { group, searchKey, isSearch, selectedIds, items ->
-        val filteredItems = if (isSearch && searchKey.isNotBlank()) {
-            items.filter { it.title.contains(searchKey, ignoreCase = true) }
-        } else {
-            items
-        }
+    val uiState = combine(
+        currentGroup,
+        searchKey,
+        isSearch,
+        selectedIds,
+        currentGroup.flatMapLatest { group ->
+            if (group.isEmpty()) repository.observeAll() else repository.observeGroup(group)
+        },
+        repository.observeGroups(),
+    ) { values ->
+        val group = values[0] as String
+        val query = values[1] as String
+        val searching = values[2] as Boolean
+        @Suppress("UNCHECKED_CAST") val selected = values[3] as Set<String>
+        @Suppress("UNCHECKED_CAST") val items = values[4] as List<RssStar>
+        @Suppress("UNCHECKED_CAST") val groups = values[5] as List<String>
+        val filtered = if (searching && query.isNotBlank()) {
+            items.filter { it.title.contains(query, ignoreCase = true) }
+        } else items
         RssFavoritesUiState(
-            items = filteredItems,
-            selectedIds = selectedIds,
-            searchKey = searchKey,
-            isSearch = isSearch,
-            currentGroup = group
+            items = filtered.toImmutableList(),
+            selectedIds = selected.toImmutableSet(),
+            searchKey = query,
+            isSearch = searching,
+            currentGroup = group,
+            groups = groups.toImmutableList(),
         )
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RssFavoritesUiState())
-
-    fun onSearchToggle(isSearch: Boolean) {
-        _isSearch.value = isSearch
-        if (!isSearch) _searchKey.value = ""
-    }
-
-    fun onSearchQueryChange(query: String) {
-        _searchKey.value = query
-    }
-
-    fun onGroupChange(group: String) {
-        _currentGroup.value = group
-        _selectedIds.value = emptySet()
-    }
-
-    fun setSelection(ids: Set<String>) {
-        _selectedIds.value = ids
-    }
-
-    fun toggleSelection(rssStar: RssStar) {
-        val id = "${rssStar.origin}|${rssStar.link}"
-        _selectedIds.update {
-            if (it.contains(id)) it - id else it + id
-        }
-    }
-
-    fun selectAll() {
-        _selectedIds.value = state.value.items.map { "${it.origin}|${it.link}" }.toSet()
-    }
-
-    fun selectInvert() {
-        val allIds = state.value.items.map { "${it.origin}|${it.link}" }.toSet()
-        _selectedIds.update { current ->
-            allIds - current
-        }
-    }
-
-    fun clearSelection() {
-        _selectedIds.value = emptySet()
-    }
-
-    fun deleteSelected() {
-        val selected = _selectedIds.value
-        viewModelScope.launch(IO) {
-            state.value.items.forEach {
-                val id = "${it.origin}|${it.link}"
-                if (selected.contains(id)) {
-                    appDb.rssStarDao.delete(it.origin, it.link)
-                }
+    }.flowOn(IO).stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000),
+        RssFavoritesUiState(),
+    )
+
+    fun onIntent(intent: RssFavoritesIntent) {
+        when (intent) {
+            is RssFavoritesIntent.ToggleSearch -> {
+                isSearch.value = intent.enabled
+                if (!intent.enabled) searchKey.value = ""
             }
-            clearSelection()
-        }
-    }
-
-    fun selectionAddToGroups(selectedIds: Set<String>, groupName: String) {
-        viewModelScope.launch(IO) {
-            state.value.items.forEach {
-                val id = "${it.origin}|${it.link}"
-                if (selectedIds.contains(id)) {
-                    val groups = it.group.split(",").toMutableList()
-                    if (!groups.contains(groupName)) {
-                        groups.add(groupName)
-                        appDb.rssStarDao.update(it.copy(group = groups.filter { g -> g.isNotBlank() }
-                            .joinToString(",")))
-                    }
-                }
+            is RssFavoritesIntent.Search -> searchKey.value = intent.query
+            is RssFavoritesIntent.ChangeGroup -> {
+                currentGroup.value = intent.group
+                selectedIds.value = emptySet()
             }
-        }
-    }
-
-    fun selectionRemoveFromGroups(selectedIds: Set<String>, groupName: String) {
-        viewModelScope.launch(IO) {
-            state.value.items.forEach {
-                val id = "${it.origin}|${it.link}"
-                if (selectedIds.contains(id)) {
-                    val groups = it.group.split(",").toMutableList()
-                    if (groups.contains(groupName)) {
-                        groups.remove(groupName)
-                        appDb.rssStarDao.update(it.copy(group = groups.filter { g -> g.isNotBlank() }
-                            .joinToString(",")))
-                    }
-                }
+            is RssFavoritesIntent.ToggleSelection -> toggleSelection(intent.star)
+            RssFavoritesIntent.SelectAll -> selectedIds.value =
+                uiState.value.items.map(::itemId).toSet()
+            RssFavoritesIntent.InvertSelection -> {
+                val all = uiState.value.items.map(::itemId).toSet()
+                selectedIds.update { all - it }
+            }
+            RssFavoritesIntent.ClearSelection -> selectedIds.value = emptySet()
+            RssFavoritesIntent.DeleteSelected -> mutateSelected { repository.delete(it) }
+            is RssFavoritesIntent.AddSelectedToGroup -> mutateSelected { star ->
+                val groups = (star.group.split(',').filter(String::isNotBlank) + intent.group)
+                    .distinct().joinToString(",")
+                repository.update(star.copy(group = groups))
+            }
+            is RssFavoritesIntent.RemoveSelectedFromGroup -> mutateSelected { star ->
+                repository.update(
+                    star.copy(
+                        group = star.group.split(',')
+                            .filter { it.isNotBlank() && it != intent.group }
+                            .joinToString(",")
+                    )
+                )
+            }
+            is RssFavoritesIntent.UpdateGroup -> viewModelScope.launch(IO) {
+                repository.update(intent.star.copy(group = intent.group))
+            }
+            is RssFavoritesIntent.DeleteGroup -> viewModelScope.launch(IO) {
+                repository.deleteGroup(intent.group)
+            }
+            RssFavoritesIntent.DeleteAll -> viewModelScope.launch(IO) { repository.deleteAll() }
+            is RssFavoritesIntent.DeleteStar -> viewModelScope.launch(IO) {
+                repository.delete(intent.star)
             }
         }
     }
 
-    fun updateGroup(rssStar: RssStar, group: String) {
-        viewModelScope.launch(IO) {
-            appDb.rssStarDao.update(rssStar.copy(group = group))
-        }
-    }
-
-    fun deleteGroup(group: String) {
-        viewModelScope.launch(IO) {
-            appDb.rssStarDao.deleteByGroup(group)
-        }
+    private fun toggleSelection(star: RssStar) {
+        val id = itemId(star)
+        selectedIds.update { if (id in it) it - id else it + id }
     }
 
-    fun deleteAll() {
+    private fun mutateSelected(block: suspend (RssStar) -> Unit) {
+        val selected = selectedIds.value
         viewModelScope.launch(IO) {
-            appDb.rssStarDao.deleteAll()
+            uiState.value.items.filter { itemId(it) in selected }.forEach { block(it) }
+            selectedIds.value = emptySet()
         }
     }
 
-    fun deleteStar(rssStar: RssStar) {
-        viewModelScope.launch(IO) {
-            appDb.rssStarDao.delete(rssStar.origin, rssStar.link)
-        }
-    }
+    private fun itemId(star: RssStar) = "${star.origin}|${star.link}"
+}
 
-    data class RssFavoritesUiState(
-        override val items: List<RssStar> = emptyList(),
-        override val selectedIds: Set<String> = emptySet(),
-        override val searchKey: String = "",
-        override val isSearch: Boolean = false,
-        override val isLoading: Boolean = false,
-        val currentGroup: String = ""
-    ) : ListUiState<RssStar>
+@Stable
+data class RssFavoritesUiState(
+    override val items: ImmutableList<RssStar> = persistentListOf(),
+    override val selectedIds: ImmutableSet<String> = persistentSetOf(),
+    override val searchKey: String = "",
+    override val isSearch: Boolean = false,
+    override val isLoading: Boolean = false,
+    val currentGroup: String = "",
+    val groups: ImmutableList<String> = persistentListOf(),
+) : ListUiState<RssStar>
+
+sealed interface RssFavoritesIntent {
+    data class ToggleSearch(val enabled: Boolean) : RssFavoritesIntent
+    data class Search(val query: String) : RssFavoritesIntent
+    data class ChangeGroup(val group: String) : RssFavoritesIntent
+    data class ToggleSelection(val star: RssStar) : RssFavoritesIntent
+    data object SelectAll : RssFavoritesIntent
+    data object InvertSelection : RssFavoritesIntent
+    data object ClearSelection : RssFavoritesIntent
+    data object DeleteSelected : RssFavoritesIntent
+    data class AddSelectedToGroup(val group: String) : RssFavoritesIntent
+    data class RemoveSelectedFromGroup(val group: String) : RssFavoritesIntent
+    data class UpdateGroup(val star: RssStar, val group: String) : RssFavoritesIntent
+    data class DeleteGroup(val group: String) : RssFavoritesIntent
+    data object DeleteAll : RssFavoritesIntent
+    data class DeleteStar(val star: RssStar) : RssFavoritesIntent
 }
+
+sealed interface RssFavoritesEffect

+ 66 - 43
app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt

@@ -23,6 +23,7 @@ import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -56,28 +57,75 @@ import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.toastOnUi
 import org.koin.androidx.compose.koinViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun RuleSubScreen(
+fun RuleSubRouteScreen(
     onBackClick: () -> Unit,
     viewModel: RuleSubViewModel = koinViewModel()
 ) {
     val context = LocalContext.current
-    val state by viewModel.state.collectAsState()
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is RuleSubEffect.ShowMessage -> context.toastOnUi(effect.message)
+                is RuleSubEffect.OpenRule -> {
+                    val ruleSub = effect.rule
+                    when (ruleSub.type) {
+                        RuleSubType.BOOK_SOURCE ->
+                            (context as? AppCompatActivity)?.showDialogFragment(
+                                ImportBookSourceDialog(ruleSub.url)
+                            )
+                        RuleSubType.RSS_SOURCE ->
+                            (context as? AppCompatActivity)?.showDialogFragment(
+                                ImportRssSourceDialog(ruleSub.url)
+                            )
+                        RuleSubType.REPLACE_RULE ->
+                            (context as? AppCompatActivity)?.showDialogFragment(
+                                ImportReplaceRuleDialog(ruleSub.url)
+                            )
+                        RuleSubType.AUTO -> {
+                            val encodedUrl = java.net.URLEncoder.encode(ruleSub.url, "UTF-8")
+                            context.startActivity(
+                                android.content.Intent(
+                                    android.content.Intent.ACTION_VIEW,
+                                    "legado://import/importonline?src=$encodedUrl".toUri(),
+                                )
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+    RuleSubScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun RuleSubScreen(
+    state: RuleSubUiState,
+    onIntent: (RuleSubIntent) -> Unit,
+    onBackClick: () -> Unit,
+) {
     val snackbarHostState = remember { SnackbarHostState() }
-    var showEditDialog by remember { mutableStateOf<RuleSub?>(null) }
 
     RuleListScaffold(
         title = stringResource(R.string.rule_subscription),
         state = state,
         onBackClick = onBackClick,
-        onSearchToggle = viewModel::onSearchToggle,
-        onSearchQueryChange = viewModel::onSearchQueryChange,
-        onClearSelection = viewModel::clearSelection,
-        onSelectAll = viewModel::selectAll,
-        onSelectInvert = viewModel::selectInvert,
-        onDeleteSelected = { viewModel.deleteSelected() },
+        onSearchToggle = { onIntent(RuleSubIntent.ToggleSearch(it)) },
+        onSearchQueryChange = { onIntent(RuleSubIntent.Search(it)) },
+        onClearSelection = { onIntent(RuleSubIntent.ClearSelection) },
+        onSelectAll = { onIntent(RuleSubIntent.SelectAll) },
+        onSelectInvert = { onIntent(RuleSubIntent.InvertSelection) },
+        onDeleteSelected = { onIntent(RuleSubIntent.DeleteSelected) },
         snackbarHostState = snackbarHostState,
         selectionSecondaryActions = emptyList(),
         topBarActions = {},
@@ -88,7 +136,7 @@ fun RuleSubScreen(
                     Icon(Icons.AutoMirrored.Filled.Sort, null, modifier = Modifier.size(18.dp))
                 },
                 onClick = {
-                    viewModel.resetOrder()
+                    onIntent(RuleSubIntent.ResetOrder)
                     dismiss()
                 }
             )
@@ -101,7 +149,7 @@ fun RuleSubScreen(
                         alignment = Alignment.BottomEnd,
                     ),
                 onClick = {
-                    showEditDialog = RuleSub(customOrder = state.items.size + 1)
+                    onIntent(RuleSubIntent.Add)
                 },
                 tooltipText = stringResource(R.string.add),
                 icon = Icons.Default.Add
@@ -139,35 +187,14 @@ fun RuleSubScreen(
                         inSelectionMode = state.selectedIds.isNotEmpty(),
                         onToggleSelection = {
                             if (state.selectedIds.isNotEmpty()) {
-                                viewModel.toggleSelection(ruleSub)
+                                onIntent(RuleSubIntent.ToggleSelection(ruleSub))
                             } else {
-                                when (ruleSub.type) {
-                                    RuleSubType.BOOK_SOURCE -> (context as? AppCompatActivity)?.showDialogFragment(
-                                        ImportBookSourceDialog(ruleSub.url)
-                                    )
-
-                                    RuleSubType.RSS_SOURCE -> (context as? AppCompatActivity)?.showDialogFragment(
-                                        ImportRssSourceDialog(ruleSub.url)
-                                    )
-
-                                    RuleSubType.REPLACE_RULE -> (context as? AppCompatActivity)?.showDialogFragment(
-                                        ImportReplaceRuleDialog(ruleSub.url)
-                                    )
-
-                                    RuleSubType.AUTO -> {
-                                        val encodedUrl = java.net.URLEncoder.encode(ruleSub.url, "UTF-8")
-                                        val intent = android.content.Intent(
-                                            android.content.Intent.ACTION_VIEW,
-                                            "legado://import/importonline?src=$encodedUrl".toUri()
-                                        )
-                                        context.startActivity(intent)
-                                    }
-                                }
+                                onIntent(RuleSubIntent.Open(ruleSub))
                             }
                         },
                         trailingAction = {
                             SmallPlainButton(
-                                onClick = { showEditDialog = ruleSub },
+                                onClick = { onIntent(RuleSubIntent.Edit(ruleSub)) },
                                 icon = Icons.Default.Edit,
                                 contentDescription = stringResource(R.string.edit)
                             )
@@ -176,7 +203,7 @@ fun RuleSubScreen(
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.delete),
                                 onClick = {
-                                    viewModel.delete(ruleSub)
+                                    onIntent(RuleSubIntent.Delete(ruleSub))
                                     dismiss()
                                 }
                             )
@@ -188,14 +215,10 @@ fun RuleSubScreen(
     }
 
     RuleSubEditDialog(
-        ruleSub = showEditDialog,
-        onDismiss = { showEditDialog = null },
+        ruleSub = state.editingRule,
+        onDismiss = { onIntent(RuleSubIntent.DismissEditor) },
         onConfirm = { updatedRuleSub ->
-            viewModel.save(
-                updatedRuleSub,
-                onSuccess = { showEditDialog = null },
-                onError = { context.toastOnUi(it) }
-            )
+            onIntent(RuleSubIntent.Save(updatedRuleSub))
         }
     )
 }

+ 116 - 104
app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubViewModel.kt

@@ -1,136 +1,148 @@
 package io.legado.app.ui.rss.subscription
 
 import android.app.Application
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
 import io.legado.app.data.entities.RuleSub
+import io.legado.app.data.repository.RuleSubscriptionRepository
 import io.legado.app.ui.widget.components.list.ListUiState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 
-class RuleSubViewModel(application: Application) : BaseViewModel(application) {
-
-    private val _searchKey = MutableStateFlow("")
-    private val _isSearch = MutableStateFlow(false)
-    private val _selectedIds = MutableStateFlow<Set<Long>>(emptySet())
-
-    val state: StateFlow<RuleSubUiState> = combine(
-        _searchKey,
-        _isSearch,
-        _selectedIds,
-        appDb.ruleSubDao.flowAll()
-    ) { searchKey, isSearch, selectedIds, items ->
-        val filteredItems = if (isSearch && searchKey.isNotBlank()) {
+class RuleSubViewModel(
+    application: Application,
+    private val repository: RuleSubscriptionRepository,
+) : ViewModel() {
+    private val app = application
+    private val searchKey = MutableStateFlow("")
+    private val isSearch = MutableStateFlow(false)
+    private val selectedIds = MutableStateFlow<Set<Long>>(emptySet())
+    private val editingRule = MutableStateFlow<RuleSub?>(null)
+    private val _effects = MutableSharedFlow<RuleSubEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    val uiState = combine(
+        searchKey,
+        isSearch,
+        selectedIds,
+        editingRule,
+        repository.observeAll(),
+    ) { query, searching, selected, editor, items ->
+        val filtered = if (searching && query.isNotBlank()) {
             items.filter {
-                it.name.contains(searchKey, ignoreCase = true) || it.url.contains(
-                    searchKey,
-                    ignoreCase = true
-                )
+                it.name.contains(query, ignoreCase = true) ||
+                    it.url.contains(query, ignoreCase = true)
             }
-        } else {
-            items
-        }
+        } else items
         RuleSubUiState(
-            items = filteredItems,
-            selectedIds = selectedIds,
-            searchKey = searchKey,
-            isSearch = isSearch
+            items = filtered.toImmutableList(),
+            selectedIds = selected.toImmutableSet(),
+            searchKey = query,
+            isSearch = searching,
+            editingRule = editor,
         )
-    }.flowOn(IO).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), RuleSubUiState())
-
-    fun onSearchToggle(isSearch: Boolean) {
-        _isSearch.value = isSearch
-        if (!isSearch) _searchKey.value = ""
-    }
-
-    fun onSearchQueryChange(query: String) {
-        _searchKey.value = query
-    }
-
-    fun toggleSelection(ruleSub: RuleSub) {
-        _selectedIds.update {
-            if (it.contains(ruleSub.id)) it - ruleSub.id else it + ruleSub.id
-        }
-    }
-
-    fun selectAll() {
-        _selectedIds.value = state.value.items.map { it.id }.toSet()
-    }
-
-    fun selectInvert() {
-        val allIds = state.value.items.map { it.id }.toSet()
-        _selectedIds.update { current ->
-            allIds - current
-        }
-    }
-
-    fun clearSelection() {
-        _selectedIds.value = emptySet()
-    }
-
-    fun deleteSelected() {
-        val selected = _selectedIds.value
-        viewModelScope.launch(IO) {
-            state.value.items.filter { selected.contains(it.id) }.forEach {
-                appDb.ruleSubDao.delete(it)
+    }.flowOn(IO).stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000),
+        RuleSubUiState(),
+    )
+
+    fun onIntent(intent: RuleSubIntent) {
+        when (intent) {
+            is RuleSubIntent.ToggleSearch -> {
+                isSearch.value = intent.enabled
+                if (!intent.enabled) searchKey.value = ""
             }
-            clearSelection()
-        }
-    }
-
-    fun delete(ruleSub: RuleSub) {
-        viewModelScope.launch(IO) {
-            appDb.ruleSubDao.delete(ruleSub)
-        }
-    }
-
-    fun save(ruleSub: RuleSub, onSuccess: () -> Unit, onError: (String) -> Unit) {
-        viewModelScope.launch {
-            val rs = withContext(IO) {
-                appDb.ruleSubDao.findByUrl(ruleSub.url)
+            is RuleSubIntent.Search -> searchKey.value = intent.query
+            is RuleSubIntent.ToggleSelection -> selectedIds.update {
+                if (intent.rule.id in it) it - intent.rule.id else it + intent.rule.id
             }
-            if (rs != null && rs.id != ruleSub.id) {
-                onError("${getApplication<Application>().getString(R.string.url_already)}(${rs.name})")
-                return@launch
+            RuleSubIntent.SelectAll -> selectedIds.value = uiState.value.items.map { it.id }.toSet()
+            RuleSubIntent.InvertSelection -> {
+                val all = uiState.value.items.map { it.id }.toSet()
+                selectedIds.update { all - it }
             }
-            withContext(IO) {
-                appDb.ruleSubDao.insert(ruleSub)
+            RuleSubIntent.ClearSelection -> selectedIds.value = emptySet()
+            RuleSubIntent.DeleteSelected -> viewModelScope.launch(IO) {
+                val selected = selectedIds.value
+                uiState.value.items.filter { it.id in selected }.forEach { repository.delete(it) }
+                selectedIds.value = emptySet()
             }
-            onSuccess()
-        }
-    }
-
-    fun updateOrder(vararg ruleSubs: RuleSub) {
-        viewModelScope.launch(IO) {
-            appDb.ruleSubDao.update(*ruleSubs)
+            is RuleSubIntent.Delete -> viewModelScope.launch(IO) { repository.delete(intent.rule) }
+            RuleSubIntent.ResetOrder -> viewModelScope.launch(IO) {
+                val rules = repository.all()
+                rules.forEachIndexed { index, rule -> rule.customOrder = index + 1 }
+                repository.update(*rules.toTypedArray())
+            }
+            is RuleSubIntent.Edit -> editingRule.value = intent.rule
+            RuleSubIntent.Add -> editingRule.value = RuleSub(customOrder = uiState.value.items.size + 1)
+            RuleSubIntent.DismissEditor -> editingRule.value = null
+            is RuleSubIntent.Save -> save(intent.rule)
+            is RuleSubIntent.Open -> _effects.tryEmit(RuleSubEffect.OpenRule(intent.rule))
         }
     }
 
-    fun resetOrder() {
-        viewModelScope.launch(IO) {
-            val sourceSubs = appDb.ruleSubDao.all
-            for ((index: Int, ruleSub: RuleSub) in sourceSubs.withIndex()) {
-                ruleSub.customOrder = index + 1
+    private fun save(rule: RuleSub) {
+        viewModelScope.launch {
+            val existing = kotlinx.coroutines.withContext(IO) { repository.findByUrl(rule.url) }
+            if (existing != null && existing.id != rule.id) {
+                _effects.tryEmit(
+                    RuleSubEffect.ShowMessage(
+                        "${app.getString(R.string.url_already)}(${existing.name})"
+                    )
+                )
+                return@launch
             }
-            appDb.ruleSubDao.update(*sourceSubs.toTypedArray())
+            kotlinx.coroutines.withContext(IO) { repository.insert(rule) }
+            editingRule.value = null
         }
     }
+}
+
+@Stable
+data class RuleSubUiState(
+    override val items: ImmutableList<RuleSub> = persistentListOf(),
+    override val selectedIds: ImmutableSet<Long> = persistentSetOf(),
+    override val searchKey: String = "",
+    override val isSearch: Boolean = false,
+    override val isLoading: Boolean = false,
+    val editingRule: RuleSub? = null,
+) : ListUiState<RuleSub>
+
+sealed interface RuleSubIntent {
+    data class ToggleSearch(val enabled: Boolean) : RuleSubIntent
+    data class Search(val query: String) : RuleSubIntent
+    data class ToggleSelection(val rule: RuleSub) : RuleSubIntent
+    data object SelectAll : RuleSubIntent
+    data object InvertSelection : RuleSubIntent
+    data object ClearSelection : RuleSubIntent
+    data object DeleteSelected : RuleSubIntent
+    data class Delete(val rule: RuleSub) : RuleSubIntent
+    data object ResetOrder : RuleSubIntent
+    data object Add : RuleSubIntent
+    data class Edit(val rule: RuleSub) : RuleSubIntent
+    data object DismissEditor : RuleSubIntent
+    data class Save(val rule: RuleSub) : RuleSubIntent
+    data class Open(val rule: RuleSub) : RuleSubIntent
+}
 
-    data class RuleSubUiState(
-        override val items: List<RuleSub> = emptyList(),
-        override val selectedIds: Set<Long> = emptySet(),
-        override val searchKey: String = "",
-        override val isSearch: Boolean = false,
-        override val isLoading: Boolean = false,
-    ) : ListUiState<RuleSub>
+sealed interface RuleSubEffect {
+    data class OpenRule(val rule: RuleSub) : RuleSubEffect
+    data class ShowMessage(val message: String) : RuleSubEffect
 }