Parcourir la source

refactor(R2.2): 正文处理域与菜单按钮配置域摘出 ReadBookViewModel

R2 终态验收是 ReadBookViewModel ≤ 2500 行,继续按域摘。本批两个:

**正文处理域 → `ReadContentProcessDelegate`(自持状态)。**
`contentProcessConfig` 从 `ReadBookUiState` 移进 delegate 自己的 StateFlow,
以第二条 flow 经 RouteScreen 传给 `ContentProcessesSheet`(照抄 AI/高亮/正文编辑
三个域的接法)。`reloadCurrentChapterAfterContentProcessChanged` 一并搬进来——
AI 域改写落库后也要重载当前章,经 VM 的 aiHost 转发到本 delegate。
弹层关闭的中间态清理从 DismissSheet 分支里的 if 提成 `onSheetDismissed()`。

**菜单按钮配置域 → `ReadButtonConfigDelegate`(无自持状态)。**
顶栏/底栏按钮的 SharedPreferences 读写、去重补全归一化、两处自定义图标落盘。
按钮列表本身仍住在 `ReadMenuConfig`(菜单栏各处直读,搬出去要改一大片 UI),
故和 `ReadConfigUpdateDelegate` 同形:一切经 Host。四个私有常量和
DEFAULT_ENABLED_BUTTON_IDS 随之从 VM 文件底部搬进 delegate 的 companion。

两个 delegate 都不带 DAO(DAO 棘轮只认文件名含 ViewModel 的文件,
delegate 里的直连会掉进宽松基线,等于洗白 VM 的债)。

护栏:`ReadBookDomainSplitBoundaryTest` 的 DOMAINS 加两行。反证已做——
把 contentProcessConfig 塞回 ReadBookUiState → 「域状态不再挂在 ReadBookUiState 上」红;
在 VM 里写回 ContentProcessItemUi → 「VM 不再持有各域的实现」红。

ReadBookViewModel 3795 → 3574 行。

回归清单:正文处理弹层(打开/开关某项/删除确认与取消/关闭后重开)、
AI 划词改写落库后当前章重载、顶栏与底栏按钮排序开关的保存与重进生效、
菜单与顶栏自定义图标选择。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 il y a 3 mois
Parent
commit
3015cfe22b

+ 0 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -277,7 +277,6 @@ data class ReadBookUiState(
     val sheetConfig: ReadSheetConfigUiState = ReadSheetConfigUiState(),
     val sheetConfig: ReadSheetConfigUiState = ReadSheetConfigUiState(),
     // Menu config (from ReadBookConfig via repository)
     // Menu config (from ReadBookConfig via repository)
     val menuConfig: ReadMenuConfig = ReadMenuConfig(),
     val menuConfig: ReadMenuConfig = ReadMenuConfig(),
-    val contentProcessConfig: ContentProcessConfigUiState = ContentProcessConfigUiState(),
     // AI 域状态见 ReadAiUiState —— 由 ReadAiDelegate 独立持有
     // AI 域状态见 ReadAiUiState —— 由 ReadAiDelegate 独立持有
     val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
     val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
 ) {
 ) {

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -137,6 +137,7 @@ fun ReadBookRouteScreen(
     val aiState by viewModel.aiState.collectAsStateWithLifecycle()
     val aiState by viewModel.aiState.collectAsStateWithLifecycle()
     val highlightRuleState by viewModel.highlightRuleState.collectAsStateWithLifecycle()
     val highlightRuleState by viewModel.highlightRuleState.collectAsStateWithLifecycle()
     val contentEditState by viewModel.contentEditState.collectAsStateWithLifecycle()
     val contentEditState by viewModel.contentEditState.collectAsStateWithLifecycle()
+    val contentProcessState by viewModel.contentProcessState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val context = LocalContext.current
     val context = LocalContext.current
@@ -588,6 +589,7 @@ fun ReadBookRouteScreen(
                 aiState = aiState,
                 aiState = aiState,
                 highlightRuleState = highlightRuleState,
                 highlightRuleState = highlightRuleState,
                 contentEditState = contentEditState,
                 contentEditState = contentEditState,
+                contentProcessState = contentProcessState,
                 preferences = readPreferences,
                 preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 onIntent = viewModel::onIntent,
                 onBack = { controller.closeReadBook() },
                 onBack = { controller.closeReadBook() },

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -69,6 +69,7 @@ fun ReadBookScreen(
     aiState: ReadAiUiState,
     aiState: ReadAiUiState,
     highlightRuleState: HighlightRuleConfigUiState,
     highlightRuleState: HighlightRuleConfigUiState,
     contentEditState: ContentEditUiState,
     contentEditState: ContentEditUiState,
+    contentProcessState: ContentProcessConfigUiState,
     preferences: ReadPreferences,
     preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
     onBack: () -> Unit,
     onBack: () -> Unit,
@@ -198,7 +199,7 @@ fun ReadBookScreen(
     )
     )
     ContentProcessesSheet(
     ContentProcessesSheet(
         show = state.activeSheet is ReadBookSheet.ContentProcesses,
         show = state.activeSheet is ReadBookSheet.ContentProcesses,
-        state = state.contentProcessConfig,
+        state = contentProcessState,
         onIntent = onIntent,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
         onDismissRequest = dismissSheet,
     )
     )

+ 51 - 275
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -18,7 +18,6 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
-import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.HttpTTS
@@ -57,8 +56,6 @@ import io.legado.app.domain.model.settings.ThemeSettings
 import io.legado.app.domain.model.settings.isEyeProtectionConfigured
 import io.legado.app.domain.model.settings.isEyeProtectionConfigured
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.ReadingProgress
-import io.legado.app.domain.model.TextProcessAction
-import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
 import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
@@ -216,6 +213,21 @@ class ReadBookViewModel(
         _uiState.update { it.copy(menuState = ReadBookMenuState()) }
         _uiState.update { it.copy(menuState = ReadBookMenuState()) }
     }
     }
 
 
+    // --- 正文处理域 ---
+
+    private val contentProcessDelegate = ReadContentProcessDelegate(
+        context = context,
+        scope = viewModelScope,
+        host = object : ReadContentProcessDelegate.Host {
+            override fun showToast(message: String) {
+                _effects.tryEmit(ReadBookEffect.ShowToast(message))
+            }
+        },
+        bookContentProcessGateway = bookContentProcessGateway,
+    )
+
+    val contentProcessState = contentProcessDelegate.uiState
+
     // --- AI 域(摘要 / 净化 / 重写 / 预设)---
     // --- AI 域(摘要 / 净化 / 重写 / 预设)---
 
 
     private val aiHost = object : ReadAiDelegate.Host {
     private val aiHost = object : ReadAiDelegate.Host {
@@ -239,7 +251,7 @@ class ReadBookViewModel(
             bookUrl: String,
             bookUrl: String,
             chapterIndex: Int,
             chapterIndex: Int,
         ) {
         ) {
-            reloadCurrentChapterAfterContentProcessChanged(bookUrl, chapterIndex)
+            contentProcessDelegate.reloadCurrentChapter(bookUrl, chapterIndex)
         }
         }
 
 
         override suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? =
         override suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? =
@@ -340,6 +352,22 @@ class ReadBookViewModel(
         readBookStyleConfigRepository = readBookStyleConfigRepository,
         readBookStyleConfigRepository = readBookStyleConfigRepository,
     )
     )
 
 
+    // --- 菜单按钮配置域(无自持状态,按钮列表仍在 menuConfig)---
+
+    private val buttonConfigDelegate = ReadButtonConfigDelegate(
+        context = context,
+        scope = viewModelScope,
+        host = object : ReadButtonConfigDelegate.Host {
+            override fun updateMenuConfig(transform: (ReadMenuConfig) -> ReadMenuConfig) {
+                _uiState.update { it.copy(menuConfig = transform(it.menuConfig)) }
+            }
+
+            override fun applyConfigUpdate(update: ConfigUpdate) {
+                configUpdateDelegate.handle(update)
+            }
+        },
+    )
+
     private val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
     private val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
         val tts = TextToSpeech(context, null)
         val tts = TextToSpeech(context, null)
         val engines = tts.engines
         val engines = tts.engines
@@ -402,7 +430,7 @@ class ReadBookViewModel(
         // collect 的挂起点——本行返回时订阅者已注册。
         // collect 的挂起点——本行返回时订阅者已注册。
         collectReaderSessionEvents()
         collectReaderSessionEvents()
         readerSession.attach()
         readerSession.attach()
-        refreshButtonConfigs()
+        buttonConfigDelegate.refresh()
         collectReadPreferences()
         collectReadPreferences()
         collectEyeProtectionSettings()
         collectEyeProtectionSettings()
         collectReadAloudPreferences()
         collectReadAloudPreferences()
@@ -650,21 +678,13 @@ class ReadBookViewModel(
             is ReadBookIntent.OpenChapterSummary -> aiDelegate.openChapterSummary()
             is ReadBookIntent.OpenChapterSummary -> aiDelegate.openChapterSummary()
             is ReadBookIntent.OpenAiCurrentChapterRewrite -> aiDelegate.openAiCurrentChapterRewrite()
             is ReadBookIntent.OpenAiCurrentChapterRewrite -> aiDelegate.openAiCurrentChapterRewrite()
             is ReadBookIntent.RetryChapterSummary -> aiDelegate.retryChapterSummary()
             is ReadBookIntent.RetryChapterSummary -> aiDelegate.retryChapterSummary()
-            is ReadBookIntent.LoadContentProcesses -> loadContentProcesses()
-            is ReadBookIntent.ToggleContentProcess -> toggleContentProcess(intent.id, intent.enabled)
-            is ReadBookIntent.RequestDeleteContentProcess -> _uiState.update {
-                it.copy(
-                    contentProcessConfig = it.contentProcessConfig.copy(
-                        deleteItem = intent.item
-                    )
-                )
-            }
-            is ReadBookIntent.ConfirmDeleteContentProcess -> deletePendingContentProcess()
-            is ReadBookIntent.DismissDeleteContentProcess -> _uiState.update {
-                it.copy(
-                    contentProcessConfig = it.contentProcessConfig.copy(deleteItem = null)
-                )
-            }
+            is ReadBookIntent.LoadContentProcesses -> contentProcessDelegate.load()
+            is ReadBookIntent.ToggleContentProcess ->
+                contentProcessDelegate.toggle(intent.id, intent.enabled)
+            is ReadBookIntent.RequestDeleteContentProcess ->
+                contentProcessDelegate.requestDelete(intent.item)
+            is ReadBookIntent.ConfirmDeleteContentProcess -> contentProcessDelegate.confirmDelete()
+            is ReadBookIntent.DismissDeleteContentProcess -> contentProcessDelegate.dismissDelete()
             is ReadBookIntent.SelectAiRewritePreset -> aiDelegate.selectAiRewritePreset(intent.presetId)
             is ReadBookIntent.SelectAiRewritePreset -> aiDelegate.selectAiRewritePreset(intent.presetId)
             is ReadBookIntent.SetAiRewriteTemporaryInstruction ->
             is ReadBookIntent.SetAiRewriteTemporaryInstruction ->
                 aiDelegate.setAiRewriteTemporaryInstruction(intent.instruction)
                 aiDelegate.setAiRewriteTemporaryInstruction(intent.instruction)
@@ -745,7 +765,7 @@ class ReadBookViewModel(
                     _uiState.update { it.copy(activeSheet = intent.sheet) }
                     _uiState.update { it.copy(activeSheet = intent.sheet) }
                 } else if (intent.sheet is ReadBookSheet.ContentProcesses) {
                 } else if (intent.sheet is ReadBookSheet.ContentProcesses) {
                     _uiState.update { it.copy(activeSheet = intent.sheet) }
                     _uiState.update { it.copy(activeSheet = intent.sheet) }
-                    loadContentProcesses()
+                    contentProcessDelegate.load()
                 } else if (intent.sheet is ReadBookSheet.AiRewritePresetConfig) {
                 } else if (intent.sheet is ReadBookSheet.AiRewritePresetConfig) {
                     aiDelegate.openAiRewritePresetConfig()
                     aiDelegate.openAiRewritePresetConfig()
                 } else {
                 } else {
@@ -757,18 +777,10 @@ class ReadBookViewModel(
                 when (_uiState.value.activeSheet) {
                 when (_uiState.value.activeSheet) {
                     is ReadBookSheet.HighlightRuleConfig -> highlightRuleDelegate.onSheetDismissed()
                     is ReadBookSheet.HighlightRuleConfig -> highlightRuleDelegate.onSheetDismissed()
                     is ReadBookSheet.ContentEdit -> contentEditDelegate.onSheetDismissed()
                     is ReadBookSheet.ContentEdit -> contentEditDelegate.onSheetDismissed()
+                    is ReadBookSheet.ContentProcesses -> contentProcessDelegate.onSheetDismissed()
                     else -> Unit
                     else -> Unit
                 }
                 }
-                _uiState.update {
-                    if (it.activeSheet is ReadBookSheet.ContentProcesses) {
-                        it.copy(
-                            activeSheet = null,
-                            contentProcessConfig = ContentProcessConfigUiState(),
-                        )
-                    } else {
-                        it.copy(activeSheet = null)
-                    }
-                }
+                _uiState.update { it.copy(activeSheet = null) }
             }
             }
             is ReadBookIntent.SetActiveSheet -> _uiState.update {
             is ReadBookIntent.SetActiveSheet -> _uiState.update {
                 it.copy(activeSheet = intent.sheet)
                 it.copy(activeSheet = intent.sheet)
@@ -1039,16 +1051,20 @@ class ReadBookViewModel(
             is ReadBookIntent.ExportHighlightRulesAsUrl -> highlightRuleDelegate.exportAsUrl()
             is ReadBookIntent.ExportHighlightRulesAsUrl -> highlightRuleDelegate.exportAsUrl()
             is ReadBookIntent.ExportHighlightRulesToFile ->
             is ReadBookIntent.ExportHighlightRulesToFile ->
                 highlightRuleDelegate.exportToFile(intent.uri)
                 highlightRuleDelegate.exportToFile(intent.uri)
-            is ReadBookIntent.SaveMenuCustomIcon -> saveMenuCustomIcon(intent.id, intent.uri)
-            is ReadBookIntent.SaveTitleBarCustomIcon -> saveTitleBarCustomIcon(intent.id, intent.uri)
+            is ReadBookIntent.SaveMenuCustomIcon ->
+                buttonConfigDelegate.saveMenuCustomIcon(intent.id, intent.uri)
+            is ReadBookIntent.SaveTitleBarCustomIcon ->
+                buttonConfigDelegate.saveTitleBarCustomIcon(intent.id, intent.uri)
             is ReadBookIntent.OpenMenuCustomIconPicker -> {
             is ReadBookIntent.OpenMenuCustomIconPicker -> {
                 _effects.tryEmit(ReadBookEffect.OpenMenuCustomIconPicker(intent.id))
                 _effects.tryEmit(ReadBookEffect.OpenMenuCustomIconPicker(intent.id))
             }
             }
             is ReadBookIntent.OpenTitleBarCustomIconPicker -> {
             is ReadBookIntent.OpenTitleBarCustomIconPicker -> {
                 _effects.tryEmit(ReadBookEffect.OpenTitleBarCustomIconPicker(intent.id))
                 _effects.tryEmit(ReadBookEffect.OpenTitleBarCustomIconPicker(intent.id))
             }
             }
-            is ReadBookIntent.SaveMenuButtonConfig -> saveMenuButtonConfig(intent.items)
-            is ReadBookIntent.SaveTitleBarButtonConfig -> saveTitleBarButtonConfig(intent.items)
+            is ReadBookIntent.SaveMenuButtonConfig ->
+                buttonConfigDelegate.saveMenuButtons(intent.items)
+            is ReadBookIntent.SaveTitleBarButtonConfig ->
+                buttonConfigDelegate.saveTitleBarButtons(intent.items)
 
 
             is ReadBookIntent.KeepLightChanged -> {
             is ReadBookIntent.KeepLightChanged -> {
                 _readPreferences.update { it.copy(keepLight = intent.value) }
                 _readPreferences.update { it.copy(keepLight = intent.value) }
@@ -2292,106 +2308,6 @@ class ReadBookViewModel(
         return taskFlow.value.status
         return taskFlow.value.status
     }
     }
 
 
-    private fun refreshButtonConfigs() {
-        val titleBarButtons = loadButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY)
-        val bottomBarButtons = loadButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY)
-        _uiState.update {
-            it.copy(
-                menuConfig = it.menuConfig.copy(
-                    titleBarButtons = titleBarButtons.toImmutableList(),
-                    bottomBarButtons = bottomBarButtons.toImmutableList(),
-                ),
-            )
-        }
-    }
-
-    private fun saveTitleBarButtonConfig(items: List<ReadBookButtonConfigItem>) {
-        val normalized = normalizeButtonConfig(items)
-        saveButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY, normalized)
-        _uiState.update {
-            it.copy(
-                menuConfig = it.menuConfig.copy(
-                    titleBarButtons = normalized.toImmutableList(),
-                ),
-            )
-        }
-    }
-
-    private fun saveMenuButtonConfig(items: List<ReadBookButtonConfigItem>) {
-        val normalized = normalizeButtonConfig(items)
-        saveButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY, normalized)
-        _uiState.update {
-            it.copy(
-                menuConfig = it.menuConfig.copy(
-                    bottomBarButtons = normalized.toImmutableList(),
-                ),
-            )
-        }
-    }
-
-    private fun loadButtonConfig(
-        preferenceName: String,
-        key: String,
-    ): List<ReadBookButtonConfigItem> {
-        val prefs = context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE)
-        val raw = prefs.getString(key, null)
-            ?.split(";")
-            ?.mapNotNull { token ->
-                val parts = token.split(",")
-                val id = parts.getOrNull(0)?.takeIf { it in ReadBookButtonIds }
-                val enabled = parts.getOrNull(1)?.toBooleanStrictOrNull()
-                if (id != null && enabled != null) {
-                    ReadBookButtonConfigItem(id, enabled)
-                } else {
-                    null
-                }
-            }
-            ?: emptyList()
-
-        return if (raw.isEmpty()) {
-            ReadBookButtonIds.map { id ->
-                ReadBookButtonConfigItem(
-                    id = id,
-                    enabled = id in DEFAULT_ENABLED_BUTTON_IDS,
-                )
-            }
-        } else {
-            normalizeButtonConfig(raw)
-        }
-    }
-
-    private fun saveButtonConfig(
-        preferenceName: String,
-        key: String,
-        items: List<ReadBookButtonConfigItem>,
-    ) {
-        val value = items.joinToString(";") { "${it.id},${it.enabled}" }
-        context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE)
-            .edit()
-            .putString(key, value)
-            .apply()
-    }
-
-    private fun normalizeButtonConfig(
-        items: List<ReadBookButtonConfigItem>,
-    ): List<ReadBookButtonConfigItem> {
-        val seen = mutableSetOf<String>()
-        val normalized = items.mapNotNull { item ->
-            val id = item.id
-            if (id in ReadBookButtonIds && seen.add(id)) {
-                ReadBookButtonConfigItem(id, item.enabled)
-            } else {
-                null
-            }
-        }.toMutableList()
-        ReadBookButtonIds.forEach { id ->
-            if (seen.add(id)) {
-                normalized.add(ReadBookButtonConfigItem(id, false))
-            }
-        }
-        return normalized
-    }
-
     private fun calculateSeekProgress(): Int {
     private fun calculateSeekProgress(): Int {
         return when (readSettingsRepository.currentSettings.progressBarBehavior) {
         return when (readSettingsRepository.currentSettings.progressBarBehavior) {
             "page" -> ReadBook.durPageIndex
             "page" -> ReadBook.durPageIndex
@@ -3007,112 +2923,6 @@ class ReadBookViewModel(
         }
         }
     }
     }
 
 
-    private fun loadContentProcesses() {
-        val book = ReadBook.book ?: return
-        val chapterIndex = ReadBook.durChapterIndex
-        _uiState.update {
-            it.copy(
-                contentProcessConfig = it.contentProcessConfig.copy(
-                    isLoading = true,
-                    errorMessage = null,
-                )
-            )
-        }
-        viewModelScope.launch(IO) {
-            runCatching {
-                bookContentProcessGateway.getForChapter(book.bookUrl, chapterIndex)
-                    .mapNotNull { it.toContentProcessItemUi() }
-                    .toImmutableList()
-            }.onSuccess { items ->
-                _uiState.update {
-                    it.copy(
-                        contentProcessConfig = it.contentProcessConfig.copy(
-                            isLoading = false,
-                            items = items,
-                            errorMessage = null,
-                        )
-                    )
-                }
-            }.onFailure { error ->
-                _uiState.update {
-                    it.copy(
-                        contentProcessConfig = it.contentProcessConfig.copy(
-                            isLoading = false,
-                            errorMessage = error.localizedMessage ?: context.getString(R.string.error),
-                        )
-                    )
-                }
-            }
-        }
-    }
-
-    private fun toggleContentProcess(id: String, enabled: Boolean) {
-        viewModelScope.launch(IO) {
-            runCatching {
-                bookContentProcessGateway.setEnabled(id, enabled)
-            }.onSuccess {
-                reloadCurrentChapterAfterContentProcessChanged()
-                loadContentProcesses()
-            }.onFailure { error ->
-                _effects.tryEmit(
-                    ReadBookEffect.ShowToast(error.localizedMessage ?: context.getString(R.string.error))
-                )
-            }
-        }
-    }
-
-    private fun deletePendingContentProcess() {
-        val item = _uiState.value.contentProcessConfig.deleteItem ?: return
-        viewModelScope.launch(IO) {
-            runCatching {
-                bookContentProcessGateway.delete(item.id)
-            }.onSuccess {
-                _uiState.update {
-                    it.copy(
-                        contentProcessConfig = it.contentProcessConfig.copy(deleteItem = null)
-                    )
-                }
-                reloadCurrentChapterAfterContentProcessChanged()
-                loadContentProcesses()
-            }.onFailure { error ->
-                _effects.tryEmit(
-                    ReadBookEffect.ShowToast(error.localizedMessage ?: context.getString(R.string.error))
-                )
-            }
-        }
-    }
-
-    private fun reloadCurrentChapterAfterContentProcessChanged(
-        bookUrl: String? = null,
-        chapterIndex: Int = ReadBook.durChapterIndex,
-    ) {
-        val book = ReadBook.book ?: return
-        if (bookUrl != null && book.bookUrl != bookUrl) return
-        if (ReadBook.durChapterIndex != chapterIndex) return
-        ReadBook.clearTextChapter()
-        for (index in chapterIndex - 1..chapterIndex + 1) {
-            ReadBook.removeLoading(index)
-        }
-        ReadBook.loadContent(resetPageOffset = false)
-    }
-
-    private fun BookContentProcess.toContentProcessItemUi(): ContentProcessItemUi? {
-        val anchor = GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
-            ?: return null
-        val action = GSON.fromJsonObject<TextProcessAction>(actionJson).getOrNull()
-            ?: return null
-        return ContentProcessItemUi(
-            id = id,
-            kind = kind,
-            actionType = action.type,
-            enabled = enabled && status == BookContentProcess.STATUS_ACTIVE,
-            chapterIndex = chapterIndex ?: anchor.chapterIndex,
-            selectedText = anchor.selectedText,
-            replacementText = action.replacement ?: action.text.orEmpty(),
-            createdAt = createdAt,
-        )
-    }
-
     private fun changeReplaceRule(enabled: Boolean) {
     private fun changeReplaceRule(enabled: Boolean) {
         ReadBook.book?.let {
         ReadBook.book?.let {
             it.setUseReplaceRule(enabled)
             it.setUseReplaceRule(enabled)
@@ -3186,28 +2996,6 @@ class ReadBookViewModel(
     }
     }
 
 
     @Suppress("LongMethod")
     @Suppress("LongMethod")
-    private fun saveMenuCustomIcon(id: String, uri: Uri) {
-        execute {
-            val iconFile = java.io.File(context.filesDir, "read_menu_icons/$id.png")
-            iconFile.parentFile?.mkdirs()
-            context.contentResolver.openInputStream(uri)?.use { input ->
-                iconFile.outputStream().use { output -> input.copyTo(output) }
-            }
-            configUpdateDelegate.handle(ConfigUpdate.MenuCustomIcon(id, iconFile.absolutePath))
-        }
-    }
-
-    private fun saveTitleBarCustomIcon(id: String, uri: Uri) {
-        execute {
-            val iconFile = java.io.File(context.filesDir, "title_bar_icons/$id.png")
-            iconFile.parentFile?.mkdirs()
-            context.contentResolver.openInputStream(uri)?.use { input ->
-                iconFile.outputStream().use { output -> input.copyTo(output) }
-            }
-            configUpdateDelegate.handle(ConfigUpdate.TitleBarCustomIcon(id, iconFile.absolutePath))
-        }
-    }
-
     private fun selectFont(path: String) {
     private fun selectFont(path: String) {
         readBookStyleConfigRepository.updateCurrentStyle(
         readBookStyleConfigRepository.updateCurrentStyle(
             stringMutation(ReadStyleStringKey.TextFont, path)
             stringMutation(ReadStyleStringKey.TextFont, path)
@@ -3798,18 +3586,6 @@ internal fun readStyleExportFileName(styleName: String): String {
     return "$safeName.zip"
     return "$safeName.zip"
 }
 }
 
 
-private const val TITLE_BAR_ICON_PREFS = "title_bar_icons"
-private const val TITLE_BAR_ICON_KEY = "icons"
-private const val TOOL_BUTTON_PREFS = "tool_button_config"
-private const val TOOL_BUTTON_KEY = "tool_buttons"
-private val DEFAULT_ENABLED_BUTTON_IDS = setOf(
-    "search",
-    "auto_page",
-    "catalog",
-    "read_aloud",
-    "setting",
-)
-
 private const val DARK_LUX_THRESHOLD = 8f
 private const val DARK_LUX_THRESHOLD = 8f
 private const val BRIGHT_LUX_THRESHOLD = 100f
 private const val BRIGHT_LUX_THRESHOLD = 100f
 private const val LIGHT_LUMINANCE_THRESHOLD = 0.35
 private const val LIGHT_LUMINANCE_THRESHOLD = 0.35

+ 159 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadButtonConfigDelegate.kt

@@ -0,0 +1,159 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import android.net.Uri
+import io.legado.app.help.coroutine.Coroutine
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import java.io.File
+
+/**
+ * 阅读菜单按钮配置域(R2.2 续批)。
+ *
+ * 管顶栏/底栏按钮的排序与开关,以及两处自定义图标的落盘。按钮列表本身仍住在
+ * [ReadMenuConfig] 里(菜单栏各处直读,搬出去要改一大片 UI),故本 delegate
+ * **无自持状态**,和 [ReadConfigUpdateDelegate] 同形:一切读写经 [Host]。
+ */
+class ReadButtonConfigDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+) {
+
+    interface Host {
+        fun updateMenuConfig(transform: (ReadMenuConfig) -> ReadMenuConfig)
+
+        /** 自定义图标落盘后走配置更新分发,通知菜单重读。 */
+        fun applyConfigUpdate(update: ConfigUpdate)
+    }
+
+    /** 从 SharedPreferences 读回两条按钮配置,刷进 menuConfig。VM 构造时调一次。 */
+    fun refresh() {
+        val titleBarButtons = loadButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY)
+        val bottomBarButtons = loadButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY)
+        host.updateMenuConfig {
+            it.copy(
+                titleBarButtons = titleBarButtons.toImmutableList(),
+                bottomBarButtons = bottomBarButtons.toImmutableList(),
+            )
+        }
+    }
+
+    fun saveTitleBarButtons(items: List<ReadBookButtonConfigItem>) {
+        val normalized = normalizeButtonConfig(items)
+        saveButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY, normalized)
+        host.updateMenuConfig { it.copy(titleBarButtons = normalized.toImmutableList()) }
+    }
+
+    fun saveMenuButtons(items: List<ReadBookButtonConfigItem>) {
+        val normalized = normalizeButtonConfig(items)
+        saveButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY, normalized)
+        host.updateMenuConfig { it.copy(bottomBarButtons = normalized.toImmutableList()) }
+    }
+
+    fun saveMenuCustomIcon(id: String, uri: Uri) {
+        copyCustomIcon(id, uri, "read_menu_icons") { path ->
+            ConfigUpdate.MenuCustomIcon(id, path)
+        }
+    }
+
+    fun saveTitleBarCustomIcon(id: String, uri: Uri) {
+        copyCustomIcon(id, uri, "title_bar_icons") { path ->
+            ConfigUpdate.TitleBarCustomIcon(id, path)
+        }
+    }
+
+    private fun copyCustomIcon(
+        id: String,
+        uri: Uri,
+        directoryName: String,
+        update: (path: String) -> ConfigUpdate,
+    ) {
+        Coroutine.async(scope, Dispatchers.IO) {
+            val iconFile = File(context.filesDir, "$directoryName/$id.png")
+            iconFile.parentFile?.mkdirs()
+            context.contentResolver.openInputStream(uri)?.use { input ->
+                iconFile.outputStream().use { output -> input.copyTo(output) }
+            }
+            host.applyConfigUpdate(update(iconFile.absolutePath))
+        }
+    }
+
+    private fun loadButtonConfig(
+        preferenceName: String,
+        key: String,
+    ): List<ReadBookButtonConfigItem> {
+        val prefs = context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE)
+        val raw = prefs.getString(key, null)
+            ?.split(";")
+            ?.mapNotNull { token ->
+                val parts = token.split(",")
+                val id = parts.getOrNull(0)?.takeIf { it in ReadBookButtonIds }
+                val enabled = parts.getOrNull(1)?.toBooleanStrictOrNull()
+                if (id != null && enabled != null) {
+                    ReadBookButtonConfigItem(id, enabled)
+                } else {
+                    null
+                }
+            }
+            ?: emptyList()
+
+        return if (raw.isEmpty()) {
+            ReadBookButtonIds.map { id ->
+                ReadBookButtonConfigItem(
+                    id = id,
+                    enabled = id in DEFAULT_ENABLED_BUTTON_IDS,
+                )
+            }
+        } else {
+            normalizeButtonConfig(raw)
+        }
+    }
+
+    private fun saveButtonConfig(
+        preferenceName: String,
+        key: String,
+        items: List<ReadBookButtonConfigItem>,
+    ) {
+        val value = items.joinToString(";") { "${it.id},${it.enabled}" }
+        context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE)
+            .edit()
+            .putString(key, value)
+            .apply()
+    }
+
+    private fun normalizeButtonConfig(
+        items: List<ReadBookButtonConfigItem>,
+    ): List<ReadBookButtonConfigItem> {
+        val seen = mutableSetOf<String>()
+        val normalized = items.mapNotNull { item ->
+            val id = item.id
+            if (id in ReadBookButtonIds && seen.add(id)) {
+                ReadBookButtonConfigItem(id, item.enabled)
+            } else {
+                null
+            }
+        }.toMutableList()
+        ReadBookButtonIds.forEach { id ->
+            if (seen.add(id)) {
+                normalized.add(ReadBookButtonConfigItem(id, false))
+            }
+        }
+        return normalized
+    }
+
+    private companion object {
+        const val TITLE_BAR_ICON_PREFS = "title_bar_icons"
+        const val TITLE_BAR_ICON_KEY = "icons"
+        const val TOOL_BUTTON_PREFS = "tool_button_config"
+        const val TOOL_BUTTON_KEY = "tool_buttons"
+        val DEFAULT_ENABLED_BUTTON_IDS = setOf(
+            "search",
+            "auto_page",
+            "catalog",
+            "read_aloud",
+            "setting",
+        )
+    }
+}

+ 140 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadContentProcessDelegate.kt

@@ -0,0 +1,140 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import io.legado.app.R
+import io.legado.app.data.entities.BookContentProcess
+import io.legado.app.domain.gateway.BookContentProcessGateway
+import io.legado.app.domain.model.TextProcessAction
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.model.ReadBook
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * 正文处理(划词改写落库项)域(R2.2 续批)。
+ *
+ * 自持 [ContentProcessConfigUiState],不再让每次加载/开关/删除都 copy 整个
+ * [ReadBookUiState]。章节重载是本域和 AI 域共用的动作,故走 [Host]。
+ */
+class ReadContentProcessDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val bookContentProcessGateway: BookContentProcessGateway,
+) {
+
+    interface Host {
+        fun showToast(message: String)
+    }
+
+    private val _uiState = MutableStateFlow(ContentProcessConfigUiState())
+    val uiState = _uiState.asStateFlow()
+
+    fun load() {
+        val book = ReadBook.book ?: return
+        val chapterIndex = ReadBook.durChapterIndex
+        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+        scope.launch(IO) {
+            runCatching {
+                bookContentProcessGateway.getForChapter(book.bookUrl, chapterIndex)
+                    .mapNotNull { it.toContentProcessItemUi() }
+                    .toImmutableList()
+            }.onSuccess { items ->
+                _uiState.update {
+                    it.copy(isLoading = false, items = items, errorMessage = null)
+                }
+            }.onFailure { error ->
+                _uiState.update {
+                    it.copy(
+                        isLoading = false,
+                        errorMessage = error.localizedMessage
+                            ?: context.getString(R.string.error),
+                    )
+                }
+            }
+        }
+    }
+
+    fun toggle(id: String, enabled: Boolean) {
+        scope.launch(IO) {
+            runCatching {
+                bookContentProcessGateway.setEnabled(id, enabled)
+            }.onSuccess {
+                reloadCurrentChapter()
+                load()
+            }.onFailure { error ->
+                host.showToast(error.localizedMessage ?: context.getString(R.string.error))
+            }
+        }
+    }
+
+    fun requestDelete(item: ContentProcessItemUi) {
+        _uiState.update { it.copy(deleteItem = item) }
+    }
+
+    fun dismissDelete() {
+        _uiState.update { it.copy(deleteItem = null) }
+    }
+
+    fun confirmDelete() {
+        val item = _uiState.value.deleteItem ?: return
+        scope.launch(IO) {
+            runCatching {
+                bookContentProcessGateway.delete(item.id)
+            }.onSuccess {
+                _uiState.update { it.copy(deleteItem = null) }
+                reloadCurrentChapter()
+                load()
+            }.onFailure { error ->
+                host.showToast(error.localizedMessage ?: context.getString(R.string.error))
+            }
+        }
+    }
+
+    /** 弹层关闭时清掉中间态。 */
+    fun onSheetDismissed() {
+        _uiState.value = ContentProcessConfigUiState()
+    }
+
+    /**
+     * 正文处理项变化后重载当前章。AI 域改写落库后也走这里(经 VM 的 Host 转发),
+     * 故带上 bookUrl / chapterIndex 的守卫:期间用户可能已经翻走。
+     */
+    fun reloadCurrentChapter(
+        bookUrl: String? = null,
+        chapterIndex: Int = ReadBook.durChapterIndex,
+    ) {
+        val book = ReadBook.book ?: return
+        if (bookUrl != null && book.bookUrl != bookUrl) return
+        if (ReadBook.durChapterIndex != chapterIndex) return
+        ReadBook.clearTextChapter()
+        for (index in chapterIndex - 1..chapterIndex + 1) {
+            ReadBook.removeLoading(index)
+        }
+        ReadBook.loadContent(resetPageOffset = false)
+    }
+
+    private fun BookContentProcess.toContentProcessItemUi(): ContentProcessItemUi? {
+        val anchor = GSON.fromJsonObject<TextProcessAnchor>(anchorJson).getOrNull()
+            ?: return null
+        val action = GSON.fromJsonObject<TextProcessAction>(actionJson).getOrNull()
+            ?: return null
+        return ContentProcessItemUi(
+            id = id,
+            kind = kind,
+            actionType = action.type,
+            enabled = enabled && status == BookContentProcess.STATUS_ACTIVE,
+            chapterIndex = chapterIndex ?: anchor.chapterIndex,
+            selectedText = anchor.selectedText,
+            replacementText = action.replacement ?: action.text.orEmpty(),
+            createdAt = createdAt,
+        )
+    }
+}

+ 14 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -153,6 +153,20 @@ class ReadBookDomainSplitBoundaryTest {
                 stateFields = emptySet(),
                 stateFields = emptySet(),
                 stateTypes = listOf("is ConfigUpdate."),
                 stateTypes = listOf("is ConfigUpdate."),
             ),
             ),
+            DomainSplit(
+                name = "正文处理",
+                delegateFile = "io/legado/app/ui/book/read/ReadContentProcessDelegate.kt",
+                stateFields = setOf("contentProcessConfig"),
+                stateTypes = listOf("ContentProcessConfigUiState", "ContentProcessItemUi"),
+            ),
+            // 按钮配置域无自持状态:按钮列表仍在 menuConfig 里,靠 stateTypes 守
+            // 「SharedPreferences 读写和归一化逻辑不回流 VM」
+            DomainSplit(
+                name = "菜单按钮配置",
+                delegateFile = "io/legado/app/ui/book/read/ReadButtonConfigDelegate.kt",
+                stateFields = emptySet(),
+                stateTypes = listOf("ReadBookButtonIds", "getSharedPreferences"),
+            ),
         )
         )
 
 
         val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")
         val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")