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

This commit is contained in:
HapeLee
2026-06-27 02:27:42 +08:00
parent 64aeb33f91
commit 5195d51b08
15 changed files with 1043 additions and 1 deletions
@@ -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()
}
@@ -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
}
}
@@ -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())
}
}
@@ -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>,
)
@@ -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
)
}
}
}
@@ -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)
)
}
}
}
}
@@ -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
}
}
}
@@ -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
@@ -104,6 +104,9 @@ data class MainRouteSearchContent(
val searchResultIndex: Int = 0,
) : MainRoute
@Serializable
data object MainRouteHighlightTagRule : MainRoute
@Serializable
data object MainRouteAbout : MainRoute
@@ -158,6 +158,7 @@ object MainNavigator {
}
}
MainRouteHighlightTagRule,
MainRouteReadRecord -> {
if (currentRoute == MainRouteHome) {
backStack.add(route)
@@ -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()
}
}
@@ -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
}
@@ -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(
@@ -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()
@@ -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,
)
}
}