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

fix(read): 修复阅读菜单与高亮规则功能

HapeLee 4 месяцев назад
Родитель
Сommit
da00f712bf

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

@@ -181,6 +181,7 @@ data class HighlightRuleConfigUiState(
     val editingRule: HighlightRule? = null,
     val showNewRule: Boolean = false,
     val deleteRule: HighlightRule? = null,
+    val importState: BaseImportUiState<HighlightRule> = BaseImportUiState.Idle,
 )
 
 @Stable
@@ -429,6 +430,21 @@ sealed interface ReadBookIntent {
     data class RequestDeleteHighlightRule(val rule: HighlightRule) : ReadBookIntent
     data object ConfirmDeleteHighlightRule : ReadBookIntent
     data object DismissDeleteHighlightRule : ReadBookIntent
+    data class MoveHighlightRule(val from: Int, val to: Int) : ReadBookIntent
+    data object SaveHighlightRuleOrder : ReadBookIntent
+    data class ImportHighlightRuleSource(val text: String) : ReadBookIntent
+    data object OpenHighlightRuleImportPicker : ReadBookIntent
+    data class HighlightRuleImportFileSelected(val uri: Uri) : ReadBookIntent
+    data object CancelHighlightRuleImport : ReadBookIntent
+    data class ToggleHighlightRuleImportSelection(val index: Int) : ReadBookIntent
+    data class ToggleHighlightRuleImportAll(val isSelected: Boolean) : ReadBookIntent
+    data class UpdateHighlightRuleImportItem(
+        val index: Int,
+        val rule: HighlightRule,
+    ) : ReadBookIntent
+    data object SaveImportedHighlightRules : ReadBookIntent
+    data object ExportHighlightRules : ReadBookIntent
+    data class ExportHighlightRulesToFile(val uri: Uri) : ReadBookIntent
 
     // Icon picker — file IO handled by ViewModel
     data class SaveMenuCustomIcon(val id: String, val uri: Uri) : ReadBookIntent
@@ -645,6 +661,8 @@ sealed interface ReadBookEffect {
     data object OpenHttpTtsImportPicker : ReadBookEffect
     data object OpenHttpTtsExportPicker : ReadBookEffect
     data class OpenHttpTtsLogin(val engineId: Long) : ReadBookEffect
+    data object OpenHighlightRuleImportPicker : ReadBookEffect
+    data object OpenHighlightRuleExportPicker : ReadBookEffect
 
     // Day/night toggle
     data object ToggleDayNight : ReadBookEffect
@@ -1128,6 +1146,9 @@ sealed interface ConfigUpdate {
     data class ReadBodyToLh(val value: Boolean) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.ReloadContent)
     }
+    data class DefaultSourceChangeAll(val value: Boolean) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class TextFullJustify(val value: Boolean) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.ReloadContent)
     }

+ 19 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -177,6 +177,16 @@ class ReadBookController(
         newRefs.readView.upBattery(activity.sysBattery)
     }
 
+    fun onMenuVisibilityChanged(visible: Boolean) {
+        val autoPager = refs?.readView?.autoPager ?: return
+        if (!autoPager.isRunning) return
+        if (visible) {
+            autoPager.pause()
+        } else {
+            autoPager.resume()
+        }
+    }
+
     fun onRouteInitialized() {
         applyReadBrightness()
         upScreenTimeOut()
@@ -261,7 +271,10 @@ class ReadBookController(
                 ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud)
             )
 
-            isAutoPage -> viewModel.onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.AutoRead))
+            isAutoPage -> {
+                refs?.readView?.autoPager?.pause()
+                viewModel.onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.AutoRead))
+            }
             state.isShowingSearchResult -> viewModel.onIntent(ReadBookIntent.ShowSearchMenu)
             else -> viewModel.onIntent(ReadBookIntent.ShowMenu)
         }
