Преглед изворни кода

feat: 添加高亮标签规则配置入口和导航

HapeLee пре 4 месеци
родитељ
комит
5195d51b08

+ 39 - 0
app/src/main/java/io/legado/app/data/dao/HighlightTagRuleDao.kt

@@ -0,0 +1,39 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import io.legado.app.data.entities.HighlightTagRule
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface HighlightTagRuleDao {
+
+    @Query("SELECT * FROM highlight_tag_rules ORDER BY `order` ASC")
+    fun flowAll(): Flow<List<HighlightTagRule>>
+
+    @Query("SELECT * FROM highlight_tag_rules WHERE id = :id")
+    suspend fun getById(id: Long): HighlightTagRule?
+
+    @Query("SELECT * FROM highlight_tag_rules WHERE id IN (:ids)")
+    suspend fun getByIds(ids: Set<Long>): List<HighlightTagRule>
+
+    @Query("SELECT * FROM highlight_tag_rules WHERE enabled = 1 ORDER BY `order` ASC")
+    suspend fun getEnabled(): List<HighlightTagRule>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insert(vararg rules: HighlightTagRule)
+
+    @Update
+    suspend fun update(vararg rules: HighlightTagRule)
+
+    @Delete
+    suspend fun delete(vararg rules: HighlightTagRule)
+
+    @Query("DELETE FROM highlight_tag_rules")
+    suspend fun deleteAll()
+
+}

+ 24 - 0
app/src/main/java/io/legado/app/data/entities/HighlightTagRule.kt

@@ -0,0 +1,24 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import io.legado.app.ui.widget.components.list.SelectableItem
+
+@Entity(tableName = "highlight_tag_rules")
+data class HighlightTagRule(
+    @PrimaryKey
+    var id: Long = System.currentTimeMillis(),
+    var title: String = "",
+    var pattern: String = "",
+    var enabled: Boolean = true,
+    var order: Int = 0,
+) {
+
+    override fun hashCode(): Int = id.hashCode()
+
+    override fun equals(other: Any?): Boolean {
+        if (other is HighlightTagRule) return id == other.id
+        return false
+    }
+
+}

+ 71 - 0
app/src/main/java/io/legado/app/data/repository/HighlightTagRuleRepository.kt

@@ -0,0 +1,71 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.HighlightTagRule
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+
+class HighlightTagRuleRepository {
+
+    private val dao = appDb.highlightTagRuleDao
+
+    fun flowAll(): Flow<List<HighlightTagRule>> {
+        return dao.flowAll()
+    }
+
+    suspend fun insert(vararg rule: HighlightTagRule) {
+        withContext(Dispatchers.IO) {
+            dao.insert(*rule)
+        }
+    }
+
+    suspend fun delete(vararg rule: HighlightTagRule) {
+        withContext(Dispatchers.IO) {
+            dao.delete(*rule)
+        }
+    }
+
+    suspend fun update(vararg rule: HighlightTagRule) {
+        withContext(Dispatchers.IO) {
+            dao.update(*rule)
+        }
+    }
+
+    suspend fun findById(id: Long): HighlightTagRule? = withContext(Dispatchers.IO) {
+        dao.getById(id)
+    }
+
+    suspend fun getByIds(ids: Set<Long>): List<HighlightTagRule> =
+        withContext(Dispatchers.IO) {
+            if (ids.isEmpty()) emptyList() else dao.getByIds(ids)
+        }
+
+    suspend fun enableByIds(ids: Set<Long>) = withContext(Dispatchers.IO) {
+        if (ids.isEmpty()) return@withContext
+        val rules = dao.getByIds(ids)
+        val updated = rules.map { it.copy(enabled = true) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun disableByIds(ids: Set<Long>) = withContext(Dispatchers.IO) {
+        if (ids.isEmpty()) return@withContext
+        val rules = dao.getByIds(ids)
+        val updated = rules.map { it.copy(enabled = false) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun deleteByIds(ids: Set<Long>) = withContext(Dispatchers.IO) {
+        if (ids.isEmpty()) return@withContext
+        val rules = dao.getByIds(ids)
+        dao.delete(*rules.toTypedArray())
+    }
+
+    suspend fun moveOrder(rules: List<HighlightTagRule>) = withContext(Dispatchers.IO) {
+        val updatedRules = rules.mapIndexed { index, rule ->
+            rule.copy(order = index)
+        }
+        dao.update(*updatedRules.toTypedArray())
+    }
+
+}

+ 66 - 0
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleContract.kt

@@ -0,0 +1,66 @@
+package io.legado.app.ui.highlightTagRule
+
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.HighlightTagRule
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.list.InteractionState
+import io.legado.app.ui.widget.components.list.ListUiState
+import io.legado.app.ui.widget.components.list.SelectableItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentSetOf
+
+@Stable
+data class HighlightTagRuleItemUi(
+    override val id: Long,
+    val displayName: String,
+    val pattern: String,
+    val isEnabled: Boolean,
+    val rule: HighlightTagRule
+) : SelectableItem<Long>
+
+@Stable
+data class HighlightTagRuleUiState(
+    override val items: ImmutableList<HighlightTagRuleItemUi> = persistentListOf(),
+    override val selectedIds: ImmutableSet<Long> = persistentSetOf(),
+    override val searchKey: String = "",
+    val interaction: InteractionState = InteractionState()
+) : ListUiState<HighlightTagRuleItemUi> {
+    override val isSearch: Boolean get() = interaction.isSearchMode
+    override val isLoading: Boolean get() = interaction.isUploading
+}
+
+sealed interface HighlightTagRuleIntent {
+    data class SetSearchMode(val active: Boolean) : HighlightTagRuleIntent
+    data class UpdateSearchQuery(val query: String) : HighlightTagRuleIntent
+    data object ClearSelection : HighlightTagRuleIntent
+    data object SelectAll : HighlightTagRuleIntent
+    data object InvertSelection : HighlightTagRuleIntent
+    data class SetSelection(val ids: Set<Long>) : HighlightTagRuleIntent
+    data class ToggleSelection(val id: Long) : HighlightTagRuleIntent
+    data object EnableSelection : HighlightTagRuleIntent
+    data object DisableSelection : HighlightTagRuleIntent
+    data object DeleteSelection : HighlightTagRuleIntent
+    data object UploadSelection : HighlightTagRuleIntent
+    data class ExportSelection(val uri: android.net.Uri) : HighlightTagRuleIntent
+    data class MoveItem(val from: Int, val to: Int) : HighlightTagRuleIntent
+    data object SaveSortOrder : HighlightTagRuleIntent
+    data class SaveRule(val rule: HighlightTagRule, val isNew: Boolean) : HighlightTagRuleIntent
+    data class DeleteRule(val rule: HighlightTagRule) : HighlightTagRuleIntent
+    data class SetRuleEnabled(val rule: HighlightTagRule, val enabled: Boolean) : HighlightTagRuleIntent
+    data class CopyRule(val rule: HighlightTagRule) : HighlightTagRuleIntent
+    data class ImportSource(val text: String) : HighlightTagRuleIntent
+    data object CancelImport : HighlightTagRuleIntent
+    data class ToggleImportSelection(val index: Int) : HighlightTagRuleIntent
+    data class ToggleImportAll(val isSelected: Boolean) : HighlightTagRuleIntent
+    data class UpdateImportItem(val index: Int, val rule: HighlightTagRule) : HighlightTagRuleIntent
+    data object SaveImportedRules : HighlightTagRuleIntent
+}
+
+sealed interface HighlightTagRuleEffect
+
+data class HighlightTagRuleRenderState(
+    val uiState: HighlightTagRuleUiState,
+    val importState: BaseImportUiState<HighlightTagRule>,
+)

+ 176 - 0
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleEditSheet.kt

@@ -0,0 +1,176 @@
+package io.legado.app.ui.highlightTagRule
+
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.NoteAdd
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentPaste
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.data.entities.HighlightTagRule
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.AdaptiveSwitch
+import io.legado.app.ui.widget.components.button.series.MediumPlainButton
+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.text.AppText
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HighlightTagRuleEditSheet(
+    show: Boolean,
+    rule: HighlightTagRule?,
+    onDismissRequest: () -> Unit,
+    onSave: (HighlightTagRule) -> Unit,
+    onCopy: (HighlightTagRule) -> Unit,
+    onPaste: () -> HighlightTagRule?,
+) {
+    val scope = rememberCoroutineScope()
+
+    val isNew = rule == null || rule.id == 0L
+    val initial = remember(rule) {
+        rule ?: HighlightTagRule()
+    }
+
+    var title by remember(initial) { mutableStateOf(initial.title) }
+    var pattern by remember(initial) { mutableStateOf(initial.pattern) }
+    var enabled by remember(initial) { mutableStateOf(initial.enabled) }
+
+    var showMenu by remember { mutableStateOf(false) }
+
+    fun getCurrentRule(): HighlightTagRule {
+        return initial.copy(
+            title = title,
+            pattern = pattern,
+            enabled = enabled
+        )
+    }
+
+    AppModalBottomSheet(
+        title = if (isNew) {
+            stringResource(R.string.highlight_tag_add_rule)
+        } else {
+            stringResource(R.string.highlight_tag_edit_rule)
+        },
+        startAction = {
+            MediumPlainButton(
+                onClick = onDismissRequest,
+                icon = Icons.Default.Close,
+                contentDescription = "Close",
+            )
+        },
+        endAction = {
+            Box {
+                MediumPlainButton(
+                    onClick = { showMenu = true },
+                    icon = Icons.Default.MoreVert,
+                    contentDescription = "More"
+                )
+                RoundDropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.copy_rule),
+                        leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, null) },
+                        onClick = {
+                            onCopy(getCurrentRule())
+                            showMenu = false
+                        }
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.paste_rule),
+                        leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
+                        onClick = {
+                            scope.launch {
+                                onPaste()?.let { pasted ->
+                                    title = pasted.title
+                                    pattern = pasted.pattern
+                                    enabled = pasted.enabled
+                                }
+                            }
+                            showMenu = false
+                        }
+                    )
+                }
+            }
+        },
+        show = show,
+        onDismissRequest = onDismissRequest
+    ) {
+        Box(modifier = Modifier.fillMaxWidth()) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(bottom = 120.dp)
+                    .verticalScroll(rememberScrollState()),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                AppTextField(
+                    modifier = Modifier.fillMaxWidth(),
+                    value = title,
+                    onValueChange = { title = it },
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.highlight_tag_title),
+                    singleLine = true
+                )
+                AppTextField(
+                    modifier = Modifier.fillMaxWidth(),
+                    value = pattern,
+                    onValueChange = { pattern = it },
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.highlight_tag_pattern),
+                    minLines = 3
+                )
+
+                Spacer(modifier = Modifier.height(8.dp))
+
+                Row(
+                    modifier = Modifier.fillMaxWidth(),
+                    horizontalArrangement = Arrangement.SpaceBetween,
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    AppText(
+                        text = stringResource(R.string.enabled),
+                        style = LegadoTheme.typography.bodyMedium
+                    )
+                    AdaptiveSwitch(
+                        checked = enabled,
+                        onCheckedChange = { enabled = it }
+                    )
+                }
+            }
+
+            AppFloatingActionButton(
+                onClick = { onSave(getCurrentRule()) },
+                modifier = Modifier
+                    .align(Alignment.BottomEnd)
+                    .padding(16.dp),
+                icon = Icons.Default.Save
+            )
+        }
+    }
+}

