diff --git a/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt b/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt index 618abcadd..eb8ea2e7f 100644 --- a/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt @@ -34,6 +34,15 @@ class DictRuleRepository { dao.update(*rule) } + suspend fun findById(id: String): DictRule? = withContext(Dispatchers.IO) { + dao.getByName(id) + } + + suspend fun getByNames(names: Collection): List = + withContext(Dispatchers.IO) { + if (names.isEmpty()) emptyList() else dao.getByNames(names.toSet()) + } + suspend fun enableByIds(names: Set) = withContext(Dispatchers.IO) { if (names.isEmpty()) return@withContext val rules = dao.getByNames(names) diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt index 73567dd21..7dbd657da 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt @@ -1,5 +1,8 @@ package io.legado.app.ui.dict.rule +import android.content.ClipData +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -21,9 +24,12 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FloatingActionButton @@ -34,6 +40,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipAnchorPosition @@ -41,6 +49,7 @@ import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.animateFloatingActionButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -48,19 +57,25 @@ import androidx.compose.runtime.collectAsState 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.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll +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.compose.ui.zIndex -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.gson.Gson import io.legado.app.R import io.legado.app.data.entities.DictRule +import io.legado.app.data.repository.UploadRepository import io.legado.app.ui.widget.components.ActionItem import io.legado.app.ui.widget.components.AnimatedText import io.legado.app.ui.widget.components.DraggableSelectionHandler @@ -68,19 +83,34 @@ import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar import io.legado.app.ui.widget.components.ReorderableSelectionItem import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.ui.widget.components.SelectionBottomBar +import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet +import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode +import io.legado.app.ui.widget.components.importComponents.BaseImportUiState +import io.legado.app.ui.widget.components.importComponents.BatchImportDialog +import io.legado.app.ui.widget.components.importComponents.SourceInputDialog import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn +import io.legado.app.utils.GSON +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject import sh.calvin.reorderable.rememberReorderableLazyListState @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun DictRuleScreen( - viewModel: DictRuleViewModel = viewModel(), + viewModel: DictRuleViewModel = koinViewModel(), onBackClick: () -> Unit ) { + val uploadRepository: UploadRepository = koinInject() + + val context = LocalContext.current + val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsState() + val rules = uiState.items val listState = rememberLazyListState() var isSearch by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } val selectedIds by viewModel.selectedIds.collectAsState() val inSelectionMode = selectedIds.isNotEmpty() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -89,11 +119,129 @@ fun DictRuleScreen( var showEditSheet by remember { mutableStateOf(false) } var editingRule by remember { mutableStateOf(null) } var showDeleteRuleDialog by remember { mutableStateOf(null) } + val reorderableState = rememberReorderableLazyListState(listState) { from, to -> viewModel.moveItemInList(from.index, to.index) hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } + val clipboardManager = LocalClipboard.current + val snackbarHostState = remember { SnackbarHostState() } + var showUrlInput by remember { mutableStateOf(false) } + var showFilePickerSheet by remember { mutableStateOf(false) } + var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) } + var isUploading by remember { mutableStateOf(false) } + val importState by viewModel.importState.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState() + + val importDoc = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> + uri?.let { + context.contentResolver.openInputStream(it)?.use { stream -> + val text = stream.reader().readText() + viewModel.importSource(text) + } + } + } + ) + + val exportDoc = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + onResult = { uri -> + uri?.let { it -> + scope.launch { + val rulesToExport = rules + .filter { selectedIds.contains(it.name) } + .map { it.rule } + + val json = Gson().toJson(rulesToExport) + context.contentResolver.openOutputStream(it)?.use { stream -> + stream.writer().write(json) + } + } + } + } + ) + + if (showUrlInput) { + SourceInputDialog( + title = stringResource(R.string.import_on_line), + onDismissRequest = { showUrlInput = false }, + onConfirm = { + showUrlInput = false + viewModel.importSource(it) + } + ) + } + + if (showFilePickerSheet) { + FilePickerSheet( + sheetState = sheetState, + onDismissRequest = { showFilePickerSheet = false }, + mode = filePickerMode, + onSelectSysDir = { + showFilePickerSheet = false + exportDoc.launch("exportDictRule.json") + }, + onSelectSysFile = {}, + onUpload = { + showFilePickerSheet = false + scope.launch { + val selectedRules = viewModel.getSelectedRules() + val json = GSON.toJson(selectedRules) + + isUploading = true + + try { + runCatching { + uploadRepository.upload( + fileName = "exportDictRule.json", + file = json, + contentType = "application/json" + ) + }.onSuccess { url -> + isUploading = false + val result = snackbarHostState.showSnackbar( + message = "上传成功: $url", + actionLabel = "复制链接", + withDismissAction = true + ) + if (result == SnackbarResult.ActionPerformed) { + clipboardManager.setClipEntry( + ClipEntry(ClipData.newPlainText("export url", url)) + ) + } + }.onFailure { e -> + isUploading = false + snackbarHostState.showSnackbar("上传失败: ${e.localizedMessage}") + } + } finally { + isUploading = false + } + } + }, + allowExtensions = arrayOf("json") + ) + } + + (importState as? BaseImportUiState.Success)?.let { state -> + BatchImportDialog( + title = "导入词典规则", + importState = state, + onDismissRequest = { viewModel.cancelImport() }, + onToggleItem = { viewModel.toggleImportSelection(it) }, + onToggleAll = { viewModel.toggleImportAll(it) }, + onConfirm = { viewModel.saveImportedRules() }, + itemContent = { rule, _ -> + Column { + Text(rule.name, style = MaterialTheme.typography.titleMedium) + Text(rule.urlRule, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + ) + } + LaunchedEffect(reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) { viewModel.saveSortOrder() @@ -178,9 +326,10 @@ fun DictRuleScreen( Column { GlassMediumFlexibleTopAppBar( title = { - val titleText = remember(inSelectionMode, selectedIds, uiState.items) { + val titleText = remember(isUploading, inSelectionMode, selectedIds, rules) { when { - inSelectionMode -> "已选择 ${selectedIds.size}/${uiState.items.size}" + isUploading -> "正在上传..." + inSelectionMode -> "已选择 ${selectedIds.size}/${rules.size}" else -> "字典规则" } } @@ -209,6 +358,32 @@ fun DictRuleScreen( IconButton(onClick = { isSearch = !isSearch }) { Icon(Icons.Default.Search, contentDescription = "Search") } + IconButton(onClick = { showMenu = !showMenu }) { + Icon(Icons.Default.MoreVert, contentDescription = "More") + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("在线导入") }, + onClick = { + showMenu = false + showUrlInput = true + } + ) + DropdownMenuItem( + text = { Text("本地导入") }, + onClick = { + importDoc.launch( + arrayOf( + "text/plain", + "application/json" + ) + ); showMenu = false + } + ) + } } }, scrollBehavior = scrollBehavior @@ -260,7 +435,7 @@ fun DictRuleScreen( ), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(uiState.items, key = { it.name }) { item -> + items(rules, key = { it.name }) { item -> ReorderableSelectionItem( state = reorderableState, key = item.name, @@ -294,7 +469,7 @@ fun DictRuleScreen( if (inSelectionMode) { DraggableSelectionHandler( listState = listState, - items = uiState.items, + items = rules, selectedIds = selectedIds, onSelectionChange = viewModel::setSelection, idProvider = { it.name }, @@ -316,10 +491,10 @@ fun DictRuleScreen( ) { SelectionBottomBar( onSelectAll = { - viewModel.setSelection(uiState.items.map { it.name }.toSet()) + viewModel.setSelection(rules.map { it.name }.toSet()) }, onSelectInvert = { - val allIds = uiState.items.map { it.name }.toSet() + val allIds = rules.map { it.name }.toSet() viewModel.setSelection(allIds - selectedIds) }, primaryAction = ActionItem( @@ -341,6 +516,10 @@ fun DictRuleScreen( viewModel.disableSelectionByIds(selectedIds) viewModel.setSelection(emptySet()) } + ), + ActionItem( + text = stringResource(R.string.export), + onClick = { showFilePickerSheet = true } ) ) ) diff --git a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt index b208ae547..e9d13166e 100644 --- a/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt @@ -2,14 +2,28 @@ package io.legado.app.ui.dict.rule import android.app.Application import androidx.compose.runtime.Immutable +import androidx.core.net.toUri import androidx.lifecycle.viewModelScope import io.legado.app.base.BaseViewModel +import io.legado.app.constant.AppConst import io.legado.app.data.entities.DictRule import io.legado.app.data.repository.DictRuleRepository -import io.legado.app.help.DefaultData +import io.legado.app.help.http.decompressed +import io.legado.app.help.http.newCallResponseBody +import io.legado.app.help.http.okHttpClient +import io.legado.app.help.http.text +import io.legado.app.ui.widget.components.importComponents.BaseImportUiState +import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper +import io.legado.app.ui.widget.components.importComponents.ImportStatus 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.isAbsUrl +import io.legado.app.utils.isJsonArray +import io.legado.app.utils.isJsonObject +import io.legado.app.utils.isUri +import io.legado.app.utils.readText import io.legado.app.utils.sendToClip import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers @@ -26,6 +40,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Immutable data class DictRuleItemUi( @@ -51,8 +66,9 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) { private val _uiRules = MutableStateFlow>(emptyList()) private val _selectedIds = MutableStateFlow>(emptySet()) - val selectedIds: StateFlow> = _selectedIds.asStateFlow() - + val selectedIds: StateFlow> = _selectedIds + private val _importState = MutableStateFlow>(BaseImportUiState.Idle) + val importState = _importState.asStateFlow() fun toggleSelection(id: String) { _selectedIds.update { if (it.contains(id)) it - id else it + id @@ -172,22 +188,110 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) { } } - fun upSortNumber() { - execute { - val rules = repository.getAll() - for ((index, rule) in rules.withIndex()) { - rule.sortNumber = index + 1 + fun importSource(text: String) { + _importState.value = BaseImportUiState.Loading + viewModelScope.launch(Dispatchers.IO) { + runCatching { + val jsonText = resolveSource(text.trim()) + val rules = parseDictRules(jsonText) + val wrappers = rules.map { newRule -> + val oldRule = repository.findById(newRule.name) + + val status = when { + oldRule == null -> ImportStatus.New + hasChanged(newRule, oldRule) -> ImportStatus.Update + else -> ImportStatus.Existing + } + + ImportItemWrapper( + data = newRule, + oldData = oldRule, + status = status, + isSelected = status != ImportStatus.Existing + ) + } + + _importState.value = BaseImportUiState.Success( + source = text, + items = wrappers + ) + }.onFailure { + it.printStackTrace() + _importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error") } - repository.insert(*rules.toTypedArray()) } } - fun importDefault() { - execute { - DefaultData.importDefaultDictRules() + private suspend fun resolveSource(text: String): String { + return when { + text.isAbsUrl() -> { + okHttpClient.newCallResponseBody { + if (text.endsWith("#requestWithoutUA")) { + url(text.substringBeforeLast("#requestWithoutUA")) + header(AppConst.UA_NAME, "null") + } else { + url(text) + } + }.decompressed().text("utf-8") + } + + text.isUri() -> text.toUri().readText(context) + else -> text } } + private fun parseDictRules(text: String): List { + return when { + text.isJsonArray() -> GSON.fromJsonArray(text).getOrThrow() + text.isJsonObject() -> listOf(GSON.fromJsonObject(text).getOrThrow()) + else -> throw Exception("格式不正确") + } + } + + private fun hasChanged(newRule: DictRule, oldRule: DictRule): Boolean { + return newRule.name != oldRule.name + || newRule.urlRule != oldRule.urlRule + || newRule.showRule != oldRule.showRule + } + + fun toggleImportSelection(index: Int) { + val currentState = _importState.value as? BaseImportUiState.Success ?: return + val newItems = currentState.items.toMutableList() + val item = newItems[index] + newItems[index] = item.copy(isSelected = !item.isSelected) + _importState.value = currentState.copy(items = newItems) + } + + fun toggleImportAll(isSelected: Boolean) { + val currentState = _importState.value as? BaseImportUiState.Success ?: return + val newItems = currentState.items.map { it.copy(isSelected = isSelected) } + _importState.value = currentState.copy(items = newItems) + } + + 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 cancelImport() { + _importState.value = BaseImportUiState.Idle + } + + suspend fun getSelectedRules(): List { + val selectedIds = _selectedIds.value + return repository.getByNames(selectedIds) + } + fun copyRule(dictRule: DictRule) { context.sendToClip(GSON.toJson(dictRule)) }