@@ -833,6 +846,8 @@ class ReadBookController(
             is ReadBookEffect.OpenHttpTtsImportPicker,
             is ReadBookEffect.OpenHttpTtsExportPicker,
             is ReadBookEffect.OpenHttpTtsLogin,
+            is ReadBookEffect.OpenHighlightRuleImportPicker,
+            is ReadBookEffect.OpenHighlightRuleExportPicker,
             is ReadBookEffect.TtsCacheCleared,
             is ReadBookEffect.ExportJson,
             // DB query + bookmark effects — handled by ViewModel, ignored here
@@ -908,6 +923,9 @@ class ReadBookController(
             stopAutoPage()
         } else {
             refs?.readView?.autoPager?.start()
+            if (viewModel.uiState.value.menuVisible) {
+                refs?.readView?.autoPager?.pause()
+            }
             viewModel.setAutoPage(true)
             onScreenOffTimerStart?.invoke()
         }

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

@@ -122,6 +122,10 @@ fun ReadBookRouteScreen(
                             state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass
                     )
 
+    LaunchedEffect(state.menuVisible) {
+        controller.onMenuVisibilityChanged(state.menuVisible)
+    }
+
     // ── ActivityResult Launchers ──────────────────────────────────────
 
     val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
@@ -235,6 +239,18 @@ fun ReadBookRouteScreen(
         uri?.let { viewModel.onIntent(ReadBookIntent.ExportHttpTtsToFile(it)) }
     }
 
+    val importHighlightRulePicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocument()
+    ) { uri ->
+        uri?.let { viewModel.onIntent(ReadBookIntent.HighlightRuleImportFileSelected(it)) }
+    }
+
+    val exportHighlightRulePicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.CreateDocument("application/json")
+    ) { uri ->
+        uri?.let { viewModel.onIntent(ReadBookIntent.ExportHighlightRulesToFile(it)) }
+    }
+
     val bookInfoLauncher = rememberLauncherForActivityResult(
         StartActivityContract(BookInfoActivity::class.java)
     ) { result ->
@@ -408,6 +424,19 @@ fun ReadBookRouteScreen(
                                 exportHttpTtsPicker.launch("httpTTS.json")
                             }
 
+                            is ReadBookEffect.OpenHighlightRuleImportPicker -> {
+                                importHighlightRulePicker.launch(
+                                    arrayOf(
+                                        "application/json",
+                                        "text/plain"
+                                    )
+                                )
+                            }
+
+                            is ReadBookEffect.OpenHighlightRuleExportPicker -> {
+                                exportHighlightRulePicker.launch("highlightRule.json")
+                            }
+
                             // All other effects — delegate to bridge (View/Window/Activity operations)
                             else -> controller.handleEffect(effect)
                         }

+ 256 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -83,6 +83,7 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.StringUtils
+import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.hexString
 import io.legado.app.utils.isAbsUrl
@@ -410,6 +411,7 @@ class ReadBookViewModel(
                             editingRule = null,
                             showNewRule = false,
                             deleteRule = null,
+                            importState = BaseImportUiState.Idle,
                         ),
                     )
                 } else {
@@ -658,6 +660,36 @@ class ReadBookViewModel(
             is ReadBookIntent.DismissDeleteHighlightRule -> _uiState.update {
                 it.copy(highlightRuleConfig = it.highlightRuleConfig.copy(deleteRule = null))
             }
+            is ReadBookIntent.MoveHighlightRule -> moveHighlightRule(intent.from, intent.to)
+            is ReadBookIntent.SaveHighlightRuleOrder -> {
+                saveHighlightRules(_uiState.value.highlightRuleConfig.rules)
+            }
+            is ReadBookIntent.ImportHighlightRuleSource -> {
+                importHighlightRuleSource(intent.text)
+            }
+            is ReadBookIntent.OpenHighlightRuleImportPicker -> {
+                _effects.tryEmit(ReadBookEffect.OpenHighlightRuleImportPicker)
+            }
+            is ReadBookIntent.HighlightRuleImportFileSelected -> {
+                importHighlightRuleFile(intent.uri)
+            }
+            is ReadBookIntent.CancelHighlightRuleImport -> cancelHighlightRuleImport()
+            is ReadBookIntent.ToggleHighlightRuleImportSelection -> {
+                toggleHighlightRuleImportSelection(intent.index)
+            }
+            is ReadBookIntent.ToggleHighlightRuleImportAll -> {
+                toggleHighlightRuleImportAll(intent.isSelected)
+            }
+            is ReadBookIntent.UpdateHighlightRuleImportItem -> {
+                updateHighlightRuleImportItem(intent.index, intent.rule)
+            }
+            is ReadBookIntent.SaveImportedHighlightRules -> saveImportedHighlightRules()
+            is ReadBookIntent.ExportHighlightRules -> {
+                _effects.tryEmit(ReadBookEffect.OpenHighlightRuleExportPicker)
+            }
+            is ReadBookIntent.ExportHighlightRulesToFile -> {
+                exportHighlightRules(intent.uri)
+            }
             is ReadBookIntent.SaveMenuCustomIcon -> saveMenuCustomIcon(intent.id, intent.uri)
             is ReadBookIntent.SaveTitleBarCustomIcon -> saveTitleBarCustomIcon(intent.id, intent.uri)
             is ReadBookIntent.OpenMenuCustomIconPicker -> {
@@ -3306,6 +3338,12 @@ class ReadBookViewModel(
                     readSettingsRepository.setReadBodyToLh(update.value)
                 }
             }
+            is ConfigUpdate.DefaultSourceChangeAll -> {
+                ReadConfig.defaultSourceChangeAll = update.value
+                viewModelScope.launch {
+                    readSettingsRepository.setDefaultSourceChangeAll(update.value)
+                }
+            }
             is ConfigUpdate.TextFullJustify -> {
                 ReadBookConfig.textFullJustify = update.value
                 viewModelScope.launch {
@@ -3526,6 +3564,223 @@ class ReadBookViewModel(
         }
     }
 
+    private fun moveHighlightRule(from: Int, to: Int) {
+        val rules = _uiState.value.highlightRuleConfig.rules
+        if (from !in rules.indices || to !in rules.indices) return
+        val reordered = rules.toMutableList().apply {
+            add(to, removeAt(from))
+        }
+        _uiState.update {
+            it.copy(
+                highlightRuleConfig = it.highlightRuleConfig.copy(
+                    rules = reordered.toImmutableList()
+                )
+            )
+        }
+    }
+
+    private fun importHighlightRuleSource(text: String) {
+        _uiState.update {
+            it.copy(
+                highlightRuleConfig = it.highlightRuleConfig.copy(
+                    importState = BaseImportUiState.Loading
+                )
+            )
+        }
+        execute {
+            val importedRules = importHighlightRuleSourceAwait(text.trim())
+                .map(highlightRuleRepository::sanitizeRule)
+            if (importedRules.isEmpty()) {
+                throw NoStackTraceException(context.getString(R.string.wrong_format))
+            }
+            val oldRules = highlightRuleRepository.load(ReadBookConfig.durConfig.name)
+                .associateBy { it.id }
+            BaseImportUiState.Success(
+                source = text,
+                items = importedRules.map { rule ->
+                    val oldRule = oldRules[rule.id]
+                    val status = when {
+                        oldRule == null -> ImportStatus.New
+                        oldRule != rule -> ImportStatus.Update
+                        else -> ImportStatus.Existing
+                    }
+                    ImportItemWrapper(
+                        data = rule,
+                        oldData = oldRule,
+                        status = status,
+                        isSelected = status != ImportStatus.Existing,
+                    )
+                }
+            )
+        }.onSuccess { importState ->
+            _uiState.update {
+                it.copy(
+                    highlightRuleConfig = it.highlightRuleConfig.copy(
+                        importState = importState
+                    )
+                )
+            }
+        }.onError {
+            AppLog.put("导入高亮规则失败\n${it.localizedMessage}", it, true)
+            _uiState.update { state ->
+                state.copy(
+                    highlightRuleConfig = state.highlightRuleConfig.copy(
+                        importState = BaseImportUiState.Error(
+                            it.localizedMessage ?: context.getString(R.string.wrong_format)
+                        )
+                    )
+                )
+            }
+        }
+    }
+
+    private suspend fun importHighlightRuleSourceAwait(text: String): List<HighlightRule> {
+        return when {
+            text.isJsonArray() -> GSON.fromJsonArray<HighlightRule>(text).getOrThrow()
+            text.isJsonObject() -> listOf(
+                GSON.fromJsonObject<HighlightRule>(text).getOrThrow()
+            )
+            text.isAbsUrl() -> {
+                val body = okHttpClient.newCallResponseBody {
+                    if (text.endsWith("#requestWithoutUA")) {
+                        url(text.substringBeforeLast("#requestWithoutUA"))
+                        header(AppConst.UA_NAME, "null")
+                    } else {
+                        url(text)
+                    }
+                }.decompressed().text()
+                importHighlightRuleSourceAwait(body)
+            }
+            else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
+        }
+    }
+
+    private fun importHighlightRuleFile(uri: Uri) {
+        execute<String?> {
+            context.contentResolver.openInputStream(uri)?.use {
+                it.reader().readText()
+            }
+        }.onSuccess { text ->
+            if (text.isNullOrBlank()) {
+                _uiState.update { state ->
+                    state.copy(
+                        highlightRuleConfig = state.highlightRuleConfig.copy(
+                            importState = BaseImportUiState.Error(
+                                context.getString(R.string.wrong_format)
+                            )
+                        )
+                    )
+                }
+            } else {
+                importHighlightRuleSource(text)
+            }
+        }.onError {
+            _uiState.update { state ->
+                state.copy(
+                    highlightRuleConfig = state.highlightRuleConfig.copy(
+                        importState = BaseImportUiState.Error(
+                            it.localizedMessage ?: context.getString(R.string.wrong_format)
+                        )
+                    )
+                )
+            }
+        }
+    }
+
+    private fun cancelHighlightRuleImport() {
+        _uiState.update {
+            it.copy(
+                highlightRuleConfig = it.highlightRuleConfig.copy(
+                    importState = BaseImportUiState.Idle
+                )
+            )
+        }
+    }
+
+    private fun toggleHighlightRuleImportSelection(index: Int) {
+        val importState = _uiState.value.highlightRuleConfig.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        if (index !in importState.items.indices) return
+        val items = importState.items.toMutableList()
+        val item = items[index]
+        items[index] = item.copy(isSelected = !item.isSelected)
+        _uiState.update {
+            it.copy(
+                highlightRuleConfig = it.highlightRuleConfig.copy(
+                    importState = importState.copy(items = items)
+                )
+            )
+        }
+    }
+
+    private fun toggleHighlightRuleImportAll(isSelected: Boolean) {
+        val importState = _uiState.value.highlightRuleConfig.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        _uiState.update {
+            it.copy(
+                highlightRuleConfig = it.highlightRuleConfig.copy(
+                    importState = importState.copy(
+                        items = importState.items.map { item ->
+                            item.copy(isSelected = isSelected)
+                        }
+                    )
+                )
+            )
+        }
+    }
+
+    private fun updateHighlightRuleImportItem(index: Int, rule: HighlightRule) {
+        val importState = _uiState.value.highlightRuleConfig.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        if (index !in importState.items.indices) return
+        val items = importState.items.toMutableList()
+        items[index] = items[index].copy(data = rule)
+        _uiState.update {
+            it.copy(
+                highlightRuleConfig = it.highlightRuleConfig.copy(
+                    importState = importState.copy(
+                        items = items,
+                        version = importState.version + 1,
+                    )
+                )
+            )
+        }
+    }
+
+    private fun saveImportedHighlightRules() {
+        val state = _uiState.value.highlightRuleConfig
+        val importState = state.importState
+            as? BaseImportUiState.Success<HighlightRule> ?: return
+        val importedRules = importState.items
+            .filter { it.isSelected }
+            .map { highlightRuleRepository.sanitizeRule(it.data) }
+        if (importedRules.isEmpty()) return
+        val importedById = importedRules.associateBy { it.id }
+        val mergedRules = state.rules.map { importedById[it.id] ?: it } +
+                importedRules.filter { imported -> state.rules.none { it.id == imported.id } }
+        saveHighlightRules(mergedRules)
+        cancelHighlightRuleImport()
+    }
+
+    private fun exportHighlightRules(uri: Uri) {
+        val rules = _uiState.value.highlightRuleConfig.rules
+        execute {
+            context.contentResolver.openOutputStream(uri)?.use { output ->
+                output.bufferedWriter().use { writer ->
+                    writer.write(GSON.toJson(rules))
+                }
+            } ?: throw NoStackTraceException(context.getString(R.string.error))
+        }.onSuccess {
+            _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.export_success)))
+        }.onError {
+            _effects.tryEmit(
+                ReadBookEffect.ShowToast(
+                    it.localizedMessage ?: context.getString(R.string.error)
+                )
+            )
+        }
+    }
+
     private fun loadHighlightRules() {
         val configName = ReadBookConfig.durConfig.name
         _uiState.update {
@@ -3535,6 +3790,7 @@ class ReadBookViewModel(
                     editingRule = null,
                     showNewRule = false,
                     deleteRule = null,
+                    importState = BaseImportUiState.Idle,
                 ),
             )
         }

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

@@ -25,6 +25,7 @@ import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.TextPageFactory
 import io.legado.app.ui.config.readConfig.ReadConfig
+import io.legado.app.ui.main.MainNavigator
 import io.legado.app.ui.widget.dialog.PhotoDialog
 import io.legado.app.utils.activity
 import io.legado.app.utils.dpToPx
@@ -96,7 +97,7 @@ class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, at
 
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
-        if (!isMainView) return
+        if (!isMainView || MainNavigator.backNavigationInProgress) return
         ChapterProvider.upViewSize(w, h)
         textPage.format()
     }

+ 132 - 18
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt

@@ -1,22 +1,33 @@
 package io.legado.app.ui.book.read.sheet
 
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
 import io.legado.app.R
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.repository.configNames
@@ -25,8 +36,15 @@ import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.widget.components.TinySwitch
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
+import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
+import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
+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.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.TinySettingItem
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
 
 @Composable
 fun HighlightRuleConfigSheet(
@@ -35,16 +53,105 @@ fun HighlightRuleConfigSheet(
     onDismissRequest: () -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
+    var showImportSheet by remember { mutableStateOf(false) }
+    var showSourceInput by remember { mutableStateOf(false) }
+    val listState = rememberLazyListState()
+    val hapticFeedback = LocalHapticFeedback.current
+    var orderChanged by remember { mutableStateOf(false) }
+    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+        orderChanged = true
+        onIntent(ReadBookIntent.MoveHighlightRule(from.index, to.index))
+        hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
+    }
+
+    LaunchedEffect(reorderableState.isAnyItemDragging) {
+        if (!reorderableState.isAnyItemDragging && orderChanged) {
+            orderChanged = false
+            onIntent(ReadBookIntent.SaveHighlightRuleOrder)
+        }
+    }
+
+    SourceInputDialog(
+        show = showSourceInput,
+        title = stringResource(R.string.import_on_line),
+        onDismissRequest = { showSourceInput = false },
+        onConfirm = {
+            showSourceInput = false
+            onIntent(ReadBookIntent.ImportHighlightRuleSource(it))
+        },
+    )
+    FilePickerSheet(
+        show = showImportSheet,
+        onDismissRequest = { showImportSheet = false },
+        title = stringResource(R.string.highlight_rule_config),
+        onSelectSysFile = {
+            showImportSheet = false
+            onIntent(ReadBookIntent.OpenHighlightRuleImportPicker)
+        },
+        onManualInput = {
+            showImportSheet = false
+            showSourceInput = true
+        },
+        allowExtensions = arrayOf("json", "txt"),
+    )
+    BatchImportDialog(
+        title = stringResource(R.string.highlight_rule_config),
+        importState = state.importState,
+        onDismissRequest = { onIntent(ReadBookIntent.CancelHighlightRuleImport) },
+        onToggleItem = {
+            onIntent(ReadBookIntent.ToggleHighlightRuleImportSelection(it))
+        },
+        onToggleAll = {
+            onIntent(ReadBookIntent.ToggleHighlightRuleImportAll(it))
+        },
+        onUpdateItem = { index, rule ->
+            onIntent(ReadBookIntent.UpdateHighlightRuleImportItem(index, rule))
+        },
+        onConfirm = {
+            onIntent(ReadBookIntent.SaveImportedHighlightRules)
+        },
+        itemTitle = { it.name.ifBlank { it.displayPattern() } },
+        itemSubtitle = { it.pattern.takeIf(String::isNotBlank) },
+    )
+
     AppModalBottomSheet(
         show = show,
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.highlight_rule_config),
-        endAction = {
+        startAction = {
             SmallTonalButton(
                 onClick = { onIntent(ReadBookIntent.AddHighlightRule) },
                 icon = Icons.Default.Add
             )
-        }
+        },
+        endAction = {
+            var expanded by remember { mutableStateOf(false) }
+            Box {
+                SmallTonalButton(
+                    onClick = { expanded = true },
+                    icon = Icons.Default.MoreVert
+                )
+                RoundDropdownMenu(
+                    expanded = expanded,
+                    onDismissRequest = { expanded = false },
+                ) {
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.import_action),
+                        onClick = {
+                            expanded = false
+                            showImportSheet = true
+                        },
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.export),
+                        onClick = {
+                            expanded = false
+                            onIntent(ReadBookIntent.ExportHighlightRules)
+                        },
+                    )
+                }
+            }
+        },
     ) {
         Column(
             modifier = Modifier
@@ -52,26 +159,34 @@ fun HighlightRuleConfigSheet(
                 .padding(bottom = 16.dp),
         ) {
             LazyColumn(
+                state = listState,
                 verticalArrangement = Arrangement.spacedBy(8.dp),
                 modifier = Modifier.weight(1f, fill = false),
             ) {
-                itemsIndexed(state.rules, key = { _, rule -> rule.id }) { _, rule ->
-                    HighlightRuleItem(
-                        rule = rule,
-                        onToggle = { enabled ->
-                            onIntent(ReadBookIntent.ToggleHighlightRule(rule, enabled))
-                        },
-                        onEditClick = { onIntent(ReadBookIntent.EditHighlightRule(rule)) },
-                        onDeleteClick = {
-                            onIntent(ReadBookIntent.RequestDeleteHighlightRule(rule))
-                        },
-                    )
+                items(state.rules, key = { it.id }) { rule ->
+                    ReorderableItem(reorderableState, key = rule.id) { isDragging ->
+                        HighlightRuleItem(
+                            rule = rule,
+                            onToggle = { enabled ->
+                                onIntent(ReadBookIntent.ToggleHighlightRule(rule, enabled))
+                            },
+                            onEditClick = {
+                                onIntent(ReadBookIntent.EditHighlightRule(rule))
+                            },
+                            onDeleteClick = {
+                                onIntent(ReadBookIntent.RequestDeleteHighlightRule(rule))
+                            },
+                            modifier = Modifier
+                                .longPressDraggableHandle()
+                                .zIndex(if (isDragging) 1f else 0f)
+                                .animateItem(),
+                        )
+                    }
                 }
             }
         }
     }
 
-    // Edit existing rule
     val editingRuleValue = state.editingRule
 
     HighlightRuleEditSheet(
@@ -83,7 +198,6 @@ fun HighlightRuleConfigSheet(
         },
     )
 
-    // Add new rule
     HighlightRuleEditSheet(
         show = show && state.showNewRule,
         rule = null,
@@ -93,7 +207,6 @@ fun HighlightRuleConfigSheet(
         },
     )
 
-    // Delete confirmation
     val deletingRule = state.deleteRule
     val sureDeleteText = stringResource(R.string.sure_delete)
     AppAlertDialog(
@@ -106,7 +219,6 @@ fun HighlightRuleConfigSheet(
         dismissText = stringResource(android.R.string.cancel),
         onDismiss = { onIntent(ReadBookIntent.DismissDeleteHighlightRule) },
     )
-
 }
 
 @Composable
@@ -115,12 +227,14 @@ private fun HighlightRuleItem(
     onToggle: (Boolean) -> Unit,
     onEditClick: () -> Unit,
     onDeleteClick: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val configLabel = rule.configName?.configNames()?.joinToString("、") ?: "全局"
     TinySettingItem(
         title = rule.name.ifBlank { rule.displayPattern() },
         description = "${rule.styleSummary()} · $configLabel",
         onClick = onEditClick,
+        modifier = modifier,
         trailingContent = {
             Row(verticalAlignment = Alignment.CenterVertically) {
                 TinySwitch(

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt

@@ -125,6 +125,9 @@ fun MoreConfigSheet(
                 onAutoChangeSourceChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.AutoChangeSource(it)))
                 },
+                onDefaultSourceChangeAllChange = {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.DefaultSourceChangeAll(it)))
+                },
                 onAutoSuggestDayNightChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.AutoSuggestDayNight(it)))
                 },
@@ -301,6 +304,7 @@ private fun OtherSettings(
     onSliderVibratorChange: (Boolean) -> Unit,
     onSelectVibratorChange: (Boolean) -> Unit,
     onAutoChangeSourceChange: (Boolean) -> Unit,
+    onDefaultSourceChangeAllChange: (Boolean) -> Unit,
     onAutoSuggestDayNightChange: (Boolean) -> Unit,
     onSelectTextChange: (Boolean) -> Unit,
     onNoAnimScrollPageChange: (Boolean) -> Unit,
@@ -333,6 +337,12 @@ private fun OtherSettings(
         checked = preferences.autoChangeSource,
         onCheckedChange = onAutoChangeSourceChange,
     )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.read_change_all),
+        description = stringResource(R.string.read_change_all_s),
+        checked = preferences.defaultSourceChangeAll,
+        onCheckedChange = onDefaultSourceChangeAllChange,
+    )
     TinySwitchSettingItem(
         title = stringResource(R.string.auto_switch_theme_reminder_title),
         description = stringResource(R.string.auto_switch_theme_reminder_desc),

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

@@ -14,7 +14,8 @@ import kotlinx.coroutines.launch
 
 object MainNavigator {
 
-    private var backNavigationInProgress = false
+    var backNavigationInProgress = false
+        private set
     private val navigationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
     private var backNavigationResetJob: Job? = null