From dcff01c8d8a1e36d1fd1f2bb671d259d6f50c753 Mon Sep 17 00:00:00 2001 From: HapeLee <1321903405@qq.com> Date: Tue, 9 Dec 2025 01:13:04 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20=E7=94=B1Compose?= =?UTF-8?q?=E9=87=8D=E5=86=99=E7=9A=84=E6=9B=BF=E6=8D=A2=E5=87=80=E5=8C=96?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=95=8C=E9=9D=A2=EF=BC=8C=E6=B2=A1=E5=95=A5?= =?UTF-8?q?=E5=8F=98=E5=8C=96=EF=BC=8C=E5=8F=AF=E8=83=BD=E6=9C=89BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/io/legado/app/App.kt | 6 +- .../main/java/io/legado/app/di/appModule.kt | 37 ++ .../java/io/legado/app/di/bookmarkModule.kt | 16 - .../java/io/legado/app/di/exploreModule.kt | 12 - .../java/io/legado/app/di/readRecordModule.kt | 16 - .../app/ui/book/explore/ExploreShowScreen.kt | 1 - .../ui/replace/edit/ReplaceEditActivity.kt | 195 +-------- .../app/ui/replace/edit/ReplaceEditScreen.kt | 370 ++++++++++++++++++ .../ui/replace/edit/ReplaceEditViewModel.kt | 290 +++++++++++--- .../widget/components/AnimatedTextButton.kt | 1 - app/version.properties | 2 +- 11 files changed, 666 insertions(+), 280 deletions(-) create mode 100644 app/src/main/java/io/legado/app/di/appModule.kt delete mode 100644 app/src/main/java/io/legado/app/di/bookmarkModule.kt delete mode 100644 app/src/main/java/io/legado/app/di/exploreModule.kt delete mode 100644 app/src/main/java/io/legado/app/di/readRecordModule.kt create mode 100644 app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt diff --git a/app/src/main/java/io/legado/app/App.kt b/app/src/main/java/io/legado/app/App.kt index 8620ace02..5b17e1827 100644 --- a/app/src/main/java/io/legado/app/App.kt +++ b/app/src/main/java/io/legado/app/App.kt @@ -33,9 +33,7 @@ import io.legado.app.data.entities.rule.ContentRule import io.legado.app.data.entities.rule.ExploreRule import io.legado.app.data.entities.rule.SearchRule import io.legado.app.di.appDatabaseModule -import io.legado.app.di.bookmarkModule -import io.legado.app.di.exploreModule -import io.legado.app.di.readRecordModule +import io.legado.app.di.appModule import io.legado.app.help.AppFreezeMonitor import io.legado.app.help.AppWebDav import io.legado.app.help.CrashHandler @@ -80,7 +78,7 @@ class App : Application() { override fun onCreate() { startKoin { androidContext(this@App) - modules(appDatabaseModule, readRecordModule, exploreModule, bookmarkModule) + modules(appDatabaseModule, appModule) } if (getPrefString("app_theme", "0") == "12") { if (AppConfig.customMode == "accent") diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt new file mode 100644 index 000000000..983c51d3c --- /dev/null +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -0,0 +1,37 @@ +package io.legado.app.di + +import io.legado.app.data.AppDatabase +import io.legado.app.data.repository.ExploreRepository +import io.legado.app.data.repository.ExploreRepositoryImpl +import io.legado.app.data.repository.ReadRecordRepository +import io.legado.app.ui.book.bookmark.AllBookmarkViewModel +import io.legado.app.ui.book.explore.ExploreShowViewModel +import io.legado.app.ui.book.readRecord.ReadRecordViewModel +import io.legado.app.ui.replace.edit.ReplaceEditViewModel +import org.koin.android.ext.koin.androidApplication +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val appModule = module { + + viewModel { ReplaceEditViewModel(get(), get(), get()) } + + // ReadRecord + single { get().readRecordDao } + single { get().bookDao } + single { get().bookChapterDao } + single { ReadRecordRepository(get()) } + viewModel { ReadRecordViewModel(get(), get(), get()) } + + // Explore + single { ExploreRepositoryImpl(get()) } + viewModel { ExploreShowViewModel(get()) } + + // Bookmark + viewModel { + AllBookmarkViewModel( + androidApplication(), + get() + ) + } +} diff --git a/app/src/main/java/io/legado/app/di/bookmarkModule.kt b/app/src/main/java/io/legado/app/di/bookmarkModule.kt deleted file mode 100644 index 2902fd179..000000000 --- a/app/src/main/java/io/legado/app/di/bookmarkModule.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.legado.app.di - -import io.legado.app.ui.book.bookmark.AllBookmarkViewModel -import org.koin.android.ext.koin.androidApplication -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -val bookmarkModule = module { - - viewModel { - AllBookmarkViewModel( - androidApplication(), - get() - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/exploreModule.kt b/app/src/main/java/io/legado/app/di/exploreModule.kt deleted file mode 100644 index 4138d45c9..000000000 --- a/app/src/main/java/io/legado/app/di/exploreModule.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.legado.app.di - -import io.legado.app.data.repository.ExploreRepository -import io.legado.app.data.repository.ExploreRepositoryImpl -import io.legado.app.ui.book.explore.ExploreShowViewModel -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -val exploreModule = module { - single { ExploreRepositoryImpl(get()) } - viewModel { ExploreShowViewModel(get()) } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/di/readRecordModule.kt b/app/src/main/java/io/legado/app/di/readRecordModule.kt deleted file mode 100644 index 0422ec300..000000000 --- a/app/src/main/java/io/legado/app/di/readRecordModule.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.legado.app.di - -import io.legado.app.data.AppDatabase -import io.legado.app.data.repository.ReadRecordRepository -import io.legado.app.ui.book.readRecord.ReadRecordViewModel -import org.koin.core.module.dsl.viewModel -import org.koin.dsl.module - -val readRecordModule = module { - single { get().readRecordDao } - single { get().bookDao } - single { get().bookChapterDao } - single { ReadRecordRepository(get()) } - - viewModel { ReadRecordViewModel(get(), get(), get()) } -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt index 228499c1c..091aed246 100644 --- a/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt @@ -92,7 +92,6 @@ import io.legado.app.model.BookShelfState import io.legado.app.ui.widget.components.AnimatedTextButton import io.legado.app.ui.widget.components.AnimatedTextLine import io.legado.app.ui.widget.components.Cover -import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.SearchBarSection import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt index 998c7b112..cd25eba9e 100644 --- a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt +++ b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt @@ -2,37 +2,15 @@ package io.legado.app.ui.replace.edit import android.content.Context import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.widget.ArrayAdapter -import android.widget.EditText -import androidx.activity.viewModels -import androidx.lifecycle.lifecycleScope -import io.legado.app.R -import io.legado.app.base.VMBaseActivity -import io.legado.app.data.entities.ReplaceRule -import io.legado.app.databinding.ActivityReplaceEditBinding -import io.legado.app.lib.dialogs.SelectItem -import io.legado.app.ui.widget.keyboard.KeyboardToolPop -import io.legado.app.utils.GSON -import io.legado.app.utils.imeHeight -import io.legado.app.utils.sendToClip -import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat -import io.legado.app.utils.showHelp -import io.legado.app.utils.viewbindingdelegate.viewBinding -import io.legado.app.data.appDb -import io.legado.app.lib.dialogs.alert -import io.legado.app.utils.toastOnUi -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import io.legado.app.base.AppTheme +import io.legado.app.base.BaseComposeActivity /** * 编辑替换规则 */ -class ReplaceEditActivity : - VMBaseActivity(), - KeyboardToolPop.CallBack { +class ReplaceEditActivity : BaseComposeActivity() { companion object { fun startIntent( @@ -53,161 +31,24 @@ class ReplaceEditActivity : } } - override val binding by viewBinding(ActivityReplaceEditBinding::inflate) - override val viewModel by viewModels() - private val softKeyboardTool by lazy { - KeyboardToolPop(this, lifecycleScope, binding.root, this) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - softKeyboardTool.attachToWindow(window) - initView() - viewModel.initData(intent, ::upReplaceView) - } - - override fun onDestroy() { - super.onDestroy() - softKeyboardTool.dismiss() - } - - override fun onCompatCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.replace_edit, menu) - return super.onCompatCreateOptionsMenu(menu) - } - - override fun onCompatOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.menu_save -> viewModel.save(getReplaceRule()) { - setResult(RESULT_OK) - finish() - } - - R.id.menu_copy_rule -> sendToClip(GSON.toJson(getReplaceRule())) - R.id.menu_paste_rule -> viewModel.pasteRule(::upReplaceView) - } - return true - } - - private fun initView() = binding.run { - ivHelp.setOnClickListener { showHelp("regexHelp") } - ivManageGroups.setOnClickListener { showGroupManageDialog() } - - root.setOnApplyWindowInsetsListenerCompat { _, insets -> - softKeyboardTool.initialPadding = insets.imeHeight - insets - } - - setupGroupAutoComplete() - } - - private fun setupGroupAutoComplete() = binding.run { - lifecycleScope.launch { - appDb.replaceRuleDao.flowGroups().collectLatest { groups -> - val allGroups = listOf("默认") + groups - val adapter = ArrayAdapter( - this@ReplaceEditActivity, - android.R.layout.simple_dropdown_item_1line, - allGroups + @Composable + override fun Content() { + setContent { + AppTheme { + ReplaceEditScreen( + onBack = { finish() }, + onSaveSuccess = { + setResult(RESULT_OK) + finish() + }, + onShowHelp = { key -> showHelp(key) } ) - etGroup.setAdapter(adapter) - } - } - - val defaultGroup = viewModel.replaceRule?.group ?: "默认" - etGroup.setText(defaultGroup, false) - } - - - private fun showGroupManageDialog() { - lifecycleScope.launch { - val allGroups = appDb.replaceRuleDao.allGroups() - - if (allGroups.isEmpty()) { - toastOnUi("暂无分组") - return@launch - } - - val checked = BooleanArray(allGroups.size) - - alert(title = getString(R.string.group_manage)) { - multiChoiceItems(allGroups.toTypedArray(), checked) { _, _, _ -> } - negativeButton("关闭") - neutralButton("删除所选") { - - val toDelete = allGroups.filterIndexed { i, _ -> checked[i] } - - if (toDelete.isEmpty()) { - toastOnUi("未选择任何分组") - return@neutralButton - } - - lifecycleScope.launch { - appDb.replaceRuleDao.clearGroups(toDelete) - toastOnUi("已清空 ${toDelete.size} 个分组") - } - } } } } - private fun upReplaceView(rule: ReplaceRule) = binding.run { - etName.setText(rule.name) - val group = rule.group.takeUnless { it.isNullOrBlank() } ?: "默认" - etGroup.setText(group, false) - etReplaceRule.setText(rule.pattern) - cbUseRegex.isChecked = rule.isRegex - etReplaceTo.setText(rule.replacement) - cbScopeTitle.isChecked = rule.scopeTitle - cbScopeContent.isChecked = rule.scopeContent - etScope.setText(rule.scope) - etExcludeScope.setText(rule.excludeScope) - etTimeout.setText(rule.timeoutMillisecond.toString()) + private fun showHelp(key: String) { + } - private fun getReplaceRule(): ReplaceRule = binding.run { - val rule = viewModel.replaceRule ?: ReplaceRule() - - rule.name = etName.text.toString() - val groupText = binding.etGroup.text.toString().trim() - rule.group = when { - groupText.isBlank() -> null - groupText == "默认" -> null - else -> groupText - } - rule.pattern = etReplaceRule.text.toString() - rule.isRegex = cbUseRegex.isChecked - rule.replacement = etReplaceTo.text.toString() - rule.scopeTitle = cbScopeTitle.isChecked - rule.scopeContent = cbScopeContent.isChecked - rule.scope = etScope.text.toString() - rule.excludeScope = etExcludeScope.text.toString() - rule.timeoutMillisecond = etTimeout.text.toString().ifEmpty { "3000" }.toLong() - - rule - } - - override fun helpActions(): List> = - listOf(SelectItem("正则教程", "regexHelp")) - - override fun onHelpActionSelect(action: String) { - if (action == "regexHelp") showHelp("regexHelp") - } - - override fun sendText(text: String) { - if (text.isBlank()) return - val view = window?.decorView?.findFocus() - if (view is EditText) { - val start = view.selectionStart - val end = view.selectionEnd - val edit = view.editableText - if (start < 0 || start >= edit.length) { - edit.append(text) - } else if (start > end) { - edit.replace(end, start, text) - } else { - edit.replace(start, end, text) - } - } - } } diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt new file mode 100644 index 000000000..cae61dc6a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt @@ -0,0 +1,370 @@ +package io.legado.app.ui.replace.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import org.koin.androidx.compose.koinViewModel + +@Composable +fun keyboardAsState(): State { + val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + return rememberUpdatedState(isImeVisible) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReplaceEditScreen( + onBack: () -> Unit, + onSaveSuccess: () -> Unit, + onShowHelp: (String) -> Unit, + viewModel: ReplaceEditViewModel = koinViewModel() +) { + val state by viewModel.uiState.collectAsState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var showMenu by remember { mutableStateOf(false) } + val isKeyboardVisible by keyboardAsState() + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MediumTopAppBar( + title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + }, + actions = { + + IconButton(onClick = { onShowHelp("regexHelp") }) { + Icon(Icons.AutoMirrored.Filled.HelpOutline, contentDescription = "帮助") + } + + IconButton(onClick = { + viewModel.save(onSaveSuccess) + }) { + Icon(Icons.Default.Save, contentDescription = "保存") + } + + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "更多操作") + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + + DropdownMenuItem( + text = { Text("复制规则") }, + onClick = { + showMenu = false + viewModel.copyRule() + } + ) + + DropdownMenuItem( + text = { Text("粘贴规则") }, + onClick = { + showMenu = false + viewModel.pasteRule(onSuccess = {}) + } + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + OutlinedTextField( + value = state.name, + onValueChange = viewModel::onNameChange, + label = { Text("规则名称") }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Name }, + singleLine = true + ) + + GroupSelector( + currentGroup = state.group, + allGroups = state.allGroups, + onGroupChange = viewModel::onGroupChange, + onManageClick = { viewModel.toggleGroupDialog(true) } + ) + + OutlinedTextField( + value = state.pattern, + onValueChange = viewModel::onPatternChange, + label = { Text("匹配规则") }, + placeholder = { Text("输入正则表达式或关键字") }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Pattern } + ) + + OutlinedTextField( + value = state.replacement, + onValueChange = viewModel::onReplacementChange, + label = { Text("替换为") }, + placeholder = { Text("输入替换内容或捕获组") }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Replacement } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + FilterChip( + selected = state.scopeTitle, + onClick = { viewModel.onScopeTitleChange(!state.scopeTitle) }, + label = { Text("标题") }, + leadingIcon = if (state.scopeTitle) { + { Icon(Icons.Default.Check, contentDescription = "已选", Modifier.size(FilterChipDefaults.IconSize)) } + } else null + ) + + Spacer(Modifier.width(8.dp)) + + FilterChip( + selected = state.scopeContent, + onClick = { viewModel.onScopeContentChange(!state.scopeContent) }, + label = { Text("内容") }, + leadingIcon = if (state.scopeContent) { + { Icon(Icons.Default.Check, contentDescription = "已选", Modifier.size(FilterChipDefaults.IconSize)) } + } else null + ) + + Spacer(Modifier.weight(1f)) + + FilterChip( + selected = state.isRegex, + onClick = { viewModel.onRegexChange(!state.isRegex) }, + label = { Text("使用正则") }, + leadingIcon = if (state.isRegex) { + { Icon(Icons.Default.Check, contentDescription = "正则已启用", Modifier.size(FilterChipDefaults.IconSize)) } + } else null + ) + + } + + OutlinedTextField( + value = state.scope, + onValueChange = viewModel::onScopeChange, + label = { Text("特定范围") }, + placeholder = { Text("指定规则适用的范围") }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Scope } + ) + + OutlinedTextField( + value = state.excludeScope, + onValueChange = viewModel::onExcludeScopeChange, + label = { Text("排除范围") }, + placeholder = { Text("指定规则不适用的范围") }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) viewModel.activeField = ReplaceEditViewModel.ActiveField.Exclude } + ) + + OutlinedTextField( + value = state.timeout, + onValueChange = viewModel::onTimeoutChange, + label = { Text("超时 (ms)") }, + placeholder = { Text("3000") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + } + } + + AnimatedVisibility( + visible = isKeyboardVisible, + enter = slideInVertically( + initialOffsetY = { fullHeight -> fullHeight }, + ), + exit = slideOutVertically( + targetOffsetY = { fullHeight -> fullHeight }, + ), + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding() + ) { + QuickInputBar( + onInsert = { text -> viewModel.insertTextAtCursor(text) } + ) + } + + + if (state.showGroupDialog) { + ManageGroupDialog( + groups = state.allGroups.filter { it != "默认" }, + onDismiss = { viewModel.toggleGroupDialog(false) }, + onDelete = { viewModel.deleteGroups(it) } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupSelector( + currentGroup: String, + allGroups: List, + onGroupChange: (String) -> Unit, + onManageClick: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Row(verticalAlignment = Alignment.CenterVertically) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.weight(1f) + ) { + OutlinedTextField( + value = currentGroup, + onValueChange = onGroupChange, + label = { Text("分组") }, + placeholder = {Text("默认")}, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier.fillMaxWidth().menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryEditable, + true + ) + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + allGroups.forEach { selectionOption -> + DropdownMenuItem( + text = { Text(selectionOption) }, + onClick = { + onGroupChange(selectionOption) + expanded = false + } + ) + } + } + } + IconButton(onClick = onManageClick) { + Icon(Icons.Default.Settings, "Manage") + } + } +} + +@Composable +fun ManageGroupDialog( + groups: List, + onDismiss: () -> Unit, + onDelete: (List) -> Unit +) { + + val selected = remember { mutableStateMapOf() } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("分组管理") }, + text = { + if (groups.isEmpty()) Text("暂无其他分组") + else Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + groups.forEach { group -> + val isSelected = selected[group] ?: false + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .background( + color = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, + shape = MaterialTheme.shapes.small + ) + .clickable { selected[group] = !isSelected } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = isSelected, onCheckedChange = null) + Text(group, Modifier.padding(start = 8.dp)) + } + } + } + }, + confirmButton = { + OutlinedButton( + onClick = { + val toDelete = selected.filter { it.value }.keys.toList() + onDelete(toDelete) + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + containerColor = Color.Transparent, + ), + ) { + Text("删除选中") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("关闭") } } + ) +} + +@Composable +fun QuickInputBar( + onInsert: (String) -> Unit, + modifier: Modifier = Modifier +) { + val symbols = listOf(".*", "\\d+", "\\w+", "[]", "()", "^", "$", "|", "{}", "<>") + + BottomAppBar( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + symbols.forEach { symbol -> + AssistChip( + onClick = { onInsert(symbol) }, + label = { Text(symbol) }, + modifier = Modifier.padding(end = 8.dp) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt index 9280aa40d..b768156a2 100644 --- a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditViewModel.kt @@ -1,73 +1,259 @@ package io.legado.app.ui.replace.edit import android.app.Application -import android.content.Intent -import io.legado.app.base.BaseViewModel -import io.legado.app.data.appDb +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.legado.app.data.dao.ReplaceRuleDao import io.legado.app.data.entities.ReplaceRule import io.legado.app.exception.NoStackTraceException -import io.legado.app.utils.* +import io.legado.app.utils.GSON +import io.legado.app.utils.fromJsonObject +import io.legado.app.utils.getClipText +import io.legado.app.utils.sendToClip +import io.legado.app.utils.toastOnUi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch -class ReplaceEditViewModel(application: Application) : BaseViewModel(application) { - var replaceRule: ReplaceRule? = null +data class ReplaceEditUiState( + val id: Long = -1, + val name: TextFieldValue = TextFieldValue(""), + val group: String = "默认", + val pattern: TextFieldValue = TextFieldValue(""), + val replacement: TextFieldValue = TextFieldValue(""), + val isRegex: Boolean = false, + val scope: TextFieldValue = TextFieldValue(""), + val scopeTitle: Boolean = false, + val scopeContent: Boolean = false, + val excludeScope: TextFieldValue = TextFieldValue(""), + val timeout: String = "3000", + val allGroups: List = emptyList(), + val showGroupDialog: Boolean = false +) - fun initData(intent: Intent, finally: (replaceRule: ReplaceRule) -> Unit) { - execute { - val id = intent.getLongExtra("id", -1) - replaceRule = if (id > 0) { - appDb.replaceRuleDao.findById(id) +class ReplaceEditViewModel( + private val app: Application, + private val replaceRuleDao: ReplaceRuleDao, + private val savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _uiState = MutableStateFlow(ReplaceEditUiState()) + val uiState = _uiState.asStateFlow() + + var activeField: ActiveField = ActiveField.None + enum class ActiveField { Name, None, Pattern, Replacement, Scope, Exclude } + + init { + initData() + observeGroups() + } + + private fun initData() { + viewModelScope.launch { + val id = savedStateHandle.get("id") ?: -1L + if (id > 0) { + val rule = replaceRuleDao.findById(id) + rule?.let { updateStateFromRule(it) } } else { - val pattern = intent.getStringExtra("pattern") ?: "" - val isRegex = intent.getBooleanExtra("isRegex", false) - val scope = intent.getStringExtra("scope") - val isScopeTitle = intent.getBooleanExtra("isScopeTitle", false) - val isScopeContent = intent.getBooleanExtra("isScopeContent", false) - ReplaceRule( - name = pattern, - pattern = pattern, - isRegex = isRegex, - scope = scope, - scopeTitle = isScopeTitle, - scopeContent = isScopeContent - ) - } - }.onFinally { - replaceRule?.let { - finally(it) + _uiState.update { + it.copy( + id = -1, + name = TextFieldValue(""), + pattern = TextFieldValue(savedStateHandle["pattern"] ?: ""), + isRegex = savedStateHandle["isRegex"] ?: false, + scope = TextFieldValue(savedStateHandle["scope"] ?: ""), + scopeTitle = savedStateHandle["isScopeTitle"] ?: false, + scopeContent = savedStateHandle["isScopeContent"] ?: false, + excludeScope = TextFieldValue(""), + ) + } } } } - fun pasteRule(success: (ReplaceRule) -> Unit) { - execute(context = Dispatchers.Main) { - val text = context.getClipText() - if (text.isNullOrBlank()) { - throw NoStackTraceException("剪贴板为空") + private fun observeGroups() { + viewModelScope.launch { + replaceRuleDao.flowGroups().collectLatest { groups -> + _uiState.update { it.copy(allGroups = listOf("默认") + groups) } } - GSON.fromJsonObject(text).getOrNull() - ?: throw NoStackTraceException("格式不对") - }.onSuccess { - success.invoke(it) - }.onError { - context.toastOnUi(it.localizedMessage ?: "Error") - it.printOnDebug() } } - fun save(replaceRule: ReplaceRule, success: () -> Unit) { - execute { - replaceRule.checkValid() - if (replaceRule.order == Int.MIN_VALUE) { - replaceRule.order = appDb.replaceRuleDao.maxOrder + 1 - } - appDb.replaceRuleDao.insert(replaceRule) - }.onSuccess { - success() - }.onError { - context.toastOnUi("save error, ${it.localizedMessage}") + private fun updateStateFromRule(rule: ReplaceRule) { + _uiState.update { + it.copy( + id = rule.id, + name = TextFieldValue(rule.name), + group = rule.group ?: "默认", + pattern = TextFieldValue(rule.pattern), + replacement = TextFieldValue(rule.replacement), + isRegex = rule.isRegex, + scopeTitle = rule.scopeTitle, + scopeContent = rule.scopeContent, + scope = TextFieldValue(rule.scope ?: ""), + excludeScope = TextFieldValue(rule.excludeScope ?: ""), + timeout = rule.timeoutMillisecond.toString() + ) } } -} + private fun getReplaceRuleFromState(): ReplaceRule { + val state = _uiState.value + val rule = ReplaceRule().apply { + id = state.id + name = state.name.text + group = if (state.group == "默认" || state.group.isBlank()) null else state.group + pattern = state.pattern.text + replacement = state.replacement.text + isRegex = state.isRegex + scopeTitle = state.scopeTitle + scopeContent = state.scopeContent + scope = state.scope.text + excludeScope = state.excludeScope.text + timeoutMillisecond = state.timeout.toLongOrNull() ?: 3000L + } + return rule + } + + fun copyRule() { + viewModelScope.launch(Dispatchers.Main) { + val ruleToCopy = getReplaceRuleFromState() + val json = GSON.toJson(ruleToCopy) + app.sendToClip(json) + app.toastOnUi("规则已复制到剪贴板") + } + } + + fun pasteRule(onSuccess: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + try { + val text = app.getClipText() + if (text.isNullOrBlank()) { + throw NoStackTraceException("剪贴板为空") + } + + val pastedRule = GSON.fromJsonObject(text).getOrNull() + ?: throw NoStackTraceException("格式不对") + + launch(Dispatchers.Main) { + updateStateFromRule(pastedRule) + onSuccess() + } + } catch (e: Exception) { + launch(Dispatchers.Main) { + app.toastOnUi(e.localizedMessage ?: "格式不对") + } + } + } + } + + fun onNameChange(v: TextFieldValue) { + _uiState.update { it.copy(name = v) } + activeField = ActiveField.Name + } + fun onScopeChange(v: TextFieldValue) { + _uiState.update { it.copy(scope = v) } + activeField = ActiveField.Scope + } + fun onPatternChange(v: TextFieldValue) { + _uiState.update { it.copy(pattern = v) } + activeField = ActiveField.Pattern + } + fun onReplacementChange(v: TextFieldValue) { + _uiState.update { it.copy(replacement = v) } + activeField = ActiveField.Replacement + } + fun onExcludeScopeChange(v: TextFieldValue) { + _uiState.update { it.copy(excludeScope = v) } + activeField = ActiveField.Exclude + } + fun onGroupChange(v: String) = _uiState.update { it.copy(group = v) } + fun onRegexChange(v: Boolean) = _uiState.update { it.copy(isRegex = v) } + fun onScopeTitleChange(v: Boolean) = _uiState.update { it.copy(scopeTitle = v) } + fun onScopeContentChange(v: Boolean) = _uiState.update { it.copy(scopeContent = v) } + fun onTimeoutChange(v: String) = _uiState.update { it.copy(timeout = v) } + fun toggleGroupDialog(show: Boolean) = _uiState.update { it.copy(showGroupDialog = show) } + fun insertTextAtCursor(text: String) { + val state = _uiState.value + when (activeField) { + ActiveField.Name -> { + val newTfv = insertIntoTextFieldValue(state.name, text) + _uiState.update { it.copy(name = newTfv) } + } + ActiveField.Pattern -> { + val newTfv = insertIntoTextFieldValue(state.pattern, text) + _uiState.update { it.copy(pattern = newTfv) } + } + ActiveField.Replacement -> { + val newTfv = insertIntoTextFieldValue(state.replacement, text) + _uiState.update { it.copy(replacement = newTfv) } + } + ActiveField.Scope -> { + val newTfv = insertIntoTextFieldValue(state.scope, text) + _uiState.update { it.copy(scope = newTfv) } + } + ActiveField.Exclude -> { + val newTfv = insertIntoTextFieldValue(state.excludeScope, text) + _uiState.update { it.copy(excludeScope = newTfv) } + } + else -> {} + } + } + + private fun insertIntoTextFieldValue(current: TextFieldValue, insert: String): TextFieldValue { + val start = current.selection.start + val end = current.selection.end + val newText = current.text.replaceRange(start, end, insert) + val newCursor = start + insert.length + return TextFieldValue( + text = newText, + selection = TextRange(newCursor) + ) + } + + fun save(onSuccess: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val state = _uiState.value + + val rule = ReplaceRule().apply { + id = state.id + name = state.name.text + group = if (state.group == "默认" || state.group.isBlank()) null else state.group + pattern = state.pattern.text + replacement = state.replacement.text + isRegex = state.isRegex + scopeTitle = state.scopeTitle + scopeContent = state.scopeContent + scope = state.scope.text + excludeScope = state.excludeScope.text + timeoutMillisecond = state.timeout.toLongOrNull() ?: 3000L + } + + if (rule.id <= 0) { + rule.order = replaceRuleDao.maxOrder + 1 + } + + if (rule.order == Int.MIN_VALUE) { + rule.order = replaceRuleDao.maxOrder + 1 + } + + replaceRuleDao.insert(rule) + + launch(Dispatchers.Main) { + onSuccess() + } + } + } + + + fun deleteGroups(groups: List) { + viewModelScope.launch { + replaceRuleDao.clearGroups(groups) + toggleGroupDialog(false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt index 8c5c1e6a8..d5929cd41 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable diff --git a/app/version.properties b/app/version.properties index 1a5b459ca..ec2fc947e 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,5 +1,5 @@ VERSION_MAJOR=3 VERSION_MINOR=26 VERSION_PATCH=5 -VERSION_SUFFIX=0 +VERSION_SUFFIX=1 # 1 = Pre, 0 = Release \ No newline at end of file