diff --git a/app/src/main/java/io/legado/app/data/dao/HighlightTagRuleDao.kt b/app/src/main/java/io/legado/app/data/dao/HighlightTagRuleDao.kt new file mode 100644 index 000000000..18fbfb1c7 --- /dev/null +++ b/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> + + @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): List + + @Query("SELECT * FROM highlight_tag_rules WHERE enabled = 1 ORDER BY `order` ASC") + suspend fun getEnabled(): List + + @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() + +} diff --git a/app/src/main/java/io/legado/app/data/entities/HighlightTagRule.kt b/app/src/main/java/io/legado/app/data/entities/HighlightTagRule.kt new file mode 100644 index 000000000..12e32a6b3 --- /dev/null +++ b/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 + } + +} diff --git a/app/src/main/java/io/legado/app/data/repository/HighlightTagRuleRepository.kt b/app/src/main/java/io/legado/app/data/repository/HighlightTagRuleRepository.kt new file mode 100644 index 000000000..fb02bc986 --- /dev/null +++ b/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> { + 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): List = + withContext(Dispatchers.IO) { + if (ids.isEmpty()) emptyList() else dao.getByIds(ids) + } + + suspend fun enableByIds(ids: Set) = 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) = 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) = withContext(Dispatchers.IO) { + if (ids.isEmpty()) return@withContext + val rules = dao.getByIds(ids) + dao.delete(*rules.toTypedArray()) + } + + suspend fun moveOrder(rules: List) = withContext(Dispatchers.IO) { + val updatedRules = rules.mapIndexed { index, rule -> + rule.copy(order = index) + } + dao.update(*updatedRules.toTypedArray()) + } + +} diff --git a/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleContract.kt b/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleContract.kt new file mode 100644 index 000000000..d1c3699f1 --- /dev/null +++ b/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 + +@Stable +data class HighlightTagRuleUiState( + override val items: ImmutableList = persistentListOf(), + override val selectedIds: ImmutableSet = persistentSetOf(), + override val searchKey: String = "", + val interaction: InteractionState = InteractionState() +) : ListUiState { + 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) : 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, +) diff --git a/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleEditSheet.kt b/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleEditSheet.kt new file mode 100644 index 000000000..0f3250430 --- /dev/null +++ b/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 + ) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleScreen.kt b/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleScreen.kt new file mode 100644 index 000000000..47245ee69 --- /dev/null +++ b/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, + events: Flow, + 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(null) } + var showDeleteRuleDialog by remember { mutableStateOf(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)) + 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) + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleViewModel.kt new file mode 100644 index 000000000..cae053655 --- /dev/null +++ b/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( + application, + HighlightTagRuleUiState(interaction = InteractionState(isLoading = true)), + uploadRepository +) { + private val repository = HighlightTagRuleRepository() + + override val rawDataFlow: Flow> = 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, + searchKey: String, + groupFilter: String + ): List { + 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, + selectedIds: Set, + isSearch: Boolean, + isUploading: Boolean, + importState: BaseImportUiState + ): 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): String = GSON.toJson(entities) + + override fun parseImportRules(text: String): List { + return when { + text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() + text.isJsonObject() -> listOf(GSON.fromJsonObject(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 ?: 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) { + viewModelScope.launch { repository.enableByIds(ids) } + } + + fun disableSelectionByIds(ids: Set) { + viewModelScope.launch { repository.disableByIds(ids) } + } + + fun delSelectionByIds(ids: Set) { + 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(text).getOrThrow() + } catch (e: Exception) { + context.toastOnUi("格式不对") + null + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 072f56771..82b1d0522 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/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 { + HighlightTagRuleScreen( + onBackClick = { onNavigateBack() } + ) + } + entry { val viewModel = koinViewModel() val context = LocalContext.current diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt index a35e80da3..9ccbb1049 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index 0b7d69b8a..2236db3c2 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/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) diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 721074931..6d7c260d4 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/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() } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt b/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt index 85b0ebc16..db1bb56ef 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainViewModel.kt +++ b/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 } diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt index 31d7e7790..b2c749c02 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt +++ b/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( diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt b/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt index d528f9d1f..9c19698b0 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyViewModel.kt +++ b/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() diff --git a/app/src/main/java/io/legado/app/ui/widget/components/card/HighlightTagRow.kt b/app/src/main/java/io/legado/app/ui/widget/components/card/HighlightTagRow.kt new file mode 100644 index 000000000..1b97f3249 --- /dev/null +++ b/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, + 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, + ) + } +}