diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f8ca08f5..bea2b2978 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.room) alias(libs.plugins.ksp) alias(libs.plugins.google.services) @@ -181,6 +182,7 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.bundles.androidTest) implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.serialization.json) implementation(libs.bundles.coroutines) implementation(libs.core.ktx) implementation(libs.appcompat.appcompat) @@ -256,6 +258,11 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.compose.ui.viewbinding) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.compose.adaptive) + implementation(libs.androidx.compose.adaptive.layout) + implementation(libs.androidx.compose.adaptive.navigation) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.compose.material) implementation(libs.compose.materialIcons) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 95dbdc1b0..b1a19d69a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -211,13 +211,6 @@ android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize|stateHidden" /> - - + ReplaceEditViewModel( + app = get(), + replaceRuleDao = get(), + savedStateHandle = get() + ) + } viewModel { ReplaceRuleViewModel(androidApplication()) } viewModel { ExploreShowViewModel(get()) } viewModel { SearchContentViewModel(get(), get()) } diff --git a/app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt b/app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt index 1230bfce0..5df512a0b 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/EffectiveReplacesDialog.kt @@ -20,7 +20,8 @@ import io.legado.app.help.config.AppConfig import io.legado.app.lib.dialogs.alert //import io.legado.app.lib.theme.primaryColor import io.legado.app.model.ReadBook -import io.legado.app.ui.replace.edit.ReplaceEditActivity +import io.legado.app.ui.replace.ReplaceEditRoute +import io.legado.app.ui.replace.ReplaceRuleActivity import io.legado.app.utils.setLayout import io.legado.app.utils.viewbindingdelegate.viewBinding @@ -95,7 +96,8 @@ class EffectiveReplacesDialog : BaseDialogFragment(R.layout.dialog_recycler_view showChineseConvertAlert() return@let } - editActivity.launch(ReplaceEditActivity.startIntent(requireContext(), item.id)) + val editRoute = ReplaceEditRoute(id = item.id, pattern = item.pattern) + ReplaceRuleActivity.startIntent(requireContext(), editRoute) } } } diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt index 00938fad9..ee2876072 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt @@ -102,8 +102,8 @@ import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog import io.legado.app.ui.browser.WebViewActivity import io.legado.app.ui.dict.DictDialog import io.legado.app.ui.login.SourceLoginActivity +import io.legado.app.ui.replace.ReplaceEditRoute import io.legado.app.ui.replace.ReplaceRuleActivity -import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.ui.widget.PopupAction import io.legado.app.ui.widget.dialog.PhotoDialog import io.legado.app.utils.Debounce @@ -981,20 +981,23 @@ class ReadBookActivity : BaseReadBookActivity(), R.id.menu_replace -> { val scopes = arrayListOf() - ReadBook.book?.name?.let { - scopes.add(it) - } - ReadBook.bookSource?.bookSourceUrl?.let { - scopes.add(it) - } + ReadBook.book?.name?.let { scopes.add(it) } + ReadBook.bookSource?.bookSourceUrl?.let { scopes.add(it) } + val text = selectedText.lineSequence().map { it.trim() }.joinToString("\n") + + val editRoute = ReplaceEditRoute( + id = -1, + pattern = text, + scope = scopes.joinToString(";"), + isScopeTitle = false, + isScopeContent = true + ) + replaceActivity.launch( - ReplaceEditActivity.startIntent( - this, - pattern = text, - scope = scopes.joinToString(";"), - isScopeTitle = false, - isScopeContent = true + ReplaceRuleActivity.startIntent( + context = this, + editRoute = editRoute ) ) return true diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt index 848d7f60d..6c9faa8b0 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocActivity.kt @@ -14,13 +14,11 @@ import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.view.Menu import android.view.MenuItem -import android.view.MotionEvent import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView -import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentPagerAdapter @@ -29,7 +27,6 @@ import io.legado.app.R import io.legado.app.base.VMBaseActivity import io.legado.app.constant.EventBus import io.legado.app.data.entities.Book -import io.legado.app.data.entities.BookChapter import io.legado.app.databinding.ActivityChapterListBinding import io.legado.app.databinding.DialogDownloadChoiceBinding import io.legado.app.help.book.isLocal @@ -41,15 +38,11 @@ import io.legado.app.model.ReadBook import io.legado.app.ui.about.AppLogDialog import io.legado.app.ui.book.toc.rule.TxtTocRuleDialog import io.legado.app.ui.file.HandleFileContract +import io.legado.app.ui.replace.ReplaceEditRoute import io.legado.app.ui.replace.ReplaceRuleActivity -import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.ui.widget.dialog.WaitDialog -import io.legado.app.utils.applyTint -import io.legado.app.utils.dpToPx import io.legado.app.utils.gone -import io.legado.app.utils.hideSoftInput import io.legado.app.utils.observeEvent -import io.legado.app.utils.shouldHideSoftInput import io.legado.app.utils.showDialogFragment import io.legado.app.utils.viewbindingdelegate.viewBinding import io.legado.app.utils.visible @@ -262,14 +255,17 @@ class TocActivity : VMBaseActivity(), val scopes = arrayListOf() viewModel.bookData.value?.name?.let { scopes.add(it) } viewModel.bookSource?.bookSourceUrl?.let { scopes.add(it) } + + val editRoute = ReplaceEditRoute( + id = -1, + pattern = "text", + scope = scopes.joinToString(";"), + isScopeTitle = true, + isScopeContent = false + ) + replaceActivity.launch( - ReplaceEditActivity.startIntent( - this, - pattern = "text", - scope = scopes.joinToString(";"), - isScopeTitle = true, - isScopeContent = false - ) + ReplaceRuleActivity.startIntent(this, editRoute) ) return true } diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceEditRoute.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceEditRoute.kt new file mode 100644 index 000000000..6dc90c6c7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceEditRoute.kt @@ -0,0 +1,16 @@ +package io.legado.app.ui.replace + +import kotlinx.serialization.Serializable + +@Serializable +object ReplaceRuleRoute + +@Serializable +data class ReplaceEditRoute( + val id: Long = -1, + val pattern: String? = null, + val isRegex: Boolean = false, + val scope: String? = null, + val isScopeTitle: Boolean = false, + val isScopeContent: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt index bc5d98460..507703dfa 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleActivity.kt @@ -1,23 +1,95 @@ package io.legado.app.ui.replace -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity +import android.content.Context +import android.content.Intent +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable -import io.legado.app.ui.theme.AppTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import io.legado.app.base.BaseComposeActivity +import io.legado.app.ui.replace.edit.ReplaceEditScreen +import io.legado.app.ui.replace.edit.ReplaceEditViewModel +import io.legado.app.ui.theme.AppTheme +import kotlinx.serialization.json.Json +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf class ReplaceRuleActivity : BaseComposeActivity() { + companion object { + const val EXTRA_START_ROUTE = "start_route" + fun startIntent( + context: Context, + editRoute: ReplaceEditRoute? = null + ): Intent = Intent(context, ReplaceRuleActivity::class.java).apply { + editRoute?.let { + putExtra(EXTRA_START_ROUTE, Json.encodeToString(it)) + } + } + } + @Composable override fun Content() { AppTheme { - ReplaceRuleScreen(onBackClick = { finish() }) + val navController = rememberNavController() + val context = LocalActivity.current + + LaunchedEffect(Unit) { + context?.setResult(RESULT_OK) + } + + val startRouteJson = intent.getStringExtra(EXTRA_START_ROUTE) + val initialEditRoute = remember(startRouteJson) { + startRouteJson?.let { Json.decodeFromString(it) } + } + + val startDestination: Any = initialEditRoute ?: ReplaceRuleRoute + + SharedTransitionLayout { + NavHost( + navController = navController, + startDestination = startDestination + ) { + composable { + ReplaceRuleScreen( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@composable, + onBackClick = { finish() }, + onNavigateToEdit = { route -> navController.navigate(route) } + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + val viewModel: ReplaceEditViewModel = koinViewModel { parametersOf(route) } + + ReplaceEditScreen( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this@composable, + viewModel = viewModel, + onBack = { + // 如果编辑页是唯一的页面,点击返回应关闭 Activity + if (navController.previousBackStackEntry == null) { + finish() + } else { + navController.popBackStack() + } + }, + onSaveSuccess = { + if (navController.previousBackStackEntry == null) finish() + else navController.popBackStack() + }, + onShowHelp = { /* ... */ } + ) + } + } + } } } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - } - } + diff --git a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt index 700487730..e2111d093 100644 --- a/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt +++ b/app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt @@ -4,6 +4,8 @@ import android.content.ClipData import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -86,7 +88,6 @@ import com.google.gson.Gson import io.legado.app.R import io.legado.app.data.entities.ReplaceRule import io.legado.app.data.repository.UploadRepository -import io.legado.app.ui.replace.edit.ReplaceEditActivity import io.legado.app.ui.widget.components.ActionItem import io.legado.app.ui.widget.components.AnimatedText import io.legado.app.ui.widget.components.DraggableSelectionHandler @@ -111,8 +112,11 @@ import sh.calvin.reorderable.rememberReorderableLazyListState ) @Composable fun ReplaceRuleScreen( + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, viewModel: ReplaceRuleViewModel = koinViewModel(), - onBackClick: () -> Unit + onBackClick: () -> Unit, + onNavigateToEdit: (ReplaceEditRoute) -> Unit, ) { val uploadRepository: UploadRepository = koinInject() @@ -519,16 +523,24 @@ fun ReplaceRuleScreen( tooltip = { PlainTooltip { Text("Localized description") } }, state = rememberTooltipState(), ) { - FloatingActionButton( - modifier = Modifier.animateFloatingActionButton( - visible = !inSelectionMode, - alignment = Alignment.BottomEnd, - ), - onClick = { - context.startActivity(ReplaceEditActivity.startIntent(context)) + with(sharedTransitionScope) { + FloatingActionButton( + modifier = Modifier + .animateFloatingActionButton( + visible = !inSelectionMode, + alignment = Alignment.BottomEnd, + ) + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "fab_add"), + animatedVisibilityScope = animatedVisibilityScope, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds + ), + onClick = { + onNavigateToEdit(ReplaceEditRoute(id = -1)) + } + ) { + Icon(Icons.Default.Add, contentDescription = "Add Rule") } - ) { - Icon(Icons.Default.Add, contentDescription = "Add Rule") } } } @@ -555,44 +567,53 @@ fun ReplaceRuleScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(rules, key = { it.id }) { ui -> - ReorderableSelectionItem( - state = reorderableState, - key = ui.id, - title = ui.name, - isEnabled = ui.isEnabled, - isSelected = selectedRuleIds.contains(ui.id), - inSelectionMode = inSelectionMode, - canReorder = canReorder, - onToggleSelection = { - viewModel.toggleSelection(ui.id) - }, - onEnabledChange = { enabled -> - viewModel.update(ui.rule.copy(isEnabled = enabled)) - }, - onClickEdit = { - context.startActivity( - ReplaceEditActivity.startIntent( - context, - ui.id + with(sharedTransitionScope) { + ReorderableSelectionItem( + state = reorderableState, + key = ui.id, + title = ui.name, + isEnabled = ui.isEnabled, + isSelected = selectedRuleIds.contains(ui.id), + inSelectionMode = inSelectionMode, + canReorder = canReorder, + onToggleSelection = { + viewModel.toggleSelection(ui.id) + }, + onEnabledChange = { enabled -> + viewModel.update(ui.rule.copy(isEnabled = enabled)) + }, + onClickEdit = { + onNavigateToEdit( + ReplaceEditRoute( + id = ui.id, + pattern = ui.rule.pattern + ) ) - ) - }, - modifier = Modifier.padding(horizontal = 12.dp), - dropdownContent = { dismiss -> - DropdownMenuItem( - text = { Text("移至顶部") }, - onClick = { viewModel.toTop(ui.rule); dismiss() } - ) - DropdownMenuItem( - text = { Text("移至底部") }, - onClick = { viewModel.toBottom(ui.rule); dismiss() } - ) - DropdownMenuItem( - text = { Text("删除") }, - onClick = { showDeleteRuleDialog = ui.rule; dismiss() } - ) - } - ) + }, + modifier = Modifier + .padding(horizontal = 12.dp) + .sharedBounds( + sharedContentState = rememberSharedContentState(key = "rule_${ui.id}"), + animatedVisibilityScope = animatedVisibilityScope, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + //clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(12.dp)) + ), + dropdownContent = { dismiss -> + DropdownMenuItem( + text = { Text("移至顶部") }, + onClick = { viewModel.toTop(ui.rule); dismiss() } + ) + DropdownMenuItem( + text = { Text("移至底部") }, + onClick = { viewModel.toBottom(ui.rule); dismiss() } + ) + DropdownMenuItem( + text = { Text("删除") }, + onClick = { showDeleteRuleDialog = ui.rule; dismiss() } + ) + } + ) + } } } if (inSelectionMode) { 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 deleted file mode 100644 index df5fa4a55..000000000 --- a/app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditActivity.kt +++ /dev/null @@ -1,54 +0,0 @@ -package io.legado.app.ui.replace.edit - -import android.content.Context -import android.content.Intent -import androidx.activity.compose.setContent -import androidx.compose.runtime.Composable -import io.legado.app.ui.theme.AppTheme -import io.legado.app.base.BaseComposeActivity - -/** - * 编辑替换规则 - */ -class ReplaceEditActivity : BaseComposeActivity() { - - companion object { - fun startIntent( - context: Context, - id: Long = -1, - pattern: String? = null, - isRegex: Boolean = false, - scope: String? = null, - isScopeTitle: Boolean = false, - isScopeContent: Boolean = false - ): Intent = Intent(context, ReplaceEditActivity::class.java).apply { - putExtra("id", id) - putExtra("pattern", pattern) - putExtra("isRegex", isRegex) - putExtra("scope", scope) - putExtra("isScopeTitle", isScopeTitle) - putExtra("isScopeContent", isScopeContent) - } - } - - @Composable - override fun Content() { - setContent { - AppTheme { - ReplaceEditScreen( - onBack = { finish() }, - onSaveSuccess = { - setResult(RESULT_OK) - finish() - }, - onShowHelp = { key -> showHelp(key) } - ) - } - } - } - - private fun showHelp(key: String) { - - } - -} 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 index c82a10977..f76895336 100644 --- 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 @@ -1,6 +1,8 @@ package io.legado.app.ui.replace.edit import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background @@ -82,225 +84,267 @@ fun keyboardAsState(): State { @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ReplaceEditScreen( + sharedTransitionScope: SharedTransitionScope, + animatedVisibilityScope: AnimatedVisibilityScope, onBack: () -> Unit, onSaveSuccess: () -> Unit, onShowHelp: (String) -> Unit, viewModel: ReplaceEditViewModel = koinViewModel() ) { val state by viewModel.uiState.collectAsState() + val sharedKey = remember(state.id) { + if (state.id > 0) "rule_${state.id}" else "fab_add" + } 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 = { - GlassMediumFlexibleTopAppBar( - 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( + with(sharedTransitionScope) { + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( 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 + .nestedScroll(scrollBehavior.nestedScrollConnection) + .sharedBounds( + sharedContentState = rememberSharedContentState(key = sharedKey), + animatedVisibilityScope = animatedVisibilityScope, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + //clipInOverlayDuringTransition = OverlayClip(RoundedCornerShape(16.dp)) + ), + topBar = { + GlassMediumFlexibleTopAppBar( + title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + "Back" + ) + } }, - singleLine = true - ) + actions = { - GroupSelector( - currentGroup = state.group, - allGroups = state.allGroups, - onGroupChange = viewModel::onGroupChange, - onManageClick = { viewModel.toggleGroupDialog(true) } - ) + IconButton(onClick = { onShowHelp("regexHelp") }) { + Icon( + Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = "帮助" + ) + } - OutlinedTextField( - value = state.pattern, - onValueChange = viewModel::onPatternChange, - label = { Text("匹配规则") }, - placeholder = { Text("输入正则表达式或关键字") }, + 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 - .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 + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - 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 + + OutlinedTextField( + value = state.name, + onValueChange = viewModel::onNameChange, + label = { Text("规则名称") }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { + if (it.isFocused) viewModel.activeField = + ReplaceEditViewModel.ActiveField.Name + }, + singleLine = true ) - 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 + GroupSelector( + currentGroup = state.group, + allGroups = state.allGroups, + onGroupChange = viewModel::onGroupChange, + onManageClick = { viewModel.toggleGroupDialog(true) } ) - 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.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)) + } + } - OutlinedTextField( - value = state.scope, - onValueChange = viewModel::onScopeChange, - label = { Text("特定范围") }, - placeholder = { Text("指定规则适用的范围") }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - if (it.isFocused) viewModel.activeField = - ReplaceEditViewModel.ActiveField.Scope - } + 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) } ) + } - OutlinedTextField( - value = state.excludeScope, - onValueChange = viewModel::onExcludeScopeChange, - label = { Text("排除范围") }, - placeholder = { Text("指定规则不适用的范围") }, - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { - if (it.isFocused) viewModel.activeField = - ReplaceEditViewModel.ActiveField.Exclude - } + + if (state.showGroupDialog) { + ManageGroupDialog( + groups = state.allGroups.filter { it != "默认" }, + onDismiss = { viewModel.toggleGroupDialog(false) }, + onDelete = { viewModel.deleteGroups(it) } ) - - 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) 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 05ae1042d..aa3b7fe9b 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 @@ -6,21 +6,26 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute import io.legado.app.data.dao.ReplaceRuleDao import io.legado.app.data.entities.ReplaceRule import io.legado.app.exception.NoStackTraceException +import io.legado.app.ui.replace.ReplaceEditRoute 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.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class ReplaceEditUiState( - val id: Long = System.currentTimeMillis(), + val id: Long = 0, val name: TextFieldValue = TextFieldValue(""), val group: String = "默认", val pattern: TextFieldValue = TextFieldValue(""), @@ -41,6 +46,8 @@ class ReplaceEditViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { + private val route = savedStateHandle.toRoute() + private val _uiState = MutableStateFlow(ReplaceEditUiState()) val uiState = _uiState.asStateFlow() @@ -54,19 +61,22 @@ class ReplaceEditViewModel( private fun initData() { viewModelScope.launch { - val id = savedStateHandle.get("id") ?: -1L + + val id = route.id + if (id > 0) { val rule = replaceRuleDao.findById(id) rule?.let { updateStateFromRule(it) } } else { _uiState.update { it.copy( + id = System.currentTimeMillis(), name = TextFieldValue(""), - pattern = TextFieldValue(savedStateHandle["pattern"] ?: ""), - isRegex = savedStateHandle["isRegex"] ?: false, - scope = TextFieldValue(savedStateHandle["scope"] ?: ""), - scopeTitle = savedStateHandle["isScopeTitle"] ?: false, - scopeContent = savedStateHandle["isScopeContent"] ?: false, + pattern = TextFieldValue(route.pattern ?: ""), + isRegex = route.isRegex, + scope = TextFieldValue(route.scope ?: ""), + scopeTitle = route.isScopeTitle, + scopeContent = route.isScopeContent, excludeScope = TextFieldValue(""), ) } diff --git a/build.gradle.kts b/build.gradle.kts index 268349fc4..ca6a55bf9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.google.services) apply false alias(libs.plugins.room) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec9ea2924..900d47da5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,21 @@ [versions] accompanistWebview = "0.36.0" -animation = "1.10.0" +adaptive = "1.3.0-alpha06" +adaptiveLayout = "1.3.0-alpha06" +adaptiveNavigation = "1.3.0-alpha06" +animation = "1.10.1" biometric = "1.2.0" coilCompose = "2.7.0" -composeBom = "2025.12.01" +composeBom = "2026.01.00" constraintlayoutCompose = "1.1.1" coreSplashscreen = "1.2.0" datastorePreferences = "1.2.0" -foundation = "1.10.0" +foundation = "1.10.1" kotlin = "2.3.0" +kotlinxSerialization = "1.10.0" kotlinxCoroutinesAndroid = "1.10.2" +kotlinxSerializationJson = "1.10.0" ksp = "2.3.4" agp = "8.13.2" appcompat = "1.7.1" @@ -37,10 +42,11 @@ jsoupxpath = "2.5.3" coroutines = "1.10.2" lifecycleViewmodelCompose = "2.10.0" liveeventbus = "1.8.14" +navigation3 = "1.0.0" markdownCompose = "0.5.0" markwon = "4.6.2" material = "1.14.0-alpha08" -material3 = "1.5.0-alpha11" +material3 = "1.5.0-alpha12" material3IconsExtended = "1.7.8" materialKolor = "4.1.0" media = "1.7.1" @@ -89,10 +95,15 @@ androidx-annotation = { group = "androidx.annotation", name = "annotation", vers androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" } +androidx-compose-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } +androidx-compose-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptiveLayout" } +androidx-compose-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptiveNavigation" } androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } androidx-compose-material = { module = "androidx.compose.material:material" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } compose-materialIcons = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "material3IconsExtended" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } @@ -138,6 +149,7 @@ androidx-junit = { module = "androidx.test.ext:junit", version = "1.3.0" } hutool-crypto = { module = "cn.hutool:hutool-crypto", version.ref = "hutool" } kotlinx-coroutines-android-v181 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref = "libarchive" } lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } @@ -206,7 +218,7 @@ jsoupxpath = { module = "cn.wanghaomiao:JsoupXpath", version.ref = "jsoupxpath" junit = { module = "junit:junit", version = "4.13.2" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } - +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }