Просмотр исходного кода

refactor: 完成剩余 Compose 路由的 UDF 收口

统一目录、全部书签和订阅源管理的 Route/Screen 边界。

将书架、书籍详情、阅读、导入和规则页面的一次性通知收口为 SharedFlow Effect,并修复搜索内容仓库的 JVM 测试初始化。
5151561 3 месяцев назад
Родитель
Сommit
af9c5b13bf
35 измененных файлов с 688 добавлено и 280 удалено
  1. 11 8
      app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt
  2. 2 2
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt
  3. 57 28
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
  4. 45 18
      app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt
  5. 2 0
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
  6. 13 9
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  7. 3 4
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookViewModel.kt
  8. 2 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  9. 2 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  10. 30 25
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  11. 18 11
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 1 1
      app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt
  13. 73 38
      app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
  14. 119 24
      app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt
  15. 10 0
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
  16. 3 1
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleContract.kt
  17. 13 4
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt
  18. 8 3
      app/src/main/java/io/legado/app/ui/config/ai/summary/AiSummaryConfigViewModel.kt
  19. 9 4
      app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt
  20. 3 1
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleContract.kt
  21. 10 0
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
  22. 6 3
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt
  23. 3 1
      app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleContract.kt
  24. 14 3
      app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleScreen.kt
  25. 6 3
      app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleViewModel.kt
  26. 2 2
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  27. 18 13
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  28. 2 0
      app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt
  29. 3 2
      app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt
  30. 1 0
      app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditContract.kt
  31. 4 0
      app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt
  32. 2 5
      app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt
  33. 2 2
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt
  34. 79 53
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt
  35. 112 12
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt

+ 11 - 8
app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt

@@ -20,30 +20,33 @@ import kotlinx.coroutines.flow.flowOn
 
 class SearchContentRepository(
     private val titleModeProvider: () -> Int = { 0 },
-    private val historyDao: SearchContentHistoryDao = appDb.searchContentHistoryDao,
+    private val historyDao: SearchContentHistoryDao? = null,
 ) {
 
+    private val effectiveHistoryDao: SearchContentHistoryDao
+        get() = historyDao ?: appDb.searchContentHistoryDao
+
     fun observeHistory(book: Book?, onlyThisBook: Boolean): Flow<List<SearchContentHistory>> =
         if (onlyThisBook && book != null) {
-            historyDao.getByBook(book.name, book.author)
+            effectiveHistoryDao.getByBook(book.name, book.author)
         } else {
-            historyDao.getAll()
+            effectiveHistoryDao.getAll()
         }
 
     suspend fun saveHistory(book: Book, query: String) {
-        val history = historyDao.get(book.name, book.author, query)
+        val history = effectiveHistoryDao.get(book.name, book.author, query)
             ?: SearchContentHistory(bookName = book.name, bookAuthor = book.author, query = query)
         history.time = System.currentTimeMillis()
-        historyDao.insert(history)
+        effectiveHistoryDao.insert(history)
     }
 
-    suspend fun deleteHistory(id: Long) = historyDao.delete(id)
+    suspend fun deleteHistory(id: Long) = effectiveHistoryDao.delete(id)
 
     suspend fun clearHistory(book: Book?, onlyThisBook: Boolean) {
         if (onlyThisBook && book != null) {
-            historyDao.deleteByBook(book.name, book.author)
+            effectiveHistoryDao.deleteByBook(book.name, book.author)
         } else {
-            historyDao.deleteAll()
+            effectiveHistoryDao.deleteAll()
         }
     }
 

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkActivity.kt

@@ -12,7 +12,7 @@ class AllBookmarkActivity : BaseComposeActivity() {
     @Composable
     override fun Content() {
         MaterialTheme {
-            AllBookmarkScreen(
+            AllBookmarkRouteScreen(
                 onBack = { finish() }
             )
         }
@@ -21,4 +21,4 @@ class AllBookmarkActivity : BaseComposeActivity() {
         super.onCreate(savedInstanceState)
 
     }
-}
+}

+ 57 - 28
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt

@@ -34,7 +34,7 @@ import androidx.compose.material3.ListItem
 import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -75,6 +75,8 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -83,21 +85,58 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
     ExperimentalMaterial3ExpressiveApi::class
 )
 @Composable
-fun AllBookmarkScreen(
+fun AllBookmarkRouteScreen(
     viewModel: AllBookmarkViewModel = koinViewModel(),
     onBack: () -> Unit,
 ) {
     val context = LocalContext.current
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    var pendingExportIsMd by remember { mutableStateOf(false) }
+    val exportLauncher = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocumentTree()
+    ) { uri: Uri? ->
+        uri?.let {
+            viewModel.onIntent(AllBookmarkIntent.Export(it, pendingExportIsMd))
+            Toast.makeText(context, context.getString(R.string.export_started), Toast.LENGTH_SHORT).show()
+        }
+    }
+
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is AllBookmarkEffect.ShowMessage ->
+                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+            }
+        }
+    }
+
+    AllBookmarkScreen(
+        state = uiState,
+        onIntent = viewModel::onIntent,
+        onRequestExport = { isMarkdown ->
+            pendingExportIsMd = isMarkdown
+            exportLauncher.launch(null)
+        },
+        onBack = onBack,
+    )
+}
+
+@Composable
+fun AllBookmarkScreen(
+    state: BookmarkUiState,
+    onIntent: (AllBookmarkIntent) -> Unit,
+    onRequestExport: (Boolean) -> Unit,
+    onBack: () -> Unit,
+) {
 
-    val uiState by viewModel.uiState.collectAsState()
     val contentState = when {
-        uiState.isLoading -> "LOADING"
-        uiState.bookmarks.isEmpty() -> "EMPTY"
+        state.isLoading -> "LOADING"
+        state.bookmarks.isEmpty() -> "EMPTY"
         else -> "CONTENT"
     }
-    val searchText = uiState.searchQuery
-    val collapsedGroups = uiState.collapsedGroups
-    val bookmarksGrouped = uiState.bookmarks
+    val searchText = state.searchQuery
+    val collapsedGroups = state.collapsedGroups
+    val bookmarksGrouped = state.bookmarks
     val bookmarkGroups = remember(bookmarksGrouped) { bookmarksGrouped.entries.toList() }
     val allKeys = bookmarksGrouped.keys
     val isAllCollapsed =
@@ -108,7 +147,6 @@ fun AllBookmarkScreen(
     var showSearch by remember { mutableStateOf(false) }
     var editingBookmark by remember { mutableStateOf<Bookmark?>(null) }
     var showBottomSheet by remember { mutableStateOf(false) }
-    var pendingExportIsMd by remember { mutableStateOf(false) }
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
     val stickyGroup by remember(bookmarkGroups, collapsedGroups, listState) {
@@ -126,15 +164,6 @@ fun AllBookmarkScreen(
         }
     }
 
-    val exportLauncher = rememberLauncherForActivityResult(
-        contract = ActivityResultContracts.OpenDocumentTree()
-    ) { uri: Uri? ->
-        uri?.let {
-            viewModel.exportBookmark(it, pendingExportIsMd)
-            Toast.makeText(context, context.getString(R.string.export_started), Toast.LENGTH_SHORT).show()
-        }
-    }
-
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         topBar = {
@@ -148,7 +177,7 @@ fun AllBookmarkScreen(
                     actions = {
                         if (bookmarksGrouped.isNotEmpty()) {
                             TopBarActionButton(
-                                onClick = { viewModel.toggleAllCollapse(allKeys) },
+                                onClick = { onIntent(AllBookmarkIntent.ToggleAllCollapse(allKeys)) },
                                 imageVector = if (isAllCollapsed) Icons.Default.UnfoldMore else Icons.Default.UnfoldLess,
                                 contentDescription = stringResource(
                                     if (isAllCollapsed) {
@@ -163,7 +192,7 @@ fun AllBookmarkScreen(
                             onClick = {
                                 showSearch = !showSearch
                                 if (!showSearch) {
-                                    viewModel.onSearchQueryChanged("")
+                                    onIntent(AllBookmarkIntent.SetSearchQuery(""))
                                 }
                             },
                             imageVector = Icons.Default.Search,
@@ -182,16 +211,14 @@ fun AllBookmarkScreen(
                                 text = stringResource(R.string.export_bookmarks_json),
                                 onClick = {
                                     showMenu = false
-                                    pendingExportIsMd = false
-                                    exportLauncher.launch(null)
+                                    onRequestExport(false)
                                 }
                             )
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.export_bookmarks_markdown),
                                 onClick = {
                                     showMenu = false
-                                    pendingExportIsMd = true
-                                    exportLauncher.launch(null)
+                                    onRequestExport(true)
                                 }
                             )
                         }
@@ -206,7 +233,7 @@ fun AllBookmarkScreen(
                 ) {
                     SearchBar(
                         query = searchText,
-                        onQueryChange = { viewModel.onSearchQueryChanged(it) },
+                        onQueryChange = { onIntent(AllBookmarkIntent.SetSearchQuery(it)) },
                         placeholder = stringResource(R.string.search),
                         scrollState = listState,
                         scope = scope
@@ -279,7 +306,9 @@ fun AllBookmarkScreen(
                                             title = headerKey.bookName,
                                             subtitle = headerKey.bookAuthor,
                                             isCollapsed = isCollapsed,
-                                            onToggle = { viewModel.toggleGroupCollapse(headerKey) },
+                                            onToggle = {
+                                                onIntent(AllBookmarkIntent.ToggleGroupCollapse(headerKey))
+                                            },
                                             isMiuix = isMiuix
                                         )
 
@@ -350,11 +379,11 @@ fun AllBookmarkScreen(
                 editingBookmark = null
             },
             onSave = { updatedBookmark ->
-                viewModel.updateBookmark(updatedBookmark)
+                onIntent(AllBookmarkIntent.UpdateBookmark(updatedBookmark))
                 showBottomSheet = false
             },
             onDelete = { bookmarkToDelete ->
-                viewModel.deleteBookmark(bookmarkToDelete)
+                onIntent(AllBookmarkIntent.DeleteBookmark(bookmarkToDelete))
                 showBottomSheet = false
             }
         )

+ 45 - 18
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -2,7 +2,7 @@ package io.legado.app.ui.book.bookmark
 
 import android.app.Application
 import android.net.Uri
-import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.data.dao.BookmarkDao
@@ -11,19 +11,27 @@ import io.legado.app.utils.FileDoc
 import io.legado.app.utils.GSON
 import io.legado.app.utils.createFileIfNotExist
 import io.legado.app.utils.openOutputStream
-import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.writeToOutputStream
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
+import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import java.text.SimpleDateFormat
 import java.util.Date
 import java.util.Locale
@@ -35,7 +43,7 @@ data class BookmarkGroupHeader(
     override fun toString(): String = "$bookName|$bookAuthor"
 }
 
-@Immutable
+@Stable
 data class BookmarkItemUi(
     val id: Long,
     val content: String,
@@ -46,15 +54,28 @@ data class BookmarkItemUi(
     val rawBookmark: Bookmark
 )
 
-@Immutable
+@Stable
 data class BookmarkUiState(
     val isLoading: Boolean = false,
-    val bookmarks: Map<BookmarkGroupHeader, List<BookmarkItemUi>> = emptyMap(),
+    val bookmarks: ImmutableMap<BookmarkGroupHeader, ImmutableList<BookmarkItemUi>> = persistentMapOf(),
     val error: Throwable? = null,
     val searchQuery: String = "",
-    val collapsedGroups: Set<String> = emptySet()
+    val collapsedGroups: ImmutableSet<String> = persistentSetOf()
 )
 
+sealed interface AllBookmarkIntent {
+    data class SetSearchQuery(val query: String) : AllBookmarkIntent
+    data class ToggleGroupCollapse(val group: BookmarkGroupHeader) : AllBookmarkIntent
+    data class ToggleAllCollapse(val groups: Set<BookmarkGroupHeader>) : AllBookmarkIntent
+    data class UpdateBookmark(val bookmark: Bookmark) : AllBookmarkIntent
+    data class DeleteBookmark(val bookmark: Bookmark) : AllBookmarkIntent
+    data class Export(val treeUri: Uri, val isMarkdown: Boolean) : AllBookmarkIntent
+}
+
+sealed interface AllBookmarkEffect {
+    data class ShowMessage(val message: String) : AllBookmarkEffect
+}
+
 
 class AllBookmarkViewModel(
     application: Application,
@@ -63,6 +84,8 @@ class AllBookmarkViewModel(
 
     private val _searchQuery = MutableStateFlow("")
     private val _collapsedGroups = MutableStateFlow<Set<String>>(emptySet())
+    private val _effects = MutableSharedFlow<AllBookmarkEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     @OptIn(ExperimentalCoroutinesApi::class)
     val uiState: StateFlow<BookmarkUiState> = combine(
@@ -96,12 +119,14 @@ class AllBookmarkViewModel(
             .groupBy { item ->
                 BookmarkGroupHeader(item.bookName, item.bookAuthor)
             }
+            .mapValues { (_, items) -> items.toImmutableList() }
+            .toImmutableMap()
 
         BookmarkUiState(
             isLoading = false,
             bookmarks = grouped,
             searchQuery = query,
-            collapsedGroups = collapsed
+            collapsedGroups = collapsed.toImmutableSet()
         )
     }.catch { e ->
         emit(BookmarkUiState(isLoading = false, error = e))
@@ -111,8 +136,15 @@ class AllBookmarkViewModel(
         initialValue = BookmarkUiState(isLoading = true)
     )
 
-    fun onSearchQueryChanged(query: String) {
-        _searchQuery.value = query
+    fun onIntent(intent: AllBookmarkIntent) {
+        when (intent) {
+            is AllBookmarkIntent.SetSearchQuery -> _searchQuery.value = intent.query
+            is AllBookmarkIntent.ToggleGroupCollapse -> toggleGroupCollapse(intent.group)
+            is AllBookmarkIntent.ToggleAllCollapse -> toggleAllCollapse(intent.groups)
+            is AllBookmarkIntent.UpdateBookmark -> updateBookmark(intent.bookmark)
+            is AllBookmarkIntent.DeleteBookmark -> deleteBookmark(intent.bookmark)
+            is AllBookmarkIntent.Export -> exportBookmark(intent.treeUri, intent.isMarkdown)
+        }
     }
 
     fun toggleGroupCollapse(groupKey: BookmarkGroupHeader) {
@@ -148,7 +180,6 @@ class AllBookmarkViewModel(
     fun exportBookmark(treeUri: Uri, isMarkdown: Boolean) {
         viewModelScope.launch(Dispatchers.IO) {
             try {
-                val context = getApplication<Application>()
                 val dateFormat = SimpleDateFormat("yyMMddHHmmss", Locale.getDefault())
                 val suffix = if (isMarkdown) "md" else "json"
                 val fileName = "bookmark-${dateFormat.format(Date())}.$suffix"
@@ -165,14 +196,10 @@ class AllBookmarkViewModel(
                     }
                 }
 
-                withContext(Dispatchers.Main) {
-                    context.toastOnUi("导出成功: $fileName")
-                }
+                _effects.emit(AllBookmarkEffect.ShowMessage("导出成功: $fileName"))
             } catch (e: Exception) {
                 e.printStackTrace()
-                withContext(Dispatchers.Main) {
-                    getApplication<Application>().toastOnUi("导出失败: ${e.message}")
-                }
+                _effects.emit(AllBookmarkEffect.ShowMessage("导出失败: ${e.message}"))
             }
         }
     }
@@ -194,4 +221,4 @@ class AllBookmarkViewModel(
         }
         outputStream.write(sb.toString().toByteArray())
     }
-}
+}

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

@@ -303,6 +303,8 @@ fun ImportBookRouteScreen(
                 is ImportBookEffect.ShowToastRes -> {
                     context.toastOnUi(effect.resId)
                 }
+
+                is ImportBookEffect.ShowToast -> context.toastOnUi(effect.message)
             }
         }
     }

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

@@ -30,7 +30,6 @@ import io.legado.app.utils.isUri
 import io.legado.app.utils.list
 import io.legado.app.utils.mapParallel
 import io.legado.app.utils.takePersistablePermissionSafely
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
@@ -107,6 +106,7 @@ sealed interface ImportBookEffect {
     data class ShowArchiveEntries(val fileDoc: FileDoc, val fileNames: List<String>) : ImportBookEffect
     data class ShowImportArchiveDialog(val fileDoc: FileDoc, val fileName: String) : ImportBookEffect
     data class ShowToastRes(val resId: Int) : ImportBookEffect
+    data class ShowToast(val message: String) : ImportBookEffect
 }
 
 class ImportBookViewModel(application: Application) : BaseViewModel(application) {
@@ -547,10 +547,10 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         execute {
             LocalBook.importFiles(selectedBooks.map { it.file.uri })
         }.onError {
-            context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架失败,请尝试重新选择文件夹"))
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
         }.onSuccess {
-            context.toastOnUi("添加书架成功")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架成功"))
         }.onFinally {
             clearSelection()
         }
@@ -562,10 +562,10 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         execute {
             LocalBook.importFiles(uris)
         }.onError {
-            context.toastOnUi("添加书架失败,请重新选择书籍文件")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架失败,请重新选择书籍文件"))
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
         }.onSuccess {
-            context.toastOnUi("添加书架成功")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架成功"))
         }
     }
 
@@ -574,10 +574,10 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         execute {
             LocalBook.importFiles(listOf(item.file.uri))
         }.onError {
-            context.toastOnUi("添加书架失败,请尝试重新选择文件夹")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架失败,请尝试重新选择文件夹"))
             AppLog.put("添加书架失败\n${it.localizedMessage}", it)
         }.onSuccess {
-            context.toastOnUi("添加书架成功")
+            _effects.tryEmit(ImportBookEffect.ShowToast("添加书架成功"))
         }.onFinally {
             _state.update { state ->
                 state.copy(selectedIds = state.selectedIds - item.selectionId)
@@ -683,7 +683,9 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 }
             }.onFailure {
                 withContext(Main) {
-                    context.toastOnUi("扫描文件夹出错\n${it.localizedMessage}")
+                    _effects.tryEmit(
+                        ImportBookEffect.ShowToast("扫描文件夹出错\n${it.localizedMessage}")
+                    )
                 }
                 _state.update { state ->
                     state.copy(interaction = state.interaction.copy(isLoading = false))
@@ -722,7 +724,9 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
                 )
             }
         }.onError {
-            context.toastOnUi("获取文件列表出错\n${it.localizedMessage}")
+            _effects.tryEmit(
+                ImportBookEffect.ShowToast("获取文件列表出错\n${it.localizedMessage}")
+            )
             _state.update { state ->
                 state.copy(interaction = state.interaction.copy(isLoading = false))
             }

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

@@ -30,7 +30,6 @@ import io.legado.app.utils.FileDoc
 import io.legado.app.utils.find
 import io.legado.app.utils.isUri
 import io.legado.app.utils.takePersistablePermissionSafely
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -242,7 +241,7 @@ class RemoteBookViewModel(
                     onSuccess()
                 }
             } catch (e: Exception) {
-                context.toastOnUi("初始化webDav出错:${e.localizedMessage}")
+                _effects.tryEmit(RemoteBookEffect.ShowToast("初始化webDav出错:${e.localizedMessage}"))
                 _state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) }
             }
         }
@@ -260,7 +259,7 @@ class RemoteBookViewModel(
                 _state.update { it.copy(remoteBooks = bookList) }
             } catch (e: Exception) {
                 AppLog.put("获取webDav书籍出错\n${e.localizedMessage}", e)
-                context.toastOnUi("获取webDav书籍出错\n${e.localizedMessage}")
+                _effects.tryEmit(RemoteBookEffect.ShowToast("获取webDav书籍出错\n${e.localizedMessage}"))
             } finally {
                 _state.update { it.copy(interaction = it.interaction.copy(isLoading = false)) }
             }
@@ -292,7 +291,7 @@ class RemoteBookViewModel(
             Result.failure(e)
         } catch (e: Exception) {
             AppLog.put("导入出错\n${e.localizedMessage}", e)
-            context.toastOnUi("导入出错\n${e.localizedMessage}")
+            _effects.tryEmit(RemoteBookEffect.ShowToast("导入出错\n${e.localizedMessage}"))
             Result.failure(e)
         } finally {
             _state.update { it.copy(interaction = it.interaction.copy(isUploading = false)) }

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -178,6 +178,8 @@ sealed interface BookInfoIntent {
 }
 
 sealed interface BookInfoEffect {
+    data class ShowMessage(val message: String) : BookInfoEffect
+
     data class Finish(
         val resultCode: Int? = null,
         val afterTransition: Boolean = false,

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -36,6 +36,7 @@ import io.legado.app.utils.openFileUri
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.collections.immutable.persistentListOf
 import io.legado.app.data.entities.BookGroup
@@ -126,6 +127,7 @@ fun BookInfoRouteScreen(
     LaunchedEffect(viewModel, activity) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
+                is BookInfoEffect.ShowMessage -> context.toastOnUi(effect.message)
                 is BookInfoEffect.Finish -> {
                     onFinish(effect.resultCode, effect.afterTransition)
                 }

+ 30 - 25
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -65,7 +65,6 @@ import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
@@ -269,7 +268,7 @@ class BookInfoViewModel(
             }
             upBook(book, source)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "未找到书籍")
+            showMessage(it.localizedMessage ?: "未找到书籍")
             emitEffect(BookInfoEffect.Finish(afterTransition = true))
         }
     }
@@ -328,7 +327,7 @@ class BookInfoViewModel(
 
             is BookInfoIntent.AddSourceAsNewBook -> {
                 addToBookshelf(intent.book, intent.toc) {
-                    context.toastOnUi("已添加到书架")
+                    showMessage("已添加到书架")
                 }
             }
 
@@ -481,7 +480,7 @@ class BookInfoViewModel(
             syncUiState(isTocLoading = true)
             loadBookInfo(book, canReName = false)
             if (!book.getSplitLongChapter()) {
-                context.toastOnUi(context.getString(R.string.need_more_time_load_content))
+                showMessage(R.string.need_more_time_load_content)
             }
         }
     }
@@ -505,7 +504,7 @@ class BookInfoViewModel(
         }.onSuccess {
             emitEffect(it)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "书源不存在")
+            showMessage(it.localizedMessage ?: "书源不存在")
         }
     }
 
@@ -526,7 +525,7 @@ class BookInfoViewModel(
         }.onSuccess {
             emitEffect(it)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "书源不存在")
+            showMessage(it.localizedMessage ?: "书源不存在")
         }
     }
 
@@ -571,11 +570,11 @@ class BookInfoViewModel(
             inBookshelf = true
             syncUiState(isTocLoading = true)
             loadChapter(newBook)
-            context.toastOnUi("同步完成")
+            showMessage("同步完成")
         }.onFinally {
             setBusy(false)
         }.onError {
-            context.toastOnUi(it.localizedMessage)
+            showMessage(it.localizedMessage ?: "同步失败")
         }
     }
 
@@ -590,7 +589,7 @@ class BookInfoViewModel(
         }.onFinally {
             setBusy(false)
         }.onError {
-            context.toastOnUi(it.localizedMessage)
+            showMessage(it.localizedMessage ?: "操作失败")
         }
     }
 
@@ -605,9 +604,9 @@ class BookInfoViewModel(
                     ReadManga.clearMangaChapter()
                 }
             }.onSuccess {
-                context.toastOnUi(R.string.clear_cache_success)
+                showMessage(R.string.clear_cache_success)
             }.onError {
-                context.toastOnUi("清理缓存出错\n${it.localizedMessage}")
+                showMessage("清理缓存出错\n${it.localizedMessage}")
             }
         }
     }