+ 334 - 0
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleScreen.kt

@@ -0,0 +1,334 @@
+package io.legado.app.ui.highlightTagRule
+
+import android.content.ClipData
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+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.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+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.HighlightTagRule
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.ActionItem
+import io.legado.app.ui.widget.components.DraggableSelectionHandler
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
+import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+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 HighlightTagRuleScreen(
+    viewModel: HighlightTagRuleViewModel = koinViewModel(),
+    onBackClick: () -> Unit
+) {
+    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    val importState by viewModel.importState.collectAsStateWithLifecycle()
+
+    HighlightTagRuleContent(
+        state = uiState,
+        importState = importState,
+        events = viewModel.events,
+        onIntent = viewModel::onIntent,
+        onPasteRule = viewModel::pasteRule,
+        onBackClick = onBackClick,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun HighlightTagRuleContent(
+    state: HighlightTagRuleUiState,
+    importState: BaseImportUiState<HighlightTagRule>,
+    events: Flow<BaseRuleEvent>,
+    onIntent: (HighlightTagRuleIntent) -> Unit,
+    onPasteRule: () -> HighlightTagRule?,
+    onBackClick: () -> Unit,
+) {
+
+    val context = LocalContext.current
+
+    val rules = state.items
+    val selectedIds = state.selectedIds
+    val inSelectionMode = selectedIds.isNotEmpty()
+
+    val listState = rememberLazyListState()
+    val hapticFeedback = LocalHapticFeedback.current
+
+    var showEditSheet by remember { mutableStateOf(false) }
+    var editingRule by remember { mutableStateOf<HighlightTagRule?>(null) }
+    var showDeleteRuleDialog by remember { mutableStateOf<HighlightTagRule?>(null) }
+    var showUrlInput by remember { mutableStateOf(false) }
+
+    var showImportSheet by remember { mutableStateOf(false) }
+    var showExportSheet by remember { mutableStateOf(false) }
+
+
+    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+        onIntent(HighlightTagRuleIntent.MoveItem(from.index, to.index))
+        hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
+    }
+
+    val clipboardManager = LocalClipboard.current
+    val snackbarHostState = remember { SnackbarHostState() }
+
+    LaunchedEffect(Unit) {
+        events.collect { event ->
+            when (event) {
+                is BaseRuleEvent.ShowSnackbar -> {
+                    val result = snackbarHostState.showSnackbar(
+                        message = event.message,
+                        actionLabel = event.actionLabel,
+                        withDismissAction = true
+                    )
+                    if (result == SnackbarResult.ActionPerformed && event.url != null) {
+                        clipboardManager.setClipEntry(
+                            ClipEntry(
+                                ClipData.newPlainText(
+                                    "url",
+                                    event.url
+                                )
+                            )
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    val importDoc = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocument(),
+        onResult = { uri ->
+            uri?.let {
+                context.contentResolver.openInputStream(it)?.use { stream ->
+                    val text = stream.reader().readText()
+                    onIntent(HighlightTagRuleIntent.ImportSource(text))
+                }
+            }
+        }
+    )
+
+    val exportDoc = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.CreateDocument("application/json"),
+        onResult = { uri ->
+            uri?.let { onIntent(HighlightTagRuleIntent.ExportSelection(it)) }
+        }
+    )
+
+    SourceInputDialog(
+        show = showUrlInput,
+        title = stringResource(R.string.import_on_line),
+        onDismissRequest = { showUrlInput = false },
+        onConfirm = {
+            showUrlInput = false
+            onIntent(HighlightTagRuleIntent.ImportSource(it))
+        }
+    )
+
+    FilePickerSheet(
+        show = showExportSheet,
+        onDismissRequest = { showExportSheet = false },
+        title = stringResource(R.string.export),
+        onSelectSysDir = {
+            showExportSheet = false
+            exportDoc.launch("exportHighlightTagRule.json")
+        },
+        onUpload = {
+            showExportSheet = false
+            onIntent(HighlightTagRuleIntent.UploadSelection)
+        },
+        allowExtensions = arrayOf("json")
+    )
+
+
+    FilePickerSheet(
+        show = showImportSheet,
+        onDismissRequest = { showImportSheet = false },
+        title = stringResource(R.string.import_str),
+        onSelectSysFile = { types ->
+            importDoc.launch(types)
+            showImportSheet = false
+        },
+        onManualInput = {
+            showUrlInput = true
+            showImportSheet = false
+        },
+        allowExtensions = arrayOf("json", "txt")
+    )
+
+    BatchImportDialog(
+        title = stringResource(R.string.import_highlight_tag_rule),
+        importState = importState,
+        onDismissRequest = { onIntent(HighlightTagRuleIntent.CancelImport) },
+        onToggleItem = { onIntent(HighlightTagRuleIntent.ToggleImportSelection(it)) },
+        onToggleAll = { onIntent(HighlightTagRuleIntent.ToggleImportAll(it)) },
+        onUpdateItem = { index, rule -> onIntent(HighlightTagRuleIntent.UpdateImportItem(index, rule)) },
+        onConfirm = { onIntent(HighlightTagRuleIntent.SaveImportedRules) },
+        itemTitle = { rule -> rule.title.ifBlank { rule.pattern } },
+        itemSubtitle = { rule -> rule.pattern.takeIf { it.isNotBlank() } }
+    )
+
+    LaunchedEffect(reorderableState.isAnyItemDragging) {
+        if (!reorderableState.isAnyItemDragging) {
+            onIntent(HighlightTagRuleIntent.SaveSortOrder)
+        }
+    }
+
+    AppAlertDialog(
+        data = showDeleteRuleDialog,
+        onDismissRequest = { showDeleteRuleDialog = null },
+        title = stringResource(R.string.delete),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { rule ->
+            onIntent(HighlightTagRuleIntent.DeleteRule(rule))
+            showDeleteRuleDialog = null
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showDeleteRuleDialog = null }
+    )
+
+    HighlightTagRuleEditSheet(
+        show = showEditSheet,
+        rule = editingRule,
+        onDismissRequest = {
+            showEditSheet = false
+            editingRule = null
+        },
+        onSave = { updatedRule ->
+            onIntent(HighlightTagRuleIntent.SaveRule(updatedRule, isNew = editingRule == null))
+            showEditSheet = false
+            editingRule = null
+        },
+        onCopy = { onIntent(HighlightTagRuleIntent.CopyRule(it)) },
+        onPaste = onPasteRule
+    )
+
+    RuleListScaffold(
+        title = stringResource(R.string.highlight_tag_config),
+        state = state,
+        onBackClick = { onBackClick() },
+        onSearchToggle = { active ->
+            onIntent(HighlightTagRuleIntent.SetSearchMode(active))
+        },
+        onSearchQueryChange = { onIntent(HighlightTagRuleIntent.UpdateSearchQuery(it)) },
+        searchPlaceholder = stringResource(R.string.replace_purify_search),
+        onClearSelection = { onIntent(HighlightTagRuleIntent.ClearSelection) },
+        onSelectAll = { onIntent(HighlightTagRuleIntent.SelectAll) },
+        onSelectInvert = {
+            onIntent(HighlightTagRuleIntent.InvertSelection)
+        },
+        selectionSecondaryActions = listOf(
+            ActionItem(text = stringResource(R.string.enable), onClick = {
+                onIntent(HighlightTagRuleIntent.EnableSelection)
+            }),
+            ActionItem(text = stringResource(R.string.disable_selection), onClick = {
+                onIntent(HighlightTagRuleIntent.DisableSelection)
+            }),
+            ActionItem(
+                text = stringResource(R.string.export),
+                onClick = { showExportSheet = true })
+        ),
+        onDeleteSelected = { ids ->
+            @Suppress("UNCHECKED_CAST")
+            onIntent(HighlightTagRuleIntent.SetSelection(ids as Set<Long>))
+            onIntent(HighlightTagRuleIntent.DeleteSelection)
+        },
+        onAddClick = {
+            editingRule = null
+            showEditSheet = true
+        },
+        snackbarHostState = snackbarHostState,
+        dropDownMenuContent = { dismiss ->
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.import_str),
+                onClick = { showImportSheet = true; dismiss() }
+            )
+        }
+    ) { paddingValues ->
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+        ) {
+            FastScrollLazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                state = listState,
+                contentPadding = adaptiveContentPadding(
+                    top = paddingValues.calculateTopPadding(),
+                    bottom = 120.dp
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                items(rules, key = { it.id }) { item ->
+                    ReorderableSelectionItem(
+                        state = reorderableState,
+                        key = item.id,
+                        title = item.displayName,
+                        subtitle = item.pattern,
+                        isEnabled = item.isEnabled,
+                        isSelected = selectedIds.contains(item.id),
+                        inSelectionMode = inSelectionMode,
+                        onToggleSelection = { onIntent(HighlightTagRuleIntent.ToggleSelection(item.id)) },
+                        onEnabledChange = { enabled ->
+                            onIntent(HighlightTagRuleIntent.SetRuleEnabled(item.rule, enabled))
+                        },
+                        onClickEdit = { editingRule = item.rule; showEditSheet = true },
+                        trailingAction = {
+                            SmallPlainButton(
+                                onClick = { showDeleteRuleDialog = item.rule },
+                                icon = AppIcons.Delete
+                            )
+                        }
+                    )
+                }
+            }
+            if (inSelectionMode) {
+                DraggableSelectionHandler(
+                    listState = listState,
+                    items = rules,
+                    selectedIds = selectedIds,
+                    onSelectionChange = { onIntent(HighlightTagRuleIntent.SetSelection(it)) },
+                    idProvider = { it.id },
+                    modifier = Modifier
+                        .fillMaxHeight()
+                        .width(60.dp)
+                        .align(Alignment.TopStart)
+                )
+            }
+        }
+    }
+}

+ 219 - 0
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleViewModel.kt

@@ -0,0 +1,219 @@
+package io.legado.app.ui.highlightTagRule
+
+import android.app.Application
+import androidx.lifecycle.viewModelScope
+import io.legado.app.base.BaseRuleViewModel
+import io.legado.app.data.entities.HighlightTagRule
+import io.legado.app.data.repository.HighlightTagRuleRepository
+import io.legado.app.data.repository.UploadRepository
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.list.InteractionState
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import io.legado.app.utils.fromJsonObject
+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.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class HighlightTagRuleViewModel(
+    application: Application,
+    uploadRepository: UploadRepository
+) : BaseRuleViewModel<HighlightTagRuleItemUi, HighlightTagRule, Long, HighlightTagRuleUiState>(
+    application,
+    HighlightTagRuleUiState(interaction = InteractionState(isLoading = true)),
+    uploadRepository
+) {
+    private val repository = HighlightTagRuleRepository()
+
+    override val rawDataFlow: Flow<List<HighlightTagRule>> = repository.flowAll()
+
+    fun onIntent(intent: HighlightTagRuleIntent) {
+        when (intent) {
+            is HighlightTagRuleIntent.SetSearchMode -> setSearchMode(intent.active)
+            is HighlightTagRuleIntent.UpdateSearchQuery -> setSearchKey(intent.query)
+            HighlightTagRuleIntent.ClearSelection -> setSelection(emptySet())
+            HighlightTagRuleIntent.SelectAll -> selectAll()
+            HighlightTagRuleIntent.InvertSelection -> invertSelection()
+            is HighlightTagRuleIntent.SetSelection -> setSelection(intent.ids)
+            is HighlightTagRuleIntent.ToggleSelection -> toggleSelection(intent.id)
+            HighlightTagRuleIntent.EnableSelection -> {
+                enableSelectionByIds(uiState.value.selectedIds)
+                setSelection(emptySet())
+            }
+            HighlightTagRuleIntent.DisableSelection -> {
+                disableSelectionByIds(uiState.value.selectedIds)
+                setSelection(emptySet())
+            }
+            HighlightTagRuleIntent.DeleteSelection -> {
+                delSelectionByIds(uiState.value.selectedIds)
+                setSelection(emptySet())
+            }
+            HighlightTagRuleIntent.UploadSelection -> {
+                val state = uiState.value
+                uploadSelectedRules(state.selectedIds, state.items)
+            }
+            is HighlightTagRuleIntent.ExportSelection -> {
+                val state = uiState.value
+                exportToUri(intent.uri, state.items, state.selectedIds)
+            }
+            is HighlightTagRuleIntent.MoveItem -> moveItemInList(intent.from, intent.to)
+            HighlightTagRuleIntent.SaveSortOrder -> saveSortOrder()
+            is HighlightTagRuleIntent.SaveRule -> {
+                if (intent.isNew) {
+                    insert(intent.rule)
+                } else {
+                    update(intent.rule)
+                }
+            }
+            is HighlightTagRuleIntent.DeleteRule -> delete(intent.rule)
+            is HighlightTagRuleIntent.SetRuleEnabled -> update(intent.rule.copy(enabled = intent.enabled))
+            is HighlightTagRuleIntent.CopyRule -> copyRule(intent.rule)
+            is HighlightTagRuleIntent.ImportSource -> importSource(intent.text)
+            HighlightTagRuleIntent.CancelImport -> cancelImport()
+            is HighlightTagRuleIntent.ToggleImportSelection -> toggleImportSelection(intent.index)
+            is HighlightTagRuleIntent.ToggleImportAll -> toggleImportAll(intent.isSelected)
+            is HighlightTagRuleIntent.UpdateImportItem -> updateImportItem(intent.index, intent.rule)
+            HighlightTagRuleIntent.SaveImportedRules -> saveImportedRules()
+        }
+    }
+
+    override fun filterData(
+        data: List<HighlightTagRule>,
+        searchKey: String,
+        groupFilter: String
+    ): List<HighlightTagRule> {
+        val key = groupFilter.ifEmpty { searchKey }
+        val filtered = if (key.isEmpty()) data else {
+            data.filter {
+                it.title.contains(key, ignoreCase = true) ||
+                        it.pattern.contains(key, ignoreCase = true)
+            }
+        }
+        return filtered.sortedBy { it.order }
+    }
+
+    override fun composeUiState(
+        items: List<HighlightTagRuleItemUi>,
+        selectedIds: Set<Long>,
+        isSearch: Boolean,
+        isUploading: Boolean,
+        importState: BaseImportUiState<HighlightTagRule>
+    ): HighlightTagRuleUiState {
+        return HighlightTagRuleUiState(
+            items = items.toImmutableList(),
+            selectedIds = selectedIds.toImmutableSet(),
+            searchKey = _searchKey.value,
+            interaction = InteractionState(
+                isSearchMode = isSearch,
+                isUploading = isUploading || (importState is BaseImportUiState.Loading),
+                isLoading = false
+            )
+        )
+    }
+
+    override fun HighlightTagRule.toUiItem() = HighlightTagRuleItemUi(
+        id = id,
+        displayName = title.ifBlank { pattern },
+        pattern = pattern,
+        isEnabled = enabled,
+        rule = this
+    )
+
+    override fun ruleItemToEntity(item: HighlightTagRuleItemUi): HighlightTagRule = item.rule
+
+    override suspend fun generateJson(entities: List<HighlightTagRule>): String = GSON.toJson(entities)
+
+    override fun parseImportRules(text: String): List<HighlightTagRule> {
+        return when {
+            text.isJsonArray() -> GSON.fromJsonArray<HighlightTagRule>(text).getOrThrow()
+            text.isJsonObject() -> listOf(GSON.fromJsonObject<HighlightTagRule>(text).getOrThrow())
+            else -> throw Exception("格式不正确")
+        }
+    }
+
+    override fun hasChanged(newRule: HighlightTagRule, oldRule: HighlightTagRule): Boolean {
+        return newRule.title != oldRule.title
+                || newRule.pattern != oldRule.pattern
+                || newRule.enabled != oldRule.enabled
+    }
+
+    override suspend fun findOldRule(newRule: HighlightTagRule): HighlightTagRule? {
+        return repository.findById(newRule.id)
+    }
+
+    override fun saveImportedRules() {
+        val state = _importState.value as? BaseImportUiState.Success<HighlightTagRule> ?: return
+        viewModelScope.launch(Dispatchers.IO) {
+            val rulesToSave = state.items
+                .filter { it.isSelected }
+                .map { it.data }
+            repository.insert(*rulesToSave.toTypedArray())
+            withContext(Dispatchers.Main) {
+                _importState.value = BaseImportUiState.Idle
+            }
+        }
+    }
+
+    fun saveSortOrder() {
+        val currentLocal = _localItems.value ?: return
+        viewModelScope.launch {
+            repository.moveOrder(currentLocal.map { it.rule })
+            _localItems.value = null
+        }
+    }
+
+    fun enableSelectionByIds(ids: Set<Long>) {
+        viewModelScope.launch { repository.enableByIds(ids) }
+    }
+
+    fun disableSelectionByIds(ids: Set<Long>) {
+        viewModelScope.launch { repository.disableByIds(ids) }
+    }
+
+    fun delSelectionByIds(ids: Set<Long>) {
+        viewModelScope.launch {
+            repository.deleteByIds(ids)
+            _selectedIds.update { it - ids }
+        }
+    }
+
+    private fun selectAll() {
+        setSelection(uiState.value.items.map { it.id }.toSet())
+    }
+
+    private fun invertSelection() {
+        val state = uiState.value
+        setSelection(state.items.map { it.id }.toSet() - state.selectedIds)
+    }
+
+    fun update(vararg rule: HighlightTagRule) = viewModelScope.launch { repository.update(*rule) }
+    fun insert(vararg rule: HighlightTagRule) = viewModelScope.launch { repository.insert(*rule) }
+    fun delete(vararg rule: HighlightTagRule) = viewModelScope.launch { repository.delete(*rule) }
+
+    fun copyRule(rule: HighlightTagRule) {
+        context.sendToClip(GSON.toJson(rule))
+    }
+
+    fun pasteRule(): HighlightTagRule? {
+        val text = context.getClipText()
+        if (text.isNullOrBlank()) {
+            context.toastOnUi("剪贴板没有内容")
+            return null
+        }
+        return try {
+            GSON.fromJsonObject<HighlightTagRule>(text).getOrThrow()
+        } catch (e: Exception) {
+            context.toastOnUi("格式不对")
+            null
+        }
+    }
+}

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

@@ -56,6 +56,7 @@ import io.legado.app.ui.config.readConfig.ReadConfigScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
 import io.legado.app.ui.config.themeManage.ThemeManageScreen
 import io.legado.app.ui.config.translation.TranslationConfigScreen
+import io.legado.app.ui.highlightTagRule.HighlightTagRuleScreen
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
 import io.legado.app.ui.rss.favorites.RssFavoritesScreen
@@ -158,6 +159,9 @@ fun MainActivity.mainEntryProvider(
             onNavigateToReadRecord = {
                 onNavigateToRoute(MainRouteReadRecord)
             },
+            onNavigateToHighlightTagRule = {
+                onNavigateToRoute(MainRouteHighlightTagRule)
+            },
             onNavigateToAbout = {
                 onNavigateToRoute(MainRouteAbout)
             },
@@ -618,6 +622,12 @@ fun MainActivity.mainEntryProvider(
         )
     }
 
+    entry<MainRouteHighlightTagRule> {
+        HighlightTagRuleScreen(
+            onBackClick = { onNavigateBack() }
+        )
+    }
+
     entry<MainRouteAbout> {
         val viewModel = koinViewModel<AboutViewModel>()
         val context = LocalContext.current

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -104,6 +104,9 @@ data class MainRouteSearchContent(
     val searchResultIndex: Int = 0,
 ) : MainRoute
 
+@Serializable
+data object MainRouteHighlightTagRule : MainRoute
+
 @Serializable
 data object MainRouteAbout : MainRoute
 

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

@@ -158,6 +158,7 @@ object MainNavigator {
                 }
             }
 
+            MainRouteHighlightTagRule,
             MainRouteReadRecord -> {
                 if (currentRoute == MainRouteHome) {
                     backStack.add(route)

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

@@ -117,6 +117,7 @@ fun MainScreen(
     onNavigateToRssFavorites: () -> Unit,
     onNavigateToRuleSub: () -> Unit,
     onNavigateToReadRecord: () -> Unit,
+    onNavigateToHighlightTagRule: () -> Unit,
     onNavigateToAbout: () -> Unit,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -156,6 +157,7 @@ fun MainScreen(
 
                 MainEffect.ExitApp -> (context as? ComponentActivity)?.finish()
                 MainEffect.NavigateToReadRecord -> onNavigateToReadRecord()
+                MainEffect.NavigateToHighlightTagRule -> onNavigateToHighlightTagRule()
                 MainEffect.NavigateToAbout -> onNavigateToAbout()
             }
         }

+ 3 - 0
app/src/main/java/io/legado/app/ui/main/MainViewModel.kt

@@ -99,6 +99,8 @@ class MainViewModel(
 
             PrefClickEvent.OpenReadRecord -> _effects.tryEmit(MainEffect.NavigateToReadRecord)
 
+            PrefClickEvent.OpenHighlightTagRule -> _effects.tryEmit(MainEffect.NavigateToHighlightTagRule)
+
             PrefClickEvent.OpenAbout -> _effects.tryEmit(MainEffect.NavigateToAbout)
 
             else -> Unit
@@ -118,6 +120,7 @@ sealed interface MainEffect {
 
     data object ExitApp : MainEffect
     data object NavigateToReadRecord : MainEffect
+    data object NavigateToHighlightTagRule : MainEffect
     data object NavigateToAbout : MainEffect
 }
 

+ 6 - 1
app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt

@@ -30,6 +30,7 @@ import androidx.compose.material.icons.filled.Folder
 import androidx.compose.material.icons.filled.History
 import androidx.compose.material.icons.filled.Info
 import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material.icons.filled.Sell
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.Source
 import androidx.compose.material.icons.filled.Web
@@ -71,7 +72,6 @@ fun MyScreen(
 
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
-
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         contentWindowInsets = WindowInsets.systemBars
@@ -160,6 +160,11 @@ fun MyScreen(
                         )
                     }
                 )
+                ClickableSettingItem(
+                    title = stringResource(R.string.highlight_tag_config),
+                    imageVector = Icons.Default.Sell,
+                    onClick = { onNavigate(PrefClickEvent.OpenHighlightTagRule) }
+                )
             }
 
             SplicedColumnGroup(

+ 1 - 0
app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt

@@ -26,6 +26,7 @@ sealed class PrefClickEvent {
     data class StartActivity(val destination: Class<*>, val configTag: String? = null) : PrefClickEvent()
     object OpenReadRecord : PrefClickEvent()
     object OpenBookCacheManage : PrefClickEvent()
+    object OpenHighlightTagRule : PrefClickEvent()
     object OpenAbout : PrefClickEvent()
     object ToggleWebService : PrefClickEvent()
     object ExitApp : PrefClickEvent()

+ 88 - 0
app/src/main/java/io/legado/app/ui/widget/components/card/HighlightTagRow.kt

@@ -0,0 +1,88 @@
+package io.legado.app.ui.widget.components.card
+
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.text.AnimatedTextLine
+import io.legado.app.ui.book.info.HighlightedTag
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun HighlightTagRow(
+    tags: List<HighlightedTag>,
+    modifier: Modifier = Modifier,
+) {
+    if (tags.isEmpty()) return
+
+    if (tags.size == 1) {
+        HighlightTagItem(tag = tags[0])
+    } else {
+        Row(
+            modifier = modifier
+                .fillMaxWidth()
+                .height(IntrinsicSize.Max),
+            horizontalArrangement = Arrangement.SpaceEvenly,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            tags.forEachIndexed { index, tag ->
+                if (index > 0) {
+                    VerticalDivider(
+                        modifier = Modifier
+                            .padding(horizontal = 12.dp)
+                            .fillMaxHeight(0.5f),
+                        thickness = 0.5.dp,
+                        color = LegadoTheme.colorScheme.outlineVariant,
+                    )
+                }
+                HighlightTagItem(
+                    tag = tag,
+                    modifier = Modifier.weight(1f),
+                )
+            }
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun HighlightTagItem(
+    tag: HighlightedTag,
+    modifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier.padding(vertical = 8.dp),
+        horizontalAlignment = Alignment.Start,
+    ) {
+        tag.title?.let {
+            AnimatedTextLine(
+                text = it,
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        }
+
+        val label = tag.matchedLabels.joinToString(" · ")
+        AnimatedTextLine(
+            text = label,
+            modifier = Modifier.basicMarquee(),
+            style = LegadoTheme.typography.titleMediumEmphasized,
+            color = LegadoTheme.colorScheme.secondary,
+            maxLines = 1,
+        )
+    }
+}