Эх сурвалжийг харах

[新增] 由Compose重写的替换净化编辑界面,没啥变化,可能有BUG

HapeLee 11 сар өмнө
parent
commit
dcff01c8d8

+ 2 - 4
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")

+ 37 - 0
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<AppDatabase>().readRecordDao }
+    single { get<AppDatabase>().bookDao }
+    single { get<AppDatabase>().bookChapterDao }
+    single { ReadRecordRepository(get()) }
+    viewModel { ReadRecordViewModel(get(), get(), get()) }
+
+    // Explore
+    single<ExploreRepository> { ExploreRepositoryImpl(get()) }
+    viewModel { ExploreShowViewModel(get()) }
+
+    // Bookmark
+    viewModel {
+        AllBookmarkViewModel(
+            androidApplication(),
+            get()
+        )
+    }
+}

+ 0 - 16
app/src/main/java/io/legado/app/di/bookmarkModule.kt

@@ -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()
-        )
-    }
-}

+ 0 - 12
app/src/main/java/io/legado/app/di/exploreModule.kt

@@ -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<ExploreRepository> { ExploreRepositoryImpl(get()) }
-    viewModel { ExploreShowViewModel(get()) }
-}

+ 0 - 16
app/src/main/java/io/legado/app/di/readRecordModule.kt

@@ -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<AppDatabase>().readRecordDao }
-    single { get<AppDatabase>().bookDao }
-    single { get<AppDatabase>().bookChapterDao }
-    single { ReadRecordRepository(get()) }
-
-    viewModel { ReadRecordViewModel(get(), get(), get()) }
-}

+ 0 - 1
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

+ 17 - 176
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<ActivityReplaceEditBinding, ReplaceEditViewModel>(),
-    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<ReplaceEditViewModel>()
-    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 showHelp(key: String) {
 
-    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 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<SelectItem<String>> =
-        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)
-            }
-        }
-    }
 }

+ 370 - 0
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<Boolean> {
+    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<String>,
+    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<String>,
+    onDismiss: () -> Unit,
+    onDelete: (List<String>) -> Unit
+) {
+
+    val selected = remember { mutableStateMapOf<String, Boolean>() }
+
+    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)
+            )
+        }
+    }
+}

+ 236 - 50
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<String> = 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<Long>("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
-                )
+                _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(""),
+                    )
+                }
             }
-        }.onFinally {
-            replaceRule?.let {
-                finally(it)
+        }
+    }
+
+    private fun observeGroups() {
+        viewModelScope.launch {
+            replaceRuleDao.flowGroups().collectLatest { groups ->
+                _uiState.update { it.copy(allGroups = listOf("默认") + groups) }
             }
         }
     }
 
-    fun pasteRule(success: (ReplaceRule) -> Unit) {
-        execute(context = Dispatchers.Main) {
-            val text = context.getClipText()
-            if (text.isNullOrBlank()) {
-                throw NoStackTraceException("剪贴板为空")
+    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<ReplaceRule>(text).getOrNull()
+                    ?: throw NoStackTraceException("格式不对")
+
+                launch(Dispatchers.Main) {
+                    updateStateFromRule(pastedRule)
+                    onSuccess()
+                }
+            } catch (e: Exception) {
+                launch(Dispatchers.Main) {
+                    app.toastOnUi(e.localizedMessage ?: "格式不对")
+                }
             }
-            GSON.fromJsonObject<ReplaceRule>(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
+    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()
             }
-            appDb.replaceRuleDao.insert(replaceRule)
-        }.onSuccess {
-            success()
-        }.onError {
-            context.toastOnUi("save error, ${it.localizedMessage}")
         }
     }
 
-}
+
+    fun deleteGroups(groups: List<String>) {
+        viewModelScope.launch {
+            replaceRuleDao.clearGroups(groups)
+            toggleGroupDialog(false)
+        }
+    }
+}

+ 0 - 1
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

+ 1 - 1
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