@@ -636,14 +635,14 @@ class BookInfoViewModel(
             }
         }.onSuccess { success ->
             if (success) {
-                context.toastOnUi("保存成功")
+                showMessage("保存成功")
             } else {
-                context.toastOnUi("保存失败")
+                showMessage("保存失败")
             }
         }.onFinally {
             setBusy(false)
         }.onError {
-            context.toastOnUi("保存出错: ${it.localizedMessage}")
+            showMessage("保存出错: ${it.localizedMessage}")
         }
     }
 
@@ -744,7 +743,7 @@ class BookInfoViewModel(
             success?.invoke()
         }.onError {
             AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
+            showMessage("添加书籍失败")
         }
     }
 
@@ -806,7 +805,7 @@ class BookInfoViewModel(
             val source = bookSource ?: run {
                 currentChapterList = emptyList()
                 syncUiState(isTocLoading = false)
-                context.toastOnUi(R.string.error_no_source)
+                showMessage(R.string.error_no_source)
                 return
             }
             WebBook.getBookInfo(scope, source, book, canReName = canReName)
@@ -832,7 +831,7 @@ class BookInfoViewModel(
                     scheduleRelatedBooksLoad(loadedBook, source)
                 }.onError {
                     AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
-                    context.toastOnUi(R.string.error_get_book_info)
+                    showMessage(R.string.error_get_book_info)
                     syncUiState(isTocLoading = false)
                 }
         }
@@ -981,7 +980,7 @@ class BookInfoViewModel(
             val source = bookSource ?: run {
                 currentChapterList = emptyList()
                 syncUiState(isTocLoading = false)
-                context.toastOnUi(R.string.error_no_source)
+                showMessage(R.string.error_no_source)
                 return
             }
             val oldBook = book.copy()
@@ -1026,7 +1025,7 @@ class BookInfoViewModel(
             syncUiState(isTocLoading = false)
         }.onError {
             currentWebFiles = emptyList()
-            context.toastOnUi("LoadWebFileError\n${it.localizedMessage}")
+            showMessage("LoadWebFileError\n${it.localizedMessage}")
             syncUiState(isTocLoading = false)
         }
     }
@@ -1054,7 +1053,7 @@ class BookInfoViewModel(
     private fun onTocClick() {
         val book = currentBook ?: return
         if (currentChapterList.isEmpty()) {
-            context.toastOnUi(R.string.chapter_list_empty)
+            showMessage(R.string.chapter_list_empty)
             return
         }
         if (!inBookshelf) {
@@ -1192,7 +1191,7 @@ class BookInfoViewModel(
             }
 
             BookInfoMenuAction.Upload -> uploadBook {
-                context.toastOnUi("上传成功")
+                showMessage("上传成功")
             }
             BookInfoMenuAction.SyncRemote -> syncFromRemote()
             BookInfoMenuAction.Refresh -> refreshCurrentBook()
@@ -1266,7 +1265,7 @@ class BookInfoViewModel(
         val book = currentBook ?: return
         if (book.isLocal) return
         if (!appDb.bookSourceDao.has(book.origin)) {
-            context.toastOnUi(R.string.error_no_source)
+            showMessage(R.string.error_no_source)
             return
         }
         emitEffect(BookInfoEffect.OpenBookSourceEdit(book.origin))
@@ -1279,7 +1278,7 @@ class BookInfoViewModel(
             }
         }.onError {
             AppLog.put("getArchiveEntriesName Error:\n${it.localizedMessage}", it)
-            context.toastOnUi("getArchiveEntriesName Error:\n${it.localizedMessage}")
+            showMessage("getArchiveEntriesName Error:\n${it.localizedMessage}")
         }.onSuccess {
             onSuccess.invoke(it)
         }
@@ -1303,7 +1302,7 @@ class BookInfoViewModel(
             success?.invoke(book)
         }.onError {
             AppLog.put("importArchiveBook Error:\n${it.localizedMessage}", it)
-            context.toastOnUi("importArchiveBook Error:\n${it.localizedMessage}")
+            showMessage("importArchiveBook Error:\n${it.localizedMessage}")
         }
     }
 
@@ -1334,7 +1333,7 @@ class BookInfoViewModel(
                 is NoBooksDirException -> emitEffect(BookInfoEffect.OpenSelectBooksDir)
                 else -> {
                     AppLog.put("ImportWebFileError\n${it.localizedMessage}", it)
-                    context.toastOnUi("ImportWebFileError\n${it.localizedMessage}")
+                    showMessage("ImportWebFileError\n${it.localizedMessage}")
                 }
             }
         }.onFinally {
@@ -1546,6 +1545,12 @@ class BookInfoViewModel(
         return WebBook.exploreBookWithResolvedUrl(source, url, 1, book)
     }
 
+    private fun showMessage(resId: Int) = showMessage(context.getString(resId))
+
+    private fun showMessage(message: String) {
+        emitEffect(BookInfoEffect.ShowMessage(message))
+    }
+
     private fun emitEffect(effect: BookInfoEffect) {
         _effects.tryEmit(effect)
     }

+ 18 - 11
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -113,7 +113,6 @@ import io.legado.app.utils.openUrl
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.toStringArray
-import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.coroutines.CancellationException
@@ -637,7 +636,11 @@ class ReadBookViewModel(
 
             is ReadBookIntent.MenuCoverProgress -> {
                 ReadBook.book?.let {
-                    ReadBook.uploadProgress(true) { context.toastOnUi(R.string.upload_book_success) }
+                    ReadBook.uploadProgress(true) {
+                        _effects.tryEmit(
+                            ReadBookEffect.ShowToast(context.getString(R.string.upload_book_success))
+                        )
+                    }
                 }
             }
 
@@ -651,7 +654,7 @@ class ReadBookViewModel(
                             textChapter.chapter.getFileName("nr")
                         )
                     ) {
-                        context.toastOnUi("未找到可移除的重复标题")
+                        _effects.tryEmit(ReadBookEffect.ShowToast("未找到可移除的重复标题"))
                     }
                 }
                 reverseRemoveSameTitle()
@@ -2620,7 +2623,7 @@ class ReadBookViewModel(
                 ReadBook.upMsg(null)
             }.catch {
                 AppLog.put("自动换源失败\n${it.localizedMessage}", it)
-                context.toastOnUi("自动换源失败\n${it.localizedMessage}")
+                _effects.tryEmit(ReadBookEffect.ShowToast("自动换源失败\n${it.localizedMessage}"))
             }.collect()
         }
     }
@@ -2682,7 +2685,7 @@ class ReadBookViewModel(
             _effects.tryEmit(ReadBookEffect.Finish)
         }.onError {
             AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
+            _effects.tryEmit(ReadBookEffect.ShowToast("添加书籍失败"))
         }
     }
 
@@ -4344,9 +4347,9 @@ class ReadBookViewModel(
             )
             if (!success) throw NoStackTraceException("保存到相册失败")
         }.onError {
-            context.toastOnUi("保存图片失败: ${it.localizedMessage}")
+            _effects.tryEmit(ReadBookEffect.ShowToast("保存图片失败: ${it.localizedMessage}"))
         }.onSuccess {
-            context.toastOnUi("已保存到相册")
+            _effects.tryEmit(ReadBookEffect.ShowToast("已保存到相册"))
         }
     }
 
@@ -5881,8 +5884,12 @@ class ReadBookViewModel(
             localPreferencesRepository.updatePreference(
                 LocalPreferencesKeys.READ_URL_IN_BROWSER, newValue
             )
-            context.toastOnUi(
-                if (newValue) R.string.open_by_browser else R.string.open_by_webview
+            _effects.tryEmit(
+                ReadBookEffect.ShowToast(
+                    context.getString(
+                        if (newValue) R.string.open_by_browser else R.string.open_by_webview
+                    )
+                )
             )
         }
     }
@@ -5892,7 +5899,7 @@ class ReadBookViewModel(
         if (book.isLocal) return
         val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
         if (chapter == null) {
-            context.toastOnUi(R.string.no_chapter)
+            _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.no_chapter)))
             return
         }
         _uiState.update { it.copy(activeDialog = ReadBookDialog.ConfirmChapterPay(chapter.title)) }
@@ -5998,7 +6005,7 @@ class ReadBookViewModel(
             success?.invoke()
         }.onError {
             AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
+            _effects.tryEmit(ReadBookEffect.ShowToast("添加书籍失败"))
         }
     }
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt

@@ -17,7 +17,7 @@ class TocActivity : BaseComposeActivity() {
     override fun Content() {
         val context = LocalContext.current
 
-        TocScreen(
+        TocRouteScreen(
             onBackClick = { finish() },
             onChapterClick = { index ->
                 val data = Intent().apply {

+ 73 - 38
app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.toc
 import android.app.Activity
 import android.content.Intent
 import android.net.Uri
+import android.widget.Toast
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
@@ -85,6 +86,7 @@ import androidx.compose.ui.unit.sp
 import androidx.compose.ui.zIndex
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
+import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.help.book.isLocal
 import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
@@ -118,6 +120,7 @@ import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 import java.text.SimpleDateFormat
@@ -126,18 +129,48 @@ import java.util.Locale
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun TocScreen(
+fun TocRouteScreen(
     viewModel: TocViewModel = koinViewModel(),
     onBackClick: () -> Unit,
     onChapterClick: (Int) -> Unit,
     onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
     onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
 ) {
+    val state by viewModel.screenState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is TocEffect.ShowMessage ->
+                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+            }
+        }
+    }
+    TocScreen(
+        uiState = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+        onChapterClick = onChapterClick,
+        onOpenReplaceRule = onOpenReplaceRule,
+        onBookmarkClick = onBookmarkClick,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun TocScreen(
+    uiState: TocUiState,
+    onIntent: (TocIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onChapterClick: (Int) -> Unit,
+    onOpenReplaceRule: (ReplaceEditRoute?) -> Unit,
+    onBookmarkClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
+) {
 
     val context = LocalContext.current
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-    val book by viewModel.bookState.collectAsStateWithLifecycle()
-    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val book = uiState.book
+    val state = uiState.action
 
     val pagerState = rememberPagerState { 2 }
     val scope = rememberCoroutineScope()
@@ -207,7 +240,7 @@ fun TocScreen(
     }
 
     val isOnTocPage = pagerState.currentPage == 0
-    val collapsedVolumes by viewModel.collapsedVolumes.collectAsStateWithLifecycle()
+    val collapsedVolumes = uiState.collapsedVolumes
     val stickyVolume by remember(state.items, collapsedVolumes, isOnTocPage, listState) {
         derivedStateOf {
             if (!isOnTocPage || state.items.isEmpty()) return@derivedStateOf null
@@ -251,7 +284,7 @@ fun TocScreen(
                 scope.launch { listState.animateScrollToItem(state.items.size) }
             },
             FabMenuItem(Icons.Default.DownloadForOffline, downloadAllText) {
-                viewModel.downloadAll()
+                onIntent(TocIntent.DownloadAll)
             }
         )
     }
@@ -266,17 +299,17 @@ fun TocScreen(
             ActionItem(
                 text = invertSelectionText,
                 icon = Icons.Default.Refresh,
-                onClick = { viewModel.invertSelection() }
+                onClick = { onIntent(TocIntent.InvertSelection) }
             ),
             ActionItem(
                 text = selectFollowingText,
                 icon = Icons.Default.ExpandMore,
-                onClick = { viewModel.selectFromLast() }
+                onClick = { onIntent(TocIntent.SelectFromLast) }
             ),
             ActionItem(
                 text = addBookmarkText,
                 icon = Icons.Default.BookmarkAdd,
-                onClick = { viewModel.addBookmarksForSelected() }
+                onClick = { onIntent(TocIntent.AddBookmarksForSelected) }
             )
         )
     }
@@ -286,7 +319,7 @@ fun TocScreen(
     ) { uri: Uri? ->
         uri?.let {
             val isActuallyMd = it.toString().endsWith(".md", ignoreCase = true)
-            viewModel.exportCurrentBookBookmarks(it, isActuallyMd)
+            onIntent(TocIntent.ExportBookmarks(it, isActuallyMd))
         }
     }
 
@@ -295,7 +328,7 @@ fun TocScreen(
     ) { result ->
         if (result.resultCode == Activity.RESULT_OK) {
             val newRegex = result.data?.getStringExtra("tocRegex")
-            viewModel.saveTocRegex(newRegex ?: "")
+            onIntent(TocIntent.SaveTocRegex(newRegex ?: ""))
         }
     }
 
@@ -348,7 +381,7 @@ fun TocScreen(
     }
 
     BackHandler(enabled = isSelectionMode) {
-        viewModel.clearSelection()
+        onIntent(TocIntent.ClearSelection)
     }
 
     AppScaffold(
@@ -360,10 +393,10 @@ fun TocScreen(
                 state = state,
                 scrollBehavior = scrollBehavior,
                 onBackClick = onBackClick,
-                onSearchToggle = { viewModel.setSearchMode(it) },
-                onSearchQueryChange = { viewModel.setSearchKey(it) },
+                onSearchToggle = { onIntent(TocIntent.SetSearchMode(it)) },
+                onSearchQueryChange = { onIntent(TocIntent.SetSearchQuery(it)) },
                 searchPlaceholder = stringResource(R.string.search_chapters),
-                onClearSelection = { viewModel.clearSelection() },
+                onClearSelection = { onIntent(TocIntent.ClearSelection) },
                 dropDownMenuContent = { dismiss ->
                     when (pagerState.currentPage) {
                         0 -> {
@@ -372,7 +405,7 @@ fun TocScreen(
                                 isSelected = useReplace,
                                 onClick = {
                                     dismiss()
-                                    viewModel.toggleUseReplace()
+                                    onIntent(TocIntent.ToggleUseReplace)
                                 }
                             )
                             RoundDropdownMenuItem(
@@ -380,14 +413,14 @@ fun TocScreen(
                                 isSelected = showWordCount,
                                 onClick = {
                                     dismiss()
-                                    viewModel.toggleShowWordCount()
+                                    onIntent(TocIntent.ToggleShowWordCount)
                                 }
                             )
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.reverse_toc),
                                 onClick = {
                                     dismiss()
-                                    viewModel.reverseToc()
+                                    onIntent(TocIntent.ReverseToc)
                                 }
                             )
                             PillDivider()
@@ -431,9 +464,9 @@ fun TocScreen(
                                 )
                                 RoundDropdownMenuItem(
                                     text = stringResource(R.string.split_long_chapters),
-                                    isSelected = viewModel.isSplitLongChapter,
+                                    isSelected = uiState.isSplitLongChapter,
                                     onClick = {
-                                        viewModel.toggleSplitLongChapter()
+                                        onIntent(TocIntent.ToggleSplitLongChapter)
                                         dismiss()
                                     }
                                 )
@@ -507,13 +540,13 @@ fun TocScreen(
                                     RoundDropdownMenuItem(
                                         text = stringResource(R.string.expand_volume),
                                         onClick = {
-                                            viewModel.expandAllVolumes(); showVolumeMenu = false
+                                            onIntent(TocIntent.ExpandAllVolumes); showVolumeMenu = false
                                         }
                                     )
                                     RoundDropdownMenuItem(
                                         text = stringResource(R.string.coll_volume),
                                         onClick = {
-                                            viewModel.collapseAllVolumes(); showVolumeMenu = false
+                                            onIntent(TocIntent.CollapseAllVolumes); showVolumeMenu = false
                                         }
                                     )
 
@@ -570,15 +603,15 @@ fun TocScreen(
                 exit = slideOutVertically { it } + fadeOut()
             ) {
                 SelectionBottomBar(
-                    onSelectAll = { viewModel.selectAll() },
-                    onSelectInvert = { viewModel.invertSelection() },
+                    onSelectAll = { onIntent(TocIntent.SelectAll) },
+                    onSelectInvert = { onIntent(TocIntent.InvertSelection) },
                     primaryAction = ActionItem(
                         text = stringResource(
                             R.string.download_selected_count,
                             state.selectedIds.size
                         ),
                         icon = Icons.Default.Download,
-                        onClick = { viewModel.downloadSelected() }
+                        onClick = { onIntent(TocIntent.DownloadSelected) }
                     ),
                     secondaryActions = selectionSecondaryActions
                 )
@@ -587,7 +620,9 @@ fun TocScreen(
             HorizontalPager(state = pagerState) { page ->
                 when (page) {
                     0 -> ChapterListContent(
-                        viewModel = viewModel,
+                        state = state,
+                        collapsedVolumes = collapsedVolumes,
+                        onIntent = onIntent,
                         listState = listState,
                         onChapterClick = onChapterClick,
                         contentPadding = adaptiveContentPaddingOnlyVertical(
@@ -597,7 +632,8 @@ fun TocScreen(
                     )
 
                     1 -> BookmarkListContent(
-                        viewModel = viewModel,
+                        bookmarks = uiState.bookmarks,
+                        book = book,
                         onBookmarkLongClick = onBookmarkClick,
                         onBookmarkClick = { bookmark ->
                             editingBookmark = bookmark
@@ -660,11 +696,11 @@ fun TocScreen(
             bookmark = bookmarkForSheet,
             onDismiss = { editingBookmark = null },
             onSave = { updatedBookmark ->
-                viewModel.updateBookmark(updatedBookmark)
+                onIntent(TocIntent.UpdateBookmark(updatedBookmark))
                 editingBookmark = null
             },
             onDelete = { bookmarkToDelete ->
-                viewModel.deleteBookmark(bookmarkToDelete)
+                onIntent(TocIntent.DeleteBookmark(bookmarkToDelete))
                 editingBookmark = null
             }
         )
@@ -674,13 +710,13 @@ fun TocScreen(
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 fun ChapterListContent(
-    viewModel: TocViewModel,
+    state: TocActionState,
+    collapsedVolumes: Set<Int>,
+    onIntent: (TocIntent) -> Unit,
     listState: LazyListState,
     onChapterClick: (Int) -> Unit,
     contentPadding: PaddingValues
 ) {
-    val state by viewModel.uiState.collectAsStateWithLifecycle()
-    val collapsedVolumes by viewModel.collapsedVolumes.collectAsStateWithLifecycle()
 
     FastScrollLazyColumn(
         state = listState,
@@ -697,7 +733,7 @@ fun ChapterListContent(
                         modifier = Modifier.animateItem(),
                         title = uiItem.title,
                         isCollapsed = collapsedVolumes.contains(uiItem.id),
-                        onToggle = { viewModel.toggleVolume(uiItem.id) }
+                        onToggle = { onIntent(TocIntent.ToggleVolume(uiItem.id)) }
                     )
                 }
 
@@ -712,15 +748,15 @@ fun ChapterListContent(
                         showWordCount = state.showWordCount,
                         onClick = {
                             if (state.selectedIds.isNotEmpty())
-                                viewModel.toggleSelection(uiItem.id)
+                                onIntent(TocIntent.ToggleSelection(uiItem.id))
                             else
                                 onChapterClick(uiItem.id)
                         },
                         onLongClick = {
-                            viewModel.toggleSelection(uiItem.id)
+                            onIntent(TocIntent.ToggleSelection(uiItem.id))
                         },
                         onDownloadClick = {
-                            viewModel.downloadChapter(uiItem.id)
+                            onIntent(TocIntent.DownloadChapter(uiItem.id))
                         }
                     )
                 }
@@ -895,13 +931,12 @@ fun ChapterItem(
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
 fun BookmarkListContent(
-    viewModel: TocViewModel,
+    bookmarks: List<TocBookmarkItemUi>,
+    book: Book?,
     onBookmarkLongClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
     onBookmarkClick: (Bookmark) -> Unit,
     contentPadding: PaddingValues
 ) {
-    val bookmarks by viewModel.bookmarkUiList.collectAsStateWithLifecycle()
-    val book by viewModel.bookState.collectAsStateWithLifecycle()
     val listState = rememberLazyListState()
 
     LaunchedEffect(bookmarks, book?.durChapterIndex) {

+ 119 - 24
app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.toc
 import android.app.Application
 import android.net.Uri
 import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
 
 import androidx.lifecycle.SavedStateHandle
 import androidx.lifecycle.viewModelScope
@@ -27,16 +28,23 @@ import io.legado.app.model.localBook.LocalBook
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.list.SelectableItem
-import io.legado.app.utils.toastOnUi
+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
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.filter
@@ -77,9 +85,10 @@ data class TocBookmarkItemUi(
     val raw: Bookmark
 )
 
+@Stable
 data class TocActionState(
-    override val items: List<TocItemUi> = emptyList(),
-    override val selectedIds: Set<Int> = emptySet(),
+    override val items: ImmutableList<TocItemUi> = persistentListOf(),
+    override val selectedIds: ImmutableSet<Int> = persistentSetOf(),
     override val searchKey: String = "",
     override val isSearch: Boolean = false,
     override val isLoading: Boolean = false,
@@ -89,6 +98,44 @@ data class TocActionState(
     val titleReplaceProgress: Float? = null,
 ) : ListUiState<TocItemUi>
 
+@Stable
+data class TocUiState(
+    val action: TocActionState = TocActionState(),
+    val book: Book? = null,
+    val collapsedVolumes: ImmutableSet<Int> = persistentSetOf(),
+    val bookmarks: ImmutableList<TocBookmarkItemUi> = persistentListOf(),
+    val isSplitLongChapter: Boolean = false,
+)
+
+sealed interface TocIntent {
+    data class SetSearchMode(val enabled: Boolean) : TocIntent
+    data class SetSearchQuery(val query: String) : TocIntent
+    data class ToggleVolume(val id: Int) : TocIntent
+    data class ToggleSelection(val id: Int) : TocIntent
+    data class SaveTocRegex(val regex: String) : TocIntent
+    data class ExportBookmarks(val uri: Uri, val isMarkdown: Boolean) : TocIntent
+    data class UpdateBookmark(val bookmark: Bookmark) : TocIntent
+    data class DeleteBookmark(val bookmark: Bookmark) : TocIntent
+    data class DownloadChapter(val id: Int) : TocIntent
+    data object DownloadAll : TocIntent
+    data object DownloadSelected : TocIntent
+    data object SelectAll : TocIntent
+    data object InvertSelection : TocIntent
+    data object ClearSelection : TocIntent
+    data object SelectFromLast : TocIntent
+    data object AddBookmarksForSelected : TocIntent
+    data object ToggleUseReplace : TocIntent
+    data object ToggleShowWordCount : TocIntent
+    data object ReverseToc : TocIntent
+    data object ToggleSplitLongChapter : TocIntent
+    data object ExpandAllVolumes : TocIntent
+    data object CollapseAllVolumes : TocIntent
+}
+
+sealed interface TocEffect {
+    data class ShowMessage(val message: String) : TocEffect
+}
+
 data class TocDomainItem(
     val chapter: BookChapter,
     val displayTitle: String,
@@ -176,6 +223,8 @@ class TocViewModel(
 
     private val _collapsedVolumes = MutableStateFlow<Set<Int>>(emptySet())
     val collapsedVolumes = _collapsedVolumes.asStateFlow()
+    private val _effects = MutableSharedFlow<TocEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     val downloadSummary: StateFlow<String> =
         CacheBook.downloadSummaryFlow
@@ -244,6 +293,25 @@ class TocViewModel(
                 initialValue = emptyList()
             )
 
+    val screenState: StateFlow<TocUiState> = combine(
+        uiState,
+        bookState,
+        collapsedVolumes,
+        bookmarkUiList,
+    ) { action, book, collapsed, bookmarks ->
+        TocUiState(
+            action = action,
+            book = book,
+            collapsedVolumes = collapsed.toImmutableSet(),
+            bookmarks = bookmarks.toImmutableList(),
+            isSplitLongChapter = book?.getSplitLongChapter() ?: false,
+        )
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5_000),
+        initialValue = TocUiState(),
+    )
+
     private val reverseFlow =
         bookState.map { it?.getReverseToc() ?: false }
             .distinctUntilChanged()
@@ -381,8 +449,8 @@ class TocViewModel(
         }
 
         return TocActionState(
-            items = updatedItems,
-            selectedIds = selectedIds,
+            items = updatedItems.toImmutableList(),
+            selectedIds = selectedIds.toImmutableSet(),
             searchKey = _searchKey.value,
             isSearch = isSearch,
             isLoading = isUploading,
@@ -426,6 +494,33 @@ class TocViewModel(
     override suspend fun findOldRule(newRule: TocDomainItem) = null
     override fun saveImportedRules() {}
 
+    fun onIntent(intent: TocIntent) {
+        when (intent) {
+            is TocIntent.SetSearchMode -> setSearchMode(intent.enabled)
+            is TocIntent.SetSearchQuery -> setSearchKey(intent.query)
+            is TocIntent.ToggleVolume -> toggleVolume(intent.id)
+            is TocIntent.ToggleSelection -> toggleSelection(intent.id)
+            is TocIntent.SaveTocRegex -> saveTocRegex(intent.regex)
+            is TocIntent.ExportBookmarks -> exportCurrentBookBookmarks(intent.uri, intent.isMarkdown)
+            is TocIntent.UpdateBookmark -> updateBookmark(intent.bookmark)
+            is TocIntent.DeleteBookmark -> deleteBookmark(intent.bookmark)
+            is TocIntent.DownloadChapter -> downloadChapter(intent.id)
+            TocIntent.DownloadAll -> downloadAll()
+            TocIntent.DownloadSelected -> downloadSelected()
+            TocIntent.SelectAll -> selectAll()
+            TocIntent.InvertSelection -> invertSelection()
+            TocIntent.ClearSelection -> clearSelection()
+            TocIntent.SelectFromLast -> selectFromLast()
+            TocIntent.AddBookmarksForSelected -> addBookmarksForSelected()
+            TocIntent.ToggleUseReplace -> toggleUseReplace()
+            TocIntent.ToggleShowWordCount -> toggleShowWordCount()
+            TocIntent.ReverseToc -> reverseToc()
+            TocIntent.ToggleSplitLongChapter -> toggleSplitLongChapter()
+            TocIntent.ExpandAllVolumes -> expandAllVolumes()
+            TocIntent.CollapseAllVolumes -> collapseAllVolumes()
+        }
+    }
+
     fun reverseToc() = execute {
         val currentBook = bookState.value ?: return@execute
         val currentConfig = currentBook.readConfig ?: Book.ReadConfig()
@@ -491,12 +586,10 @@ class TocViewModel(
         book.tocUrl = newRegex
         upBookTocRule(book) { error ->
             if (error != null) {
-                context.toastOnUi(
-                    context.getString(R.string.toc_rule_update_failed, error.localizedMessage)
-                )
+                showMessage(context.getString(R.string.toc_rule_update_failed, error.localizedMessage))
             }
             else {
-                context.toastOnUi(R.string.toc_rule_updated)
+                showMessage(R.string.toc_rule_updated)
                 if (ReadBook.book?.bookUrl == book.bookUrl) ReadBook.upMsg(null)
             }
         }
@@ -508,9 +601,9 @@ class TocViewModel(
         book.setSplitLongChapter(newState)
         upBookTocRule(book) { error ->
             if (error != null) {
-                context.toastOnUi(context.getString(R.string.setting_failed, error.localizedMessage))
+                showMessage(context.getString(R.string.setting_failed, error.localizedMessage))
             } else {
-                context.toastOnUi(
+                showMessage(
                     if (newState) R.string.split_long_chapters_enabled
                     else R.string.split_long_chapters_disabled
                 )
@@ -543,16 +636,16 @@ class TocViewModel(
             val book = bookState.value ?: return@launch
             val bookmarks = appDb.bookmarkDao.getByBook(book.name, book.author)
             if (bookmarks.isEmpty()) {
-                context.toastOnUi(R.string.no_bookmarks_to_export)
+                showMessage(R.string.no_bookmarks_to_export)
                 return@launch
             }
             BookmarkExporter.exportToUri(
                 context = getApplication(), fileUri = fileUri, bookmarks = bookmarks,
                 isMd = isMd, bookName = book.name, author = book.author
             )
-            context.toastOnUi(R.string.save_success)
+            showMessage(R.string.save_success)
         } catch (e: Exception) {
-            context.toastOnUi(context.getString(R.string.save_failed_with_error, e.message))
+            showMessage(context.getString(R.string.save_failed_with_error, e.message))
         }
     }
 
@@ -571,7 +664,7 @@ class TocViewModel(
             .toList()
 
         if (selectedItems.isEmpty()) {
-            context.toastOnUi(R.string.select_chapters)
+            showMessage(R.string.select_chapters)
             return@launch
         }
 
@@ -588,7 +681,7 @@ class TocViewModel(
         }
 
         appDb.bookmarkDao.insert(*bookmarks.toTypedArray())
-        context.toastOnUi(context.getString(R.string.bookmarks_added_count, bookmarks.size))
+        showMessage(context.getString(R.string.bookmarks_added_count, bookmarks.size))
         withContext(Dispatchers.Main) {
             clearSelection()
         }
@@ -601,9 +694,7 @@ class TocViewModel(
         execute {
             cacheBookChaptersUseCase.execute(book.bookUrl, indices)
         }.onSuccess { count ->
-            getApplication<Application>().toastOnUi(
-                context.getString(R.string.start_downloading_chapters, count)
-            )
+            showMessage(context.getString(R.string.start_downloading_chapters, count))
             clearSelection()
         }
     }
@@ -613,7 +704,7 @@ class TocViewModel(
         execute {
             cacheBookChaptersUseCase.execute(book.bookUrl, listOf(index))
         }.onSuccess {
-            getApplication<Application>().toastOnUi(R.string.start_downloading_chapter)
+            showMessage(R.string.start_downloading_chapter)
         }
     }
 
@@ -624,19 +715,23 @@ class TocViewModel(
             .map { it.id }
 
         if (targetIndices.isEmpty()) {
-            getApplication<Application>().toastOnUi(R.string.all_chapters_cached)
+            showMessage(R.string.all_chapters_cached)
             return
         }
 
         execute {
             cacheBookChaptersUseCase.execute(book.bookUrl, targetIndices)
         }.onSuccess { count ->
-            getApplication<Application>().toastOnUi(
-                context.getString(R.string.start_downloading_remaining_chapters, count)
-            )
+            showMessage(context.getString(R.string.start_downloading_remaining_chapters, count))
         }
     }
 
+    private fun showMessage(resId: Int) = showMessage(context.getString(resId))
+
+    private fun showMessage(message: String) {
+        _effects.tryEmit(TocEffect.ShowMessage(message))
+    }
+
     private fun List<BookChapter>.groupAndReverseVolumes(): List<BookChapter> {
         return this.fold(mutableListOf<MutableList<BookChapter>>()) { acc, chapter ->
             if (chapter.isVolume || acc.isEmpty()) acc.add(mutableListOf(chapter))

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt

@@ -73,6 +73,7 @@ fun TxtRuleRouteScreen(
         state = uiState,
         importState = importState,
         events = viewModel.events,
+        effects = viewModel.effects,
         onIntent = viewModel::onIntent,
         onPasteRule = viewModel::pasteRule,
         initialRule = initialRule,
@@ -87,6 +88,7 @@ fun TxtRuleScreen(
     state: TxtTocRuleUiState,
     importState: BaseImportUiState<TxtTocRule>,
     events: Flow<BaseRuleEvent>,
+    effects: Flow<TxtTocRuleEffect>,
     onIntent: (TxtTocRuleIntent) -> Unit,
     onPasteRule: () -> TxtTocRule?,
     initialRule: String? = null,
@@ -144,6 +146,14 @@ fun TxtRuleScreen(
         }
     }
 
+    LaunchedEffect(effects) {
+        effects.collect { effect ->
+            when (effect) {
+                is TxtTocRuleEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+
     val importDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocument(),
         onResult = { uri ->

+ 3 - 1
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleContract.kt

@@ -55,7 +55,9 @@ sealed interface TxtTocRuleIntent {
     data object ImportBuiltInRules : TxtTocRuleIntent
 }
 
-sealed interface TxtTocRuleEffect
+sealed interface TxtTocRuleEffect {
+    data class ShowMessage(val message: String) : TxtTocRuleEffect
+}
 
 data class TxtTocRuleRenderState(
     val uiState: TxtTocRuleUiState,

+ 13 - 4
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt

@@ -17,9 +17,10 @@ import io.legado.app.utils.getClipText
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.launch
 
 class TxtTocRuleViewModel(
@@ -31,6 +32,8 @@ class TxtTocRuleViewModel(
     uploadRepository
 ) {
     private val repository = TxtTocRuleRepository()
+    private val _effects = MutableSharedFlow<TxtTocRuleEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     override val rawDataFlow: Flow<List<TxtTocRule>> = repository.flowAll()
 
@@ -183,20 +186,26 @@ class TxtTocRuleViewModel(
     private fun importBuiltInRules() {
         viewModelScope.launch(Dispatchers.IO) {
             DefaultData.importDefaultTocRules()
-            context.toastOnUi(R.string.import_built_in_rules)
+            _effects.emit(
+                TxtTocRuleEffect.ShowMessage(context.getString(R.string.import_built_in_rules))
+            )
         }
     }
 
     fun pasteRule(): TxtTocRule? {
         val text = context.getClipText()
         if (text.isNullOrBlank()) {
-            context.toastOnUi(R.string.clipboard_empty)
+            _effects.tryEmit(
+                TxtTocRuleEffect.ShowMessage(context.getString(R.string.clipboard_empty))
+            )
             return null
         }
         return try {
             GSON.fromJsonObject<TxtTocRule>(text).getOrThrow()
         } catch (e: Exception) {
-            context.toastOnUi(R.string.invalid_format)
+            _effects.tryEmit(
+                TxtTocRuleEffect.ShowMessage(context.getString(R.string.invalid_format))
+            )
             null
         }
     }

+ 8 - 3
app/src/main/java/io/legado/app/ui/config/ai/summary/AiSummaryConfigViewModel.kt

@@ -7,7 +7,6 @@ import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.model.AiPromptTemplate
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.TranslationConstants
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -51,7 +50,11 @@ class AiSummaryConfigViewModel(
                         initialized = true
                     )
                 }
-                appCtx.toastOnUi("加载章节摘要配置失败: ${error.localizedMessage ?: "使用默认参数"}")
+                _effects.tryEmit(
+                    AiSummaryConfigEffect.ShowMessage(
+                        "加载章节摘要配置失败: ${error.localizedMessage ?: "使用默认参数"}"
+                    )
+                )
             }
         }
     }
@@ -105,7 +108,9 @@ class AiSummaryConfigViewModel(
                     )
                 }
             }.onSuccess {
-                appCtx.toastOnUi(R.string.ai_config_saved_success)
+                _effects.tryEmit(
+                    AiSummaryConfigEffect.ShowMessage(appCtx.getString(R.string.ai_config_saved_success))
+                )
                 _effects.tryEmit(AiSummaryConfigEffect.NavigateBack)
             }.onFailure { error ->
                 _effects.tryEmit(

+ 9 - 4
app/src/main/java/io/legado/app/ui/config/otherConfig/OtherConfigViewModel.kt

@@ -22,7 +22,6 @@ import io.legado.app.receiver.SharedReceiverActivity
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfig
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.restart
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.NonCancellable
@@ -269,7 +268,9 @@ class OtherConfigViewModel(
                 )
                 updateOtherSetting(OtherSettingsUpdate.ProcessText(enable))
             }.onFailure {
-                appCtx.toastOnUi(it.localizedMessage)
+                _effects.tryEmit(
+                    OtherConfigEffect.SettingsUpdateFailed(it.localizedMessage ?: "设置失败")
+                )
             }
         }
     }
@@ -283,14 +284,18 @@ class OtherConfigViewModel(
                     WebViewDataCleaner.clear(appCtx)
                 }.onSuccess {
                     restartScheduled = true
-                    appCtx.toastOnUi(R.string.clear_webview_data_success)
+                    _effects.tryEmit(
+                        OtherConfigEffect.ShowMessage(R.string.clear_webview_data_success)
+                    )
                     mainHandler.postDelayed(
                         { appCtx.restart() },
                         RESTART_DELAY_MILLIS
                     )
                 }.onFailure {
                     AppLog.put("清除 WebView 数据失败", it)
-                    appCtx.toastOnUi(R.string.clear_webview_data_failed)
+                    _effects.tryEmit(
+                        OtherConfigEffect.ShowMessage(R.string.clear_webview_data_failed)
+                    )
                 }
             }
         }

+ 3 - 1
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleContract.kt

@@ -63,7 +63,9 @@ sealed interface DictRuleIntent {
     data object SaveImportedRules : DictRuleIntent
 }
 
-sealed interface DictRuleEffect
+sealed interface DictRuleEffect {
+    data class ShowMessage(val message: String) : DictRuleEffect
+}
 
 data class DictRuleRenderState(
     val uiState: DictRuleUiState,

+ 10 - 0
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt

@@ -66,6 +66,7 @@ fun DictRuleRouteScreen(
         state = uiState,
         importState = importState,
         events = viewModel.events,
+        effects = viewModel.effects,
         onIntent = viewModel::onIntent,
         onPasteRule = viewModel::pasteRule,
         onBackClick = onBackClick,
@@ -78,6 +79,7 @@ fun DictRuleScreen(
     state: DictRuleUiState,
     importState: BaseImportUiState<DictRule>,
     events: Flow<BaseRuleEvent>,
+    effects: Flow<DictRuleEffect>,
     onIntent: (DictRuleIntent) -> Unit,
     onPasteRule: () -> DictRule?,
     onBackClick: () -> Unit,
@@ -133,6 +135,14 @@ fun DictRuleScreen(
         }
     }
 
+    LaunchedEffect(effects) {
+        effects.collect { effect ->
+            when (effect) {
+                is DictRuleEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+
     val importDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocument(),
         onResult = { uri ->

+ 6 - 3
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt

@@ -15,11 +15,12 @@ import io.legado.app.utils.getClipText
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -33,6 +34,8 @@ class DictRuleViewModel(
     uploadRepository
 ) {
     private val repository = DictRuleRepository()
+    private val _effects = MutableSharedFlow<DictRuleEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     override val rawDataFlow: Flow<List<DictRule>> = repository.flowAll()
 
@@ -203,13 +206,13 @@ class DictRuleViewModel(
     fun pasteRule(): DictRule? {
         val text = context.getClipText()
         if (text.isNullOrBlank()) {
-            context.toastOnUi("剪贴板没有内容")
+            _effects.tryEmit(DictRuleEffect.ShowMessage("剪贴板没有内容"))
             return null
         }
         return try {
             GSON.fromJsonObject<DictRule>(text).getOrThrow()
         } catch (e: Exception) {
-            context.toastOnUi("格式不对")
+            _effects.tryEmit(DictRuleEffect.ShowMessage("格式不对"))
             null
         }
     }

+ 3 - 1
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleContract.kt

@@ -58,7 +58,9 @@ sealed interface HighlightTagRuleIntent {
     data object SaveImportedRules : HighlightTagRuleIntent
 }
 
-sealed interface HighlightTagRuleEffect
+sealed interface HighlightTagRuleEffect {
+    data class ShowMessage(val message: String) : HighlightTagRuleEffect
+}
 
 data class HighlightTagRuleRenderState(
     val uiState: HighlightTagRuleUiState,

+ 14 - 3
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleScreen.kt

@@ -53,17 +53,18 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun HighlightTagRuleScreen(
+fun HighlightTagRuleRouteScreen(
     viewModel: HighlightTagRuleViewModel = koinViewModel(),
     onBackClick: () -> Unit
 ) {
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val importState by viewModel.importState.collectAsStateWithLifecycle()
 
-    HighlightTagRuleContent(
+    HighlightTagRuleScreen(
         state = uiState,
         importState = importState,
         events = viewModel.events,
+        effects = viewModel.effects,
         onIntent = viewModel::onIntent,
         onPasteRule = viewModel::pasteRule,
         onBackClick = onBackClick,
@@ -72,10 +73,11 @@ fun HighlightTagRuleScreen(
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-private fun HighlightTagRuleContent(
+fun HighlightTagRuleScreen(
     state: HighlightTagRuleUiState,
     importState: BaseImportUiState<HighlightTagRule>,
     events: Flow<BaseRuleEvent>,
+    effects: Flow<HighlightTagRuleEffect>,
     onIntent: (HighlightTagRuleIntent) -> Unit,
     onPasteRule: () -> HighlightTagRule?,
     onBackClick: () -> Unit,
@@ -131,6 +133,15 @@ private fun HighlightTagRuleContent(
         }
     }
 
+    LaunchedEffect(effects) {
+        effects.collect { effect ->
+            when (effect) {
+                is HighlightTagRuleEffect.ShowMessage ->
+                    snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+
     val importDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocument(),
         onResult = { uri ->

+ 6 - 3
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleViewModel.kt

@@ -15,11 +15,12 @@ import io.legado.app.utils.getClipText
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -33,6 +34,8 @@ class HighlightTagRuleViewModel(
     uploadRepository
 ) {
     private val repository = HighlightTagRuleRepository()
+    private val _effects = MutableSharedFlow<HighlightTagRuleEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
 
     override val rawDataFlow: Flow<List<HighlightTagRule>> = repository.flowAll()
 
@@ -206,13 +209,13 @@ class HighlightTagRuleViewModel(
     fun pasteRule(): HighlightTagRule? {
         val text = context.getClipText()
         if (text.isNullOrBlank()) {
-            context.toastOnUi("剪贴板没有内容")
+            _effects.tryEmit(HighlightTagRuleEffect.ShowMessage("剪贴板没有内容"))
             return null
         }
         return try {
             GSON.fromJsonObject<HighlightTagRule>(text).getOrThrow()
         } catch (e: Exception) {
-            context.toastOnUi("格式不对")
+            _effects.tryEmit(HighlightTagRuleEffect.ShowMessage("格式不对"))
             null
         }
     }

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

@@ -63,7 +63,7 @@ import io.legado.app.ui.config.readConfig.ReadConfigRouteScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigRouteScreen
 import io.legado.app.ui.config.themeManage.ThemeManageRouteScreen
 import io.legado.app.ui.config.translation.TranslationConfigRouteScreen
-import io.legado.app.ui.highlightTagRule.HighlightTagRuleScreen
+import io.legado.app.ui.highlightTagRule.HighlightTagRuleRouteScreen
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
 import io.legado.app.ui.rss.favorites.RssFavoritesRouteScreen
@@ -725,7 +725,7 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteHighlightTagRule> {
-        HighlightTagRuleScreen(
+        HighlightTagRuleRouteScreen(
             onBackClick = { onNavigateBack() }
         )
     }

+ 18 - 13
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -38,7 +38,6 @@ import io.legado.app.utils.eventBus.FlowEventBus
 import io.legado.app.utils.move
 import io.legado.app.utils.onEachParallel
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.GSON
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableMap
@@ -779,7 +778,7 @@ class BookshelfViewModel(
         execute {
             updateBooksGroupUseCase.replaceGroup(bookUrls, groupId)
         }.onError {
-            context.toastOnUi("更新分组失败\n${it.localizedMessage}")
+            showMessage("更新分组失败\n${it.localizedMessage}")
         }
     }
 
@@ -797,7 +796,7 @@ class BookshelfViewModel(
                 appDb.bookDao.update(*updates.toTypedArray())
             }
         }.onError {
-            context.toastOnUi("排序保存失败\n${it.localizedMessage}")
+            showMessage("排序保存失败\n${it.localizedMessage}")
         }
     }
 
@@ -811,12 +810,12 @@ class BookshelfViewModel(
             )
         }.onSuccess { count ->
             if (count > 0) {
-                context.toastOnUi("已加入缓存队列: $count 本")
+                showMessage("已加入缓存队列: $count 本")
             } else {
-                context.toastOnUi(R.string.no_download)
+                showMessage(R.string.no_download)
             }
         }.onError {
-            context.toastOnUi("批量缓存失败\n${it.localizedMessage}")
+            showMessage("批量缓存失败\n${it.localizedMessage}")
         }
     }
 
@@ -1049,9 +1048,9 @@ class BookshelfViewModel(
                 loadingTextFlow.value = "添加中... ($it)"
             }
             if (successCount > 0) {
-                context.toastOnUi(R.string.success)
+                showMessage(R.string.success)
             } else {
-                context.toastOnUi("添加网址失败")
+                showMessage("添加网址失败")
             }
         }.onError {
             AppLog.put("添加网址出错\n${it.localizedMessage}", it, true)
@@ -1102,7 +1101,7 @@ class BookshelfViewModel(
         }.onSuccess {
             success(it)
         }.onError {
-            context.toastOnUi("导出书籍出错\n${it.localizedMessage}")
+            showMessage("导出书籍出错\n${it.localizedMessage}")
         }
     }
 
@@ -1112,9 +1111,9 @@ class BookshelfViewModel(
                 loadingTextFlow.value = it
             }.getOrThrow()
         }.onSuccess {
-            context.toastOnUi(R.string.success)
+            showMessage(R.string.success)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "ERROR")
+            showMessage(it.localizedMessage ?: "ERROR")
         }.onFinally {
             loadingTextFlow.value = null
         }
@@ -1126,9 +1125,9 @@ class BookshelfViewModel(
                 loadingTextFlow.value = it
             }.getOrThrow()
         }.onSuccess {
-            context.toastOnUi(R.string.success)
+            showMessage(R.string.success)
         }.onError {
-            context.toastOnUi(it.localizedMessage ?: "ERROR")
+            showMessage(it.localizedMessage ?: "ERROR")
         }.onFinally {
             loadingTextFlow.value = null
         }
@@ -1142,4 +1141,10 @@ class BookshelfViewModel(
                 customTag?.contains(searchKey, true) == true
     }
 
+    private fun showMessage(resId: Int) = showMessage(context.getString(resId))
+
+    private fun showMessage(message: String) {
+        _effects.tryEmit(BookshelfEffect.ShowSnackbar(message))
+    }
+
 }

+ 2 - 0
app/src/main/java/io/legado/app/ui/main/rss/RssScreen.kt

@@ -69,6 +69,7 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.startActivity
+import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
 
@@ -98,6 +99,7 @@ fun RssRouteScreen(
     LaunchedEffect(viewModel) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
+                is RssEffect.ShowMessage -> currentContext.toastOnUi(effect.message)
                 is RssEffect.OpenSort -> {
                     currentOnOpenSort(effect.sourceUrl, effect.sortUrl, effect.key)
                 }

+ 3 - 2
app/src/main/java/io/legado/app/ui/main/rss/RssViewModel.kt

@@ -6,7 +6,6 @@ import com.script.rhino.runScriptWithContext
 import io.legado.app.base.BaseViewModel
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.repository.RssRepository
-import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -188,7 +187,7 @@ class RssViewModel(
                     _effects.tryEmit(RssEffect.OpenExternalUrl(url))
                 }
             }.onError {
-                context.toastOnUi(it.localizedMessage)
+                _effects.tryEmit(RssEffect.ShowMessage(it.localizedMessage ?: "打开订阅源失败"))
             }
     }
 
@@ -221,6 +220,8 @@ class RssViewModel(
 }
 
 sealed interface RssEffect {
+    data class ShowMessage(val message: String) : RssEffect
+
     data class OpenSort(
         val sourceUrl: String,
         val sortUrl: String?,

+ 1 - 0
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditContract.kt

@@ -44,4 +44,5 @@ sealed interface ReplaceEditIntent {
 
 sealed interface ReplaceEditEffect {
     data object NavigateBack : ReplaceEditEffect
+    data class ShowMessage(val message: String) : ReplaceEditEffect
 }

+ 4 - 0
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt

@@ -49,6 +49,7 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.zIndex
@@ -72,6 +73,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
+import io.legado.app.utils.toastOnUi
 
 @Composable
 fun keyboardAsState(): State<Boolean> {
@@ -87,11 +89,13 @@ fun ReplaceEditRouteScreen(
     onSaveSuccess: () -> Unit,
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
 
     LaunchedEffect(Unit) {
         viewModel.effects.collectLatest { effect ->
             when (effect) {
                 ReplaceEditEffect.NavigateBack -> onSaveSuccess()
+                is ReplaceEditEffect.ShowMessage -> context.toastOnUi(effect.message)
             }
         }
     }

+ 2 - 5
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt

@@ -11,7 +11,6 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getClipText
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -133,7 +132,7 @@ class ReplaceEditViewModel(
             val ruleToCopy = getReplaceRuleFromState()
             val json = GSON.toJson(ruleToCopy)
             app.sendToClip(json)
-            app.toastOnUi("规则已复制到剪贴板")
+            _effects.tryEmit(ReplaceEditEffect.ShowMessage("规则已复制到剪贴板"))
         }
     }
 
@@ -152,9 +151,7 @@ class ReplaceEditViewModel(
                     updateStateFromRule(pastedRule)
                 }
             } catch (e: Exception) {
-                launch(Dispatchers.Main) {
-                    app.toastOnUi(e.localizedMessage ?: "格式不对")
-                }
+                _effects.emit(ReplaceEditEffect.ShowMessage(e.localizedMessage ?: "格式不对"))
             }
         }
     }

+ 2 - 2
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceActivity.kt

@@ -11,7 +11,7 @@ class RssSourceActivity : BaseComposeActivity() {
     @Composable
     override fun Content() {
         AppTheme {
-            RssSourceScreen(
+            RssSourceRouteScreen(
                 onBackClick = { finish() },
                 onEditSource = { source ->
                     startActivity<RssSourceEditActivity> {
@@ -25,4 +25,4 @@ class RssSourceActivity : BaseComposeActivity() {
         }
     }
 
-}
+}

+ 79 - 53
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt

@@ -18,7 +18,6 @@ import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -34,7 +33,6 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
-import io.legado.app.base.BaseRuleEvent
 import io.legado.app.data.entities.RssSource
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.ActionItem
@@ -52,23 +50,44 @@ import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.rules.RuleListScaffold
+import kotlinx.coroutines.flow.Flow
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.rememberReorderableLazyListState
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
-fun RssSourceScreen(
+fun RssSourceRouteScreen(
     viewModel: RssSourceViewModel = koinViewModel(),
     onBackClick: () -> Unit,
     onEditSource: (RssSource) -> Unit,
     onAddSource: () -> Unit
+) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    RssSourceScreen(
+        state = uiState,
+        onIntent = viewModel::onIntent,
+        effects = viewModel.effects,
+        onBackClick = onBackClick,
+        onEditSource = onEditSource,
+        onAddSource = onAddSource,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun RssSourceScreen(
+    state: RssSourceUiState,
+    onIntent: (RssSourceIntent) -> Unit,
+    effects: Flow<RssSourceEffect>,
+    onBackClick: () -> Unit,
+    onEditSource: (RssSource) -> Unit,
+    onAddSource: () -> Unit,
 ) {
     val context = LocalContext.current
-    val uiState by viewModel.uiState.collectAsState()
-    val groups by viewModel.groupsFlow.collectAsStateWithLifecycle()
+    val groups = state.groups
 
-    val rules = uiState.items
-    val selectedIds = uiState.selectedIds
+    val rules = state.items
+    val selectedIds = state.selectedIds
     val inSelectionMode = selectedIds.isNotEmpty()
 
     val listState = rememberLazyListState()
@@ -84,18 +103,18 @@ fun RssSourceScreen(
     var showImportMenu by remember { mutableStateOf(false) }
 
     val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
-        viewModel.moveItemInList(from.index, to.index)
+        onIntent(RssSourceIntent.MoveItem(from.index, to.index))
         hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
     }
 
     val clipboardManager = LocalClipboard.current
     val snackbarHostState = remember { SnackbarHostState() }
-    val importState by viewModel.importState.collectAsStateWithLifecycle()
+    val importState = state.importState
 
     LaunchedEffect(Unit) {
-        viewModel.events.collect { event ->
+        effects.collect { event ->
             when (event) {
-                is BaseRuleEvent.ShowSnackbar -> {
+                is RssSourceEffect.ShowSnackbar -> {
                     val result = snackbarHostState.showSnackbar(
                         message = event.message,
                         actionLabel = event.actionLabel,
@@ -117,7 +136,7 @@ fun RssSourceScreen(
             uri?.let {
                 context.contentResolver.openInputStream(it)?.use { stream ->
                     val text = stream.reader().readText()
-                    viewModel.importSource(text)
+                    onIntent(RssSourceIntent.Import(text))
                 }
             }
         }
@@ -126,7 +145,7 @@ fun RssSourceScreen(
     val exportDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.CreateDocument("application/json"),
         onResult = { uri ->
-            uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
+            uri?.let { onIntent(RssSourceIntent.Export(it, rules, selectedIds)) }
         }
     )
 
@@ -136,7 +155,7 @@ fun RssSourceScreen(
         onDismissRequest = { showUrlInput = false },
         onConfirm = {
             showUrlInput = false
-            viewModel.importSource(it)
+            onIntent(RssSourceIntent.Import(it))
         }
     )
 
@@ -148,9 +167,9 @@ fun RssSourceScreen(
         suggestions = groups,
         onDismissRequest = { showAddToGroupDialog = false },
         onConfirm = { text ->
-            viewModel.selectionAddToGroups(selectedIds, text)
+            onIntent(RssSourceIntent.AddSelectionToGroup(selectedIds, text))
             showAddToGroupDialog = false
-            viewModel.setSelection(emptySet())
+            onIntent(RssSourceIntent.SetSelection(emptySet()))
         }
     )
 
@@ -161,9 +180,9 @@ fun RssSourceScreen(
         suggestions = groups,
         onDismissRequest = { showRemoveFromGroupDialog = false },
         onConfirm = { text ->
-            viewModel.selectionRemoveFromGroups(selectedIds, text)
+            onIntent(RssSourceIntent.RemoveSelectionFromGroup(selectedIds, text))
             showRemoveFromGroupDialog = false
-            viewModel.setSelection(emptySet())
+            onIntent(RssSourceIntent.SetSelection(emptySet()))
         }
     )
 
@@ -171,8 +190,8 @@ fun RssSourceScreen(
         show = showGroupManageSheet,
         groups = groups,
         onDismissRequest = { showGroupManageSheet = false },
-        onUpdateGroup = { old, new -> viewModel.upGroup(old, new) },
-        onDeleteGroup = { viewModel.delGroup(it) }
+        onUpdateGroup = { old, new -> onIntent(RssSourceIntent.UpdateGroup(old, new)) },
+        onDeleteGroup = { onIntent(RssSourceIntent.DeleteGroup(it)) }
     )
 
 
@@ -185,7 +204,7 @@ fun RssSourceScreen(
         },
         onUpload = {
             showFilePickerSheet = false
-            viewModel.uploadSelectedRules(selectedIds, rules)
+            onIntent(RssSourceIntent.Upload(selectedIds, rules))
         },
         allowExtensions = arrayOf("json")
     )
@@ -193,11 +212,13 @@ fun RssSourceScreen(
     BatchImportDialog(
         title = stringResource(R.string.import_rss_source),
         importState = importState,
-        onDismissRequest = { viewModel.cancelImport() },
-        onToggleItem = { viewModel.toggleImportSelection(it) },
-        onToggleAll = { viewModel.toggleImportAll(it) },
-        onUpdateItem = { index, source -> viewModel.updateImportItem(index, source) },
-        onConfirm = { viewModel.saveImportedRules() },
+        onDismissRequest = { onIntent(RssSourceIntent.CancelImport) },
+        onToggleItem = { onIntent(RssSourceIntent.ToggleImportItem(it)) },
+        onToggleAll = { onIntent(RssSourceIntent.ToggleImportAll(it)) },
+        onUpdateItem = { index, source ->
+            onIntent(RssSourceIntent.UpdateImportItem(index, source))
+        },
+        onConfirm = { onIntent(RssSourceIntent.SaveImportedRules) },
         itemTitle = { rule -> rule.sourceName },
         itemSubtitle = { rule ->
             rule.sourceUrl.takeIf { it.isNotBlank() }
@@ -206,7 +227,7 @@ fun RssSourceScreen(
 
     LaunchedEffect(reorderableState.isAnyItemDragging) {
         if (!reorderableState.isAnyItemDragging) {
-            viewModel.saveSortOrder()
+            onIntent(RssSourceIntent.SaveSortOrder)
         }
     }
 
@@ -216,7 +237,7 @@ fun RssSourceScreen(
         title = stringResource(R.string.delete),
         confirmText = stringResource(R.string.ok),
         onConfirm = { rule ->
-            viewModel.del(rule)
+            onIntent(RssSourceIntent.Delete(rule))
             showDeleteRuleDialog = null
         },
         dismissText = stringResource(R.string.cancel),
@@ -225,27 +246,27 @@ fun RssSourceScreen(
 
     RuleListScaffold(
         title = stringResource(R.string.rss_source),
-        subtitle = uiState.groupFilterName ?: stringResource(R.string.all),
-        state = uiState,
+        subtitle = state.groupFilterName ?: stringResource(R.string.all),
+        state = state,
         onBackClick = { onBackClick() },
-        onSearchToggle = { active -> viewModel.setSearchMode(active) },
-        onSearchQueryChange = { viewModel.setSearchKey(it) },
+        onSearchToggle = { active -> onIntent(RssSourceIntent.SetSearchMode(active)) },
+        onSearchQueryChange = { onIntent(RssSourceIntent.SetSearchQuery(it)) },
         searchPlaceholder = stringResource(R.string.search_rss_source),
-        onClearSelection = { viewModel.setSelection(emptySet()) },
-        onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
+        onClearSelection = { onIntent(RssSourceIntent.SetSelection(emptySet())) },
+        onSelectAll = { onIntent(RssSourceIntent.SetSelection(rules.map { it.id }.toSet())) },
         onSelectInvert = {
             val allIds = rules.map { it.id }.toSet()
-            viewModel.setSelection(allIds - selectedIds)
+            onIntent(RssSourceIntent.SetSelection(allIds - selectedIds))
         },
         topBarActions = {},
         selectionSecondaryActions = listOf(
             ActionItem(text = stringResource(R.string.enable), onClick = {
-                viewModel.enableSelectionByIds(selectedIds)
-                viewModel.setSelection(emptySet())
+                onIntent(RssSourceIntent.EnableSelection(selectedIds))
+                onIntent(RssSourceIntent.SetSelection(emptySet()))
             }),
             ActionItem(text = stringResource(R.string.disable_selection), onClick = {
-                viewModel.disableSelectionByIds(selectedIds)
-                viewModel.setSelection(emptySet())
+                onIntent(RssSourceIntent.DisableSelection(selectedIds))
+                onIntent(RssSourceIntent.SetSelection(emptySet()))
             }),
             ActionItem(
                 text = stringResource(R.string.add_group),
@@ -257,13 +278,13 @@ fun RssSourceScreen(
                 text = stringResource(R.string.export),
                 onClick = { showFilePickerSheet = true }),
             ActionItem(text = stringResource(R.string.check_selected_interval), onClick = {
-                viewModel.checkSelectedInterval(selectedIds, rules)
+                onIntent(RssSourceIntent.CheckSelectedInterval(selectedIds, rules))
             })
         ),
         onDeleteSelected = { ids ->
             @Suppress("UNCHECKED_CAST")
-            viewModel.delSelectionByIds(ids as Set<String>)
-            viewModel.setSelection(emptySet())
+            onIntent(RssSourceIntent.DeleteSelection(ids as Set<String>))
+            onIntent(RssSourceIntent.SetSelection(emptySet()))
         },
         onAddClick = { onAddSource() },
         snackbarHostState = snackbarHostState,
@@ -302,7 +323,7 @@ fun RssSourceScreen(
                         onClick = {
                             showImportMenu = false
                             dismiss()
-                            viewModel.importDefault()
+                            onIntent(RssSourceIntent.ImportDefault)
                         }
                     )
                 }
@@ -310,29 +331,32 @@ fun RssSourceScreen(
             PillDivider()
             RoundDropdownMenuItem(
                 text = stringResource(R.string.all),
-                onClick = { dismiss(); viewModel.setGroupFilter(null) }
+                onClick = { dismiss(); onIntent(RssSourceIntent.SetGroupFilter(null)) }
             )
             RoundDropdownMenuItem(
                 text = stringResource(R.string.enabled),
-                onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_ENABLED) }
+                onClick = { dismiss(); onIntent(RssSourceIntent.SetGroupFilter(RssSourceViewModel.FILTER_ENABLED)) }
             )
             RoundDropdownMenuItem(
                 text = stringResource(R.string.disabled),
-                onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_DISABLED) }
+                onClick = { dismiss(); onIntent(RssSourceIntent.SetGroupFilter(RssSourceViewModel.FILTER_DISABLED)) }
             )
             RoundDropdownMenuItem(
                 text = stringResource(R.string.need_login),
-                onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_LOGIN) }
+                onClick = { dismiss(); onIntent(RssSourceIntent.SetGroupFilter(RssSourceViewModel.FILTER_LOGIN)) }
             )
             RoundDropdownMenuItem(
                 text = stringResource(R.string.no_group),
-                onClick = { dismiss(); viewModel.setGroupFilter(RssSourceViewModel.FILTER_NO_GROUP) }
+                onClick = { dismiss(); onIntent(RssSourceIntent.SetGroupFilter(RssSourceViewModel.FILTER_NO_GROUP)) }
             )
             PillDivider()
             groups.forEach { group ->
                 RoundDropdownMenuItem(
                     text = group,
-                    onClick = { dismiss(); viewModel.setGroupFilter("${RssSourceViewModel.PREFIX_GROUP}$group") }
+                    onClick = {
+                        dismiss()
+                        onIntent(RssSourceIntent.SetGroupFilter("${RssSourceViewModel.PREFIX_GROUP}$group"))
+                    }
                 )
             }
         }
@@ -353,14 +377,16 @@ fun RssSourceScreen(
                         key = item.id,
                         reorderIndex = rules.indexOf(item),
                         reorderItemCount = rules.size,
-                        onMoveItem = viewModel::moveItemInList,
+                        onMoveItem = { from, to -> onIntent(RssSourceIntent.MoveItem(from, to)) },
                         title = item.name,
                         subtitle = item.group,
                         isEnabled = item.isEnabled,
                         isSelected = selectedIds.contains(item.id),
                         inSelectionMode = inSelectionMode,
-                        onToggleSelection = { viewModel.toggleSelection(item.id) },
-                        onEnabledChange = { enabled -> viewModel.update(item.source.copy(enabled = enabled)) },
+                        onToggleSelection = { onIntent(RssSourceIntent.ToggleSelection(item.id)) },
+                        onEnabledChange = { enabled ->
+                            onIntent(RssSourceIntent.Update(item.source.copy(enabled = enabled)))
+                        },
                         onClickEdit = { onEditSource(item.source) },
                         trailingAction = {
                             SmallPlainButton(
@@ -377,7 +403,7 @@ fun RssSourceScreen(
                     listState = listState,
                     items = rules,
                     selectedIds = selectedIds,
-                    onSelectionChange = { viewModel.setSelection(it) },
+                    onSelectionChange = { onIntent(RssSourceIntent.SetSelection(it)) },
                     idProvider = { it.id },
                     modifier = Modifier
                         .fillMaxHeight()

+ 112 - 12
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceViewModel.kt

@@ -1,10 +1,13 @@
 package io.legado.app.ui.rss.source.manage
 
 import android.app.Application
+import android.net.Uri
 import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.base.BaseRuleViewModel
+import io.legado.app.base.BaseRuleEvent
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.repository.UploadRepository
@@ -21,13 +24,20 @@ import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.stackTraceStr
-import io.legado.app.utils.toastOnUi
+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
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
@@ -44,17 +54,57 @@ data class RssSourceItemUi(
     val source: RssSource
 ) : SelectableItem<String>
 
+@Stable
 data class RssSourceUiState(
-    override val items: List<RssSourceItemUi> = emptyList(),
-    override val selectedIds: Set<String> = emptySet(),
+    override val items: ImmutableList<RssSourceItemUi> = persistentListOf(),
+    override val selectedIds: ImmutableSet<String> = persistentSetOf(),
     override val searchKey: String = "",
     val groupFilterName: String? = null,
-    val interaction: InteractionState = InteractionState()
+    val interaction: InteractionState = InteractionState(),
+    val groups: ImmutableList<String> = persistentListOf(),
+    val importState: BaseImportUiState<RssSource> = BaseImportUiState.Idle,
 ) : ListUiState<RssSourceItemUi> {
     override val isSearch: Boolean get() = interaction.isSearchMode
     override val isLoading: Boolean get() = interaction.isUploading
 }
 
+sealed interface RssSourceIntent {
+    data class SetSearchMode(val enabled: Boolean) : RssSourceIntent
+    data class SetSearchQuery(val query: String) : RssSourceIntent
+    data class SetSelection(val ids: Set<String>) : RssSourceIntent
+    data class ToggleSelection(val id: String) : RssSourceIntent
+    data class SetGroupFilter(val filter: String?) : RssSourceIntent
+    data class MoveItem(val from: Int, val to: Int) : RssSourceIntent
+    data class Import(val text: String) : RssSourceIntent
+    data class Export(val uri: Uri, val items: List<RssSourceItemUi>, val ids: Set<String>) : RssSourceIntent
+    data class Upload(val ids: Set<String>, val items: List<RssSourceItemUi>) : RssSourceIntent
+    data class ToggleImportItem(val index: Int) : RssSourceIntent
+    data class ToggleImportAll(val selected: Boolean) : RssSourceIntent
+    data class UpdateImportItem(val index: Int, val source: RssSource) : RssSourceIntent
+    data class Delete(val source: RssSource) : RssSourceIntent
+    data class DeleteSelection(val ids: Set<String>) : RssSourceIntent
+    data class Update(val source: RssSource) : RssSourceIntent
+    data class EnableSelection(val ids: Set<String>) : RssSourceIntent
+    data class DisableSelection(val ids: Set<String>) : RssSourceIntent
+    data class AddSelectionToGroup(val ids: Set<String>, val group: String) : RssSourceIntent
+    data class RemoveSelectionFromGroup(val ids: Set<String>, val group: String) : RssSourceIntent
+    data class UpdateGroup(val old: String, val new: String) : RssSourceIntent
+    data class DeleteGroup(val group: String) : RssSourceIntent
+    data class CheckSelectedInterval(val ids: Set<String>, val items: List<RssSourceItemUi>) : RssSourceIntent
+    data object CancelImport : RssSourceIntent
+    data object SaveImportedRules : RssSourceIntent
+    data object SaveSortOrder : RssSourceIntent
+    data object ImportDefault : RssSourceIntent
+}
+
+sealed interface RssSourceEffect {
+    data class ShowSnackbar(
+        val message: String,
+        val actionLabel: String? = null,
+        val url: String? = null,
+    ) : RssSourceEffect
+}
+
 class RssSourceViewModel(
     application: Application,
     uploadRepository: UploadRepository
@@ -80,13 +130,31 @@ class RssSourceViewModel(
 
     private val _groupFilterName = MutableStateFlow<String?>(null)
     val groupFilterName = _groupFilterName.asStateFlow()
+    private val _effects = MutableSharedFlow<RssSourceEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        viewModelScope.launch {
+            events.collect { event ->
+                when (event) {
+                    is BaseRuleEvent.ShowSnackbar -> _effects.emit(
+                        RssSourceEffect.ShowSnackbar(event.message, event.actionLabel, event.url)
+                    )
+                }
+            }
+        }
+    }
 
     override val uiState: StateFlow<RssSourceUiState> by lazy {
         combine(
             super.uiState,
-            _groupFilterName
-        ) { baseState, filterName ->
-            baseState.copy(groupFilterName = filterName)
+            _groupFilterName,
+            groupsFlow,
+        ) { baseState, filterName, groups ->
+            baseState.copy(
+                groupFilterName = filterName,
+                groups = groups.toImmutableList(),
+            )
         }.stateIn(
             scope = viewModelScope,
             started = SharingStarted.WhileSubscribed(5000),
@@ -154,14 +222,15 @@ class RssSourceViewModel(
         importState: BaseImportUiState<RssSource>
     ): RssSourceUiState {
         return RssSourceUiState(
-            items = items,
-            selectedIds = selectedIds,
+            items = items.toImmutableList(),
+            selectedIds = selectedIds.toImmutableSet(),
             searchKey = _searchKey.value,
             interaction = InteractionState(
                 isSearchMode = isSearch,
                 isUploading = isUploading || (importState is BaseImportUiState.Loading),
                 isLoading = false
-            )
+            ),
+            importState = importState,
         )
     }
 
@@ -201,6 +270,37 @@ class RssSourceViewModel(
         }
     }
 
+    fun onIntent(intent: RssSourceIntent) {
+        when (intent) {
+            is RssSourceIntent.SetSearchMode -> setSearchMode(intent.enabled)
+            is RssSourceIntent.SetSearchQuery -> setSearchKey(intent.query)
+            is RssSourceIntent.SetSelection -> setSelection(intent.ids)
+            is RssSourceIntent.ToggleSelection -> toggleSelection(intent.id)
+            is RssSourceIntent.SetGroupFilter -> setGroupFilter(intent.filter)
+            is RssSourceIntent.MoveItem -> moveItemInList(intent.from, intent.to)
+            is RssSourceIntent.Import -> importSource(intent.text)
+            is RssSourceIntent.Export -> exportToUri(intent.uri, intent.items, intent.ids)
+            is RssSourceIntent.Upload -> uploadSelectedRules(intent.ids, intent.items)
+            is RssSourceIntent.ToggleImportItem -> toggleImportSelection(intent.index)
+            is RssSourceIntent.ToggleImportAll -> toggleImportAll(intent.selected)
+            is RssSourceIntent.UpdateImportItem -> updateImportItem(intent.index, intent.source)
+            is RssSourceIntent.Delete -> del(intent.source)
+            is RssSourceIntent.DeleteSelection -> delSelectionByIds(intent.ids)
+            is RssSourceIntent.Update -> update(intent.source)
+            is RssSourceIntent.EnableSelection -> enableSelectionByIds(intent.ids)
+            is RssSourceIntent.DisableSelection -> disableSelectionByIds(intent.ids)
+            is RssSourceIntent.AddSelectionToGroup -> selectionAddToGroups(intent.ids, intent.group)
+            is RssSourceIntent.RemoveSelectionFromGroup -> selectionRemoveFromGroups(intent.ids, intent.group)
+            is RssSourceIntent.UpdateGroup -> upGroup(intent.old, intent.new)
+            is RssSourceIntent.DeleteGroup -> delGroup(intent.group)
+            is RssSourceIntent.CheckSelectedInterval -> checkSelectedInterval(intent.ids, intent.items)
+            RssSourceIntent.CancelImport -> cancelImport()
+            RssSourceIntent.SaveImportedRules -> saveImportedRules()
+            RssSourceIntent.SaveSortOrder -> saveSortOrder()
+            RssSourceIntent.ImportDefault -> importDefault()
+        }
+    }
+
     fun saveSortOrder() {
         val currentLocal = _localItems.value ?: return
         viewModelScope.launch(Dispatchers.IO) {
@@ -288,7 +388,7 @@ class RssSourceViewModel(
         }.onSuccess {
             success.invoke(it)
         }.onError {
-            context.toastOnUi(it.stackTraceStr)
+            _effects.tryEmit(RssSourceEffect.ShowSnackbar(it.stackTraceStr))
         }
     }
 
@@ -352,4 +452,4 @@ class RssSourceViewModel(
         _selectedIds.value = newSelection
     }
 
-}
+}