Przeglądaj źródła

Merge pull request #1675 from 52fisher/main

feat(toc): 预览页面添加搜索和管理页跳转、添加导入内置规则
Kudomaga 4 miesięcy temu
rodzic
commit
95c70209f4

+ 43 - 18
app/src/main/java/io/legado/app/ui/book/read/sheet/CharsetConfigSheet.kt

@@ -1,8 +1,12 @@
 package io.legado.app.ui.book.read.sheet
 package io.legado.app.ui.book.read.sheet
 
 
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.text.KeyboardOptions
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MenuAnchorType
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.material3.TextButton
@@ -11,20 +15,22 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.input.ImeAction
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppConst
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
 
 
+@OptIn(ExperimentalMaterial3Api::class)
 @Composable
 @Composable
 fun CharsetConfigSheet(
 fun CharsetConfigSheet(
     onDismissRequest: () -> Unit,
     onDismissRequest: () -> Unit,
 ) {
 ) {
     var charset by remember { mutableStateOf(ReadBook.book?.charset ?: "UTF-8") }
     var charset by remember { mutableStateOf(ReadBook.book?.charset ?: "UTF-8") }
-    val charsetEntries = remember { AppConst.charsets.toTypedArray() }
+    val charsetEntries = remember { AppConst.charsets }
+    var expanded by remember { mutableStateOf(false) }
 
 
     AlertDialog(
     AlertDialog(
         onDismissRequest = onDismissRequest,
         onDismissRequest = onDismissRequest,
@@ -32,20 +38,39 @@ fun CharsetConfigSheet(
         title = { Text(stringResource(R.string.set_charset)) },
         title = { Text(stringResource(R.string.set_charset)) },
         text = {
         text = {
             Column {
             Column {
-                OutlinedTextField(
-                    value = charset,
-                    onValueChange = { charset = it },
-                    label = { Text(stringResource(R.string.set_charset)) },
-                    singleLine = true,
-                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
-                )
-                TinyDropdownSettingItem(
-                    title = stringResource(R.string.set_charset),
-                    selectedValue = charset,
-                    displayEntries = charsetEntries,
-                    entryValues = charsetEntries,
-                    onValueChange = { charset = it },
-                )
+                ExposedDropdownMenuBox(
+                    expanded = expanded,
+                    onExpandedChange = { expanded = it },
+                ) {
+                    OutlinedTextField(
+                        value = charset,
+                        onValueChange = { charset = it },
+                        modifier = Modifier
+                            .menuAnchor(MenuAnchorType.PrimaryEditable)
+                            .fillMaxWidth(),
+                        readOnly = false,
+                        label = { Text(stringResource(R.string.set_charset)) },
+                        singleLine = true,
+                        trailingIcon = {
+                            ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
+                        },
+                        colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
+                    )
+                    ExposedDropdownMenu(
+                        expanded = expanded,
+                        onDismissRequest = { expanded = false },
+                    ) {
+                        charsetEntries.forEach { entry ->
+                            DropdownMenuItem(
+                                text = { Text(entry) },
+                                onClick = {
+                                    charset = entry
+                                    expanded = false
+                                },
+                            )
+                        }
+                    }
+                }
             }
             }
         },
         },
         confirmButton = {
         confirmButton = {

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt

@@ -329,6 +329,10 @@ fun TxtRuleScreen(
                 text = stringResource(R.string.import_str),
                 text = stringResource(R.string.import_str),
                 onClick = { showImportSheet = true; dismiss() }
                 onClick = { showImportSheet = true; dismiss() }
             )
             )
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.import_built_in_rules),
+                onClick = { onIntent(TxtTocRuleIntent.ImportBuiltInRules); dismiss() }
+            )
         }
         }
     ) { paddingValues ->
     ) { paddingValues ->
         Box(
         Box(

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleContract.kt

@@ -52,6 +52,7 @@ sealed interface TxtTocRuleIntent {
     data class ToggleImportAll(val isSelected: Boolean) : TxtTocRuleIntent
     data class ToggleImportAll(val isSelected: Boolean) : TxtTocRuleIntent
     data class UpdateImportItem(val index: Int, val rule: TxtTocRule) : TxtTocRuleIntent
     data class UpdateImportItem(val index: Int, val rule: TxtTocRule) : TxtTocRuleIntent
     data object SaveImportedRules : TxtTocRuleIntent
     data object SaveImportedRules : TxtTocRuleIntent
+    data object ImportBuiltInRules : TxtTocRuleIntent
 }
 }
 
 
 sealed interface TxtTocRuleEffect
 sealed interface TxtTocRuleEffect

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt

@@ -9,6 +9,7 @@ import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.InteractionState
+import io.legado.app.help.DefaultData
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
@@ -17,6 +18,7 @@ import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.isJsonObject
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 
 
@@ -73,6 +75,7 @@ class TxtTocRuleViewModel(
             is TxtTocRuleIntent.ToggleImportAll -> toggleImportAll(intent.isSelected)
             is TxtTocRuleIntent.ToggleImportAll -> toggleImportAll(intent.isSelected)
             is TxtTocRuleIntent.UpdateImportItem -> updateImportItem(intent.index, intent.rule)
             is TxtTocRuleIntent.UpdateImportItem -> updateImportItem(intent.index, intent.rule)
             TxtTocRuleIntent.SaveImportedRules -> saveImportedRules()
             TxtTocRuleIntent.SaveImportedRules -> saveImportedRules()
+            TxtTocRuleIntent.ImportBuiltInRules -> importBuiltInRules()
         }
         }
     }
     }
 
 
@@ -177,6 +180,13 @@ class TxtTocRuleViewModel(
         context.sendToClip(GSON.toJson(rule))
         context.sendToClip(GSON.toJson(rule))
     }
     }
 
 
+    private fun importBuiltInRules() {
+        viewModelScope.launch(Dispatchers.IO) {
+            DefaultData.importDefaultTocRules()
+            context.toastOnUi(R.string.import_built_in_rules)
+        }
+    }
+
     fun pasteRule(): TxtTocRule? {
     fun pasteRule(): TxtTocRule? {
         val text = context.getClipText()
         val text = context.getClipText()
         if (text.isNullOrBlank()) {
         if (text.isNullOrBlank()) {

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewActivity.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.toc.rule.preview
 import android.content.Intent
 import android.content.Intent
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.base.BaseComposeActivity
+import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
 
 
 class TxtTocRulePreviewActivity : BaseComposeActivity() {
 class TxtTocRulePreviewActivity : BaseComposeActivity() {
 
 
@@ -22,6 +23,9 @@ class TxtTocRulePreviewActivity : BaseComposeActivity() {
                 setResult(RESULT_OK, data)
                 setResult(RESULT_OK, data)
                 finish()
                 finish()
             },
             },
+            onOpenManagePage = {
+                startActivity(Intent(this, TxtTocRuleActivity::class.java))
+            },
         )
         )
     }
     }
 }
 }

+ 16 - 1
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewContract.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.toc.rule.preview
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.TxtTocRule
 import io.legado.app.data.entities.TxtTocRule
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentListOf
 
 
 @Stable
 @Stable
@@ -14,8 +15,17 @@ data class TxtTocRulePreviewUiState(
     val activeSheet: TxtTocRulePreviewSheet? = null,
     val activeSheet: TxtTocRulePreviewSheet? = null,
     val isGridLayout: Boolean = true,
     val isGridLayout: Boolean = true,
     val editingRule: TxtTocRule? = null,
     val editingRule: TxtTocRule? = null,
+    val searchQuery: String = "",
+    val showSearch: Boolean = false,
 ) {
 ) {
     val hasSelection: Boolean get() = selectedRule.isNotEmpty()
     val hasSelection: Boolean get() = selectedRule.isNotEmpty()
+    val filteredRules: ImmutableList<TocRulePreviewItem>
+        get() = if (searchQuery.isBlank()) rules
+        else rules.filter {
+            it.rule.name.contains(searchQuery, ignoreCase = true) ||
+                    it.rule.example?.contains(searchQuery, ignoreCase = true) == true ||
+                    it.rule.rule.contains(searchQuery, ignoreCase = true)
+        }.toImmutableList()
 }
 }
 
 
 @Stable
 @Stable
@@ -35,12 +45,17 @@ sealed interface TxtTocRulePreviewIntent {
     data class ShowChapterList(val item: TocRulePreviewItem) : TxtTocRulePreviewIntent
     data class ShowChapterList(val item: TocRulePreviewItem) : TxtTocRulePreviewIntent
     data class SelectRule(val rule: String) : TxtTocRulePreviewIntent
     data class SelectRule(val rule: String) : TxtTocRulePreviewIntent
     data object ToggleLayout : TxtTocRulePreviewIntent
     data object ToggleLayout : TxtTocRulePreviewIntent
+    data object OpenManagePage : TxtTocRulePreviewIntent
     data class EditRule(val rule: TxtTocRule) : TxtTocRulePreviewIntent
     data class EditRule(val rule: TxtTocRule) : TxtTocRulePreviewIntent
     data object DismissEditDialog : TxtTocRulePreviewIntent
     data object DismissEditDialog : TxtTocRulePreviewIntent
     data class SaveRule(val rule: TxtTocRule) : TxtTocRulePreviewIntent
     data class SaveRule(val rule: TxtTocRule) : TxtTocRulePreviewIntent
+    data object ToggleSearch : TxtTocRulePreviewIntent
+    data class UpdateSearchQuery(val query: String) : TxtTocRulePreviewIntent
+    data object ApplyRule : TxtTocRulePreviewIntent
 }
 }
 
 
 sealed interface TxtTocRulePreviewEffect {
 sealed interface TxtTocRulePreviewEffect {
-    data class ApplyRule(val rule: String) : TxtTocRulePreviewEffect
     data class ShowToast(val message: String) : TxtTocRulePreviewEffect
     data class ShowToast(val message: String) : TxtTocRulePreviewEffect
+    data object OpenManagePage : TxtTocRulePreviewEffect
+    data class ApplyRule(val rule: String) : TxtTocRulePreviewEffect
 }
 }

+ 103 - 54
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewScreen.kt

@@ -1,5 +1,11 @@
 package io.legado.app.ui.book.toc.rule.preview
 package io.legado.app.ui.book.toc.rule.preview
 
 
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.fadeOut
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
@@ -33,6 +39,7 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
@@ -44,8 +51,10 @@ import io.legado.app.data.entities.TxtTocRule
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
-import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
@@ -68,6 +77,7 @@ fun TxtTocRulePreviewRouteScreen(
     viewModel: TxtTocRulePreviewViewModel = koinViewModel(),
     viewModel: TxtTocRulePreviewViewModel = koinViewModel(),
     onBack: () -> Unit,
     onBack: () -> Unit,
     onApplyRule: (String) -> Unit,
     onApplyRule: (String) -> Unit,
+    onOpenManagePage: () -> Unit,
 ) {
 ) {
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val context = LocalContext.current
     val context = LocalContext.current
@@ -79,8 +89,9 @@ fun TxtTocRulePreviewRouteScreen(
     LaunchedEffect(Unit) {
     LaunchedEffect(Unit) {
         viewModel.effects.collect { effect ->
         viewModel.effects.collect { effect ->
             when (effect) {
             when (effect) {
-                is TxtTocRulePreviewEffect.ApplyRule -> onApplyRule(effect.rule)
                 is TxtTocRulePreviewEffect.ShowToast -> context.toastOnUi(effect.message)
                 is TxtTocRulePreviewEffect.ShowToast -> context.toastOnUi(effect.message)
+                is TxtTocRulePreviewEffect.OpenManagePage -> onOpenManagePage()
+                is TxtTocRulePreviewEffect.ApplyRule -> onApplyRule(effect.rule)
             }
             }
         }
         }
     }
     }
@@ -89,7 +100,6 @@ fun TxtTocRulePreviewRouteScreen(
         state = uiState,
         state = uiState,
         onIntent = viewModel::onIntent,
         onIntent = viewModel::onIntent,
         onBack = onBack,
         onBack = onBack,
-        onApplyRule = onApplyRule,
     )
     )
 }
 }
 
 
@@ -99,7 +109,6 @@ fun TxtTocRulePreviewScreen(
     state: TxtTocRulePreviewUiState,
     state: TxtTocRulePreviewUiState,
     onIntent: (TxtTocRulePreviewIntent) -> Unit,
     onIntent: (TxtTocRulePreviewIntent) -> Unit,
     onBack: () -> Unit,
     onBack: () -> Unit,
-    onApplyRule: (String) -> Unit,
 ) {
 ) {
     // Chapter list bottom sheet
     // Chapter list bottom sheet
     when (val sheet = state.activeSheet) {
     when (val sheet = state.activeSheet) {
@@ -166,6 +175,13 @@ fun TxtTocRulePreviewScreen(
                 scrollBehavior = scrollBehavior,
                 scrollBehavior = scrollBehavior,
                 navigationIcon = { TopBarNavigationButton(onBack) },
                 navigationIcon = { TopBarNavigationButton(onBack) },
                 actions = {
                 actions = {
+                    // Search button
+                    TopBarActionButton(
+                        onClick = { onIntent(TxtTocRulePreviewIntent.ToggleSearch) },
+                        imageVector = AppIcons.Search,
+                        contentDescription = stringResource(R.string.search),
+                    )
+                    // Layout toggle button
                     TopBarActionButton(
                     TopBarActionButton(
                         onClick = { onIntent(TxtTocRulePreviewIntent.ToggleLayout) },
                         onClick = { onIntent(TxtTocRulePreviewIntent.ToggleLayout) },
                         imageVector = if (state.isGridLayout) {
                         imageVector = if (state.isGridLayout) {
@@ -177,13 +193,34 @@ fun TxtTocRulePreviewScreen(
                             if (state.isGridLayout) R.string.layout_mode_list else R.string.layout_mode_grid
                             if (state.isGridLayout) R.string.layout_mode_list else R.string.layout_mode_grid
                         ),
                         ),
                     )
                     )
+                    // Manage page button
+                    TopBarActionButton(
+                        onClick = { onIntent(TxtTocRulePreviewIntent.OpenManagePage) },
+                        imageVector = AppIcons.Settings,
+                        contentDescription = stringResource(R.string.manage),
+                    )
+                },
+                bottomContent = {
+                    AnimatedVisibility(
+                        modifier = Modifier.adaptiveHorizontalPadding(),
+                        visible = state.showSearch,
+                        enter = expandVertically() + fadeIn(),
+                        exit = shrinkVertically() + fadeOut()
+                    ) {
+                        SearchBar(
+                            query = state.searchQuery,
+                            onQueryChange = { onIntent(TxtTocRulePreviewIntent.UpdateSearchQuery(it)) },
+                            onSearch = {},
+                            placeholder = stringResource(R.string.search),
+                        )
+                    }
                 },
                 },
             )
             )
         },
         },
         floatingActionButton = {
         floatingActionButton = {
             if (state.hasSelection) {
             if (state.hasSelection) {
                 AppFloatingActionButton(
                 AppFloatingActionButton(
-                    onClick = { onApplyRule(state.selectedRule) },
+                    onClick = { onIntent(TxtTocRulePreviewIntent.ApplyRule) },
                     icon = Icons.Default.Check,
                     icon = Icons.Default.Check,
                     tooltipText = stringResource(R.string.ok),
                     tooltipText = stringResource(R.string.ok),
                 )
                 )
@@ -200,50 +237,52 @@ fun TxtTocRulePreviewScreen(
                 CircularProgressIndicator()
                 CircularProgressIndicator()
             }
             }
         } else if (state.isGridLayout) {
         } else if (state.isGridLayout) {
-            LazyVerticalGrid(
-                columns = GridCells.Fixed(2),
-                modifier = Modifier.fillMaxSize(),
-                contentPadding = adaptiveContentPadding(
-                    top = contentPadding.calculateTopPadding(),
-                    bottom = contentPadding.calculateBottomPadding() + 80.dp,
-                ),
-                horizontalArrangement = Arrangement.spacedBy(8.dp),
-                verticalArrangement = Arrangement.spacedBy(8.dp),
-            ) {
-                itemsIndexed(state.rules, key = { _, item -> item.rule.id }) { _, item ->
-                    RulePreviewCard(
-                        item = item,
-                        isSelected = item.rule.rule == state.selectedRule,
-                        onClick = {
-                            onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
-                            if (item.totalCount > 0) {
-                                onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
-                            }
-                        },
-                    )
-                }
-            }
-        } else {
-            LazyColumn(
-                modifier = Modifier.fillMaxSize(),
-                contentPadding = adaptiveContentPadding(
-                    top = contentPadding.calculateTopPadding(),
-                    bottom = contentPadding.calculateBottomPadding() + 80.dp,
-                ),
-                verticalArrangement = Arrangement.spacedBy(4.dp),
-            ) {
-                itemsIndexed(state.rules, key = { _, item -> item.rule.id }) { _, item ->
-                    RulePreviewListItem(
-                        item = item,
-                        isSelected = item.rule.rule == state.selectedRule,
-                        onClick = {
-                            onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
-                            if (item.totalCount > 0) {
-                                onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
-                            }
-                        },
-                    )
+                val displayRules = state.filteredRules
+                LazyVerticalGrid(
+                    columns = GridCells.Fixed(2),
+                    modifier = Modifier.fillMaxSize(),
+                    contentPadding = adaptiveContentPadding(
+                        top = contentPadding.calculateTopPadding(),
+                        bottom = contentPadding.calculateBottomPadding() + 80.dp,
+                    ),
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp),
+                ) {
+                    itemsIndexed(displayRules, key = { _, item -> item.rule.id }) { _, item ->
+                        RulePreviewCard(
+                            item = item,
+                            isSelected = item.rule.rule == state.selectedRule,
+                            onClick = {
+                                onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
+                                if (item.totalCount > 0) {
+                                    onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
+                                }
+                            },
+                        )
+                    }
                 }
                 }
+            } else {
+                val displayRules = state.filteredRules
+                LazyColumn(
+                    modifier = Modifier.fillMaxSize(),
+                    contentPadding = adaptiveContentPadding(
+                        top = contentPadding.calculateTopPadding(),
+                        bottom = contentPadding.calculateBottomPadding() + 80.dp,
+                    ),
+                    verticalArrangement = Arrangement.spacedBy(4.dp),
+                ) {
+                    itemsIndexed(displayRules, key = { _, item -> item.rule.id }) { _, item ->
+                        RulePreviewListItem(
+                            item = item,
+                            isSelected = item.rule.rule == state.selectedRule,
+                            onClick = {
+                                onIntent(TxtTocRulePreviewIntent.SelectRule(item.rule.rule))
+                                if (item.totalCount > 0) {
+                                    onIntent(TxtTocRulePreviewIntent.ShowChapterList(item))
+                                }
+                            },
+                        )
+                    }
             }
             }
         }
         }
     }
     }
@@ -255,12 +294,17 @@ private fun RulePreviewCard(
     isSelected: Boolean,
     isSelected: Boolean,
     onClick: () -> Unit,
     onClick: () -> Unit,
 ) {
 ) {
-    NormalCard(
+    val borderColor by animateColorAsState(
+        targetValue = if (isSelected) LegadoTheme.colorScheme.primary else Color.Transparent,
+        label = "borderColor"
+    )
+
+    GlassCard(
         onClick = onClick,
         onClick = onClick,
         border = if (isSelected) {
         border = if (isSelected) {
             BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary)
             BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary)
         } else {
         } else {
-            null
+            BorderStroke(0.5.dp, borderColor)
         },
         },
         modifier = Modifier.fillMaxWidth(),
         modifier = Modifier.fillMaxWidth(),
     ) {
     ) {
@@ -289,7 +333,7 @@ private fun RulePreviewCard(
             }
             }
             Surface(
             Surface(
                 shape = RoundedCornerShape(12.dp),
                 shape = RoundedCornerShape(12.dp),
-                color = LegadoTheme.colorScheme.primaryContainer,
+                color = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.85f),
                 modifier = Modifier
                 modifier = Modifier
                     .align(Alignment.BottomEnd)
                     .align(Alignment.BottomEnd)
                     .padding(8.dp),
                     .padding(8.dp),
@@ -319,12 +363,17 @@ private fun RulePreviewListItem(
     isSelected: Boolean,
     isSelected: Boolean,
     onClick: () -> Unit,
     onClick: () -> Unit,
 ) {
 ) {
-    NormalCard(
+    val borderColor by animateColorAsState(
+        targetValue = if (isSelected) LegadoTheme.colorScheme.primary else Color.Transparent,
+        label = "borderColor"
+    )
+
+    GlassCard(
         onClick = onClick,
         onClick = onClick,
         border = if (isSelected) {
         border = if (isSelected) {
             BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary)
             BorderStroke(1.5.dp, LegadoTheme.colorScheme.primary)
         } else {
         } else {
-            null
+            BorderStroke(0.5.dp, borderColor)
         },
         },
         modifier = Modifier.fillMaxWidth(),
         modifier = Modifier.fillMaxWidth(),
     ) {
     ) {
@@ -360,7 +409,7 @@ private fun RulePreviewListItem(
 
 
             Surface(
             Surface(
                 shape = RoundedCornerShape(12.dp),
                 shape = RoundedCornerShape(12.dp),
-                color = LegadoTheme.colorScheme.primaryContainer,
+                color = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.85f),
             ) {
             ) {
                 if (item.totalCount < 0) {
                 if (item.totalCount < 0) {
                     CircularProgressIndicator(
                     CircularProgressIndicator(

+ 47 - 8
app/src/main/java/io/legado/app/ui/book/toc/rule/preview/TxtTocRulePreviewViewModel.kt

@@ -13,6 +13,7 @@ import io.legado.app.utils.Utf8BomUtils
 import io.legado.app.R
 import io.legado.app.R
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -38,6 +39,7 @@ class TxtTocRulePreviewViewModel(
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
 
 
     private var book: Book? = null
     private var book: Book? = null
+    private var lazyComputeJob: Job? = null
 
 
     fun init(bookUrl: String, currentTocRegex: String?) {
     fun init(bookUrl: String, currentTocRegex: String?) {
         viewModelScope.launch(Dispatchers.IO) {
         viewModelScope.launch(Dispatchers.IO) {
@@ -59,6 +61,9 @@ class TxtTocRulePreviewViewModel(
             is TxtTocRulePreviewIntent.ToggleLayout -> {
             is TxtTocRulePreviewIntent.ToggleLayout -> {
                 _uiState.update { it.copy(isGridLayout = !it.isGridLayout) }
                 _uiState.update { it.copy(isGridLayout = !it.isGridLayout) }
             }
             }
+            is TxtTocRulePreviewIntent.OpenManagePage -> {
+                _effects.tryEmit(TxtTocRulePreviewEffect.OpenManagePage)
+            }
             is TxtTocRulePreviewIntent.EditRule -> {
             is TxtTocRulePreviewIntent.EditRule -> {
                 _uiState.update { it.copy(activeSheet = null, editingRule = intent.rule) }
                 _uiState.update { it.copy(activeSheet = null, editingRule = intent.rule) }
             }
             }
@@ -70,6 +75,23 @@ class TxtTocRulePreviewViewModel(
                     saveRuleAndRefresh(intent.rule)
                     saveRuleAndRefresh(intent.rule)
                 }
                 }
             }
             }
+            is TxtTocRulePreviewIntent.ToggleSearch -> {
+                _uiState.update {
+                    it.copy(
+                        showSearch = !it.showSearch,
+                        searchQuery = if (it.showSearch) "" else it.searchQuery,
+                    )
+                }
+            }
+            is TxtTocRulePreviewIntent.UpdateSearchQuery -> {
+                _uiState.update { it.copy(searchQuery = intent.query) }
+            }
+            is TxtTocRulePreviewIntent.ApplyRule -> {
+                val selectedRule = _uiState.value.selectedRule
+                if (selectedRule.isNotEmpty()) {
+                    _effects.tryEmit(TxtTocRulePreviewEffect.ApplyRule(selectedRule))
+                }
+            }
         }
         }
     }
     }
 
 
@@ -82,9 +104,9 @@ class TxtTocRulePreviewViewModel(
 
 
         val allRules = getAllRules()
         val allRules = getAllRules()
 
 
-        // Add all rules as placeholders first (totalCount = -1 means not computed yet)
+        // Placeholders: totalCount = -1 means not computed yet, 0 means no book / computed empty
         val previewItems = allRules.map { tocRule ->
         val previewItems = allRules.map { tocRule ->
-            TocRulePreviewItem(rule = tocRule, totalCount = -1)
+            TocRulePreviewItem(rule = tocRule, totalCount = if (book != null) -1 else 0)
         }.toMutableList()
         }.toMutableList()
 
 
         _uiState.update {
         _uiState.update {
@@ -103,11 +125,17 @@ class TxtTocRulePreviewViewModel(
     }
     }
 
 
     private fun computeChaptersLazy(book: Book, rules: List<TxtTocRule>) {
     private fun computeChaptersLazy(book: Book, rules: List<TxtTocRule>) {
-        viewModelScope.launch(Dispatchers.IO) {
-            val results = rules.map { tocRule -> computePreview(book, tocRule) }
+        lazyComputeJob?.cancel()
+        lazyComputeJob = viewModelScope.launch(Dispatchers.IO) {
+            val resultMap = mutableMapOf<Long, TocRulePreviewItem>()
+            for (tocRule in rules) {
+                ensureActive()
+                val item = computePreview(book, tocRule)
+                resultMap[item.rule.id] = item
+            }
             _uiState.update { state ->
             _uiState.update { state ->
                 val newRules = state.rules.map { existing ->
                 val newRules = state.rules.map { existing ->
-                    results.find { it.rule.id == existing.rule.id } ?: existing
+                    resultMap[existing.rule.id] ?: existing
                 }.toImmutableList()
                 }.toImmutableList()
                 state.copy(rules = newRules)
                 state.copy(rules = newRules)
             }
             }
@@ -120,7 +148,7 @@ class TxtTocRulePreviewViewModel(
             val pattern = try {
             val pattern = try {
                 tocRule.rule.toPattern(Pattern.MULTILINE)
                 tocRule.rule.toPattern(Pattern.MULTILINE)
             } catch (e: PatternSyntaxException) {
             } catch (e: PatternSyntaxException) {
-                return TocRulePreviewItem(rule = tocRule, chapterCount = 0)
+                return TocRulePreviewItem(rule = tocRule, totalCount = 0)
             }
             }
             val (chapters, total) = analyzeWithPattern(book, pattern)
             val (chapters, total) = analyzeWithPattern(book, pattern)
             TocRulePreviewItem(
             TocRulePreviewItem(
@@ -130,7 +158,7 @@ class TxtTocRulePreviewViewModel(
                 chapters = chapters.take(500).toImmutableList(),
                 chapters = chapters.take(500).toImmutableList(),
             )
             )
         } catch (e: Exception) {
         } catch (e: Exception) {
-            TocRulePreviewItem(rule = tocRule, chapterCount = 0)
+            TocRulePreviewItem(rule = tocRule, totalCount = 0)
         }
         }
     }
     }
 
 
@@ -155,6 +183,9 @@ class TxtTocRulePreviewViewModel(
             repository.insert(updatedRule)
             repository.insert(updatedRule)
         }
         }
 
 
+        // Cancel pending lazy compute to avoid race condition
+        lazyComputeJob?.cancel()
+
         // Refresh the specific rule preview
         // Refresh the specific rule preview
         val currentRules = _uiState.value.rules.toMutableList()
         val currentRules = _uiState.value.rules.toMutableList()
         val index = currentRules.indexOfFirst { it.rule.id == updatedRule.id }
         val index = currentRules.indexOfFirst { it.rule.id == updatedRule.id }
@@ -171,6 +202,14 @@ class TxtTocRulePreviewViewModel(
         } else {
         } else {
             _uiState.update { it.copy(editingRule = null) }
             _uiState.update { it.copy(editingRule = null) }
         }
         }
+
+        // Restart lazy compute for remaining rules
+        if (book != null) {
+            val remainingRules = currentRules.filter { it.totalCount < 0 }.map { it.rule }
+            if (remainingRules.isNotEmpty()) {
+                computeChaptersLazy(book, remainingRules)
+            }
+        }
     }
     }
 
 
     private suspend fun getAllRules(): List<TxtTocRule> {
     private suspend fun getAllRules(): List<TxtTocRule> {
@@ -183,7 +222,7 @@ class TxtTocRulePreviewViewModel(
         return rules.filter { it.rule.isNotBlank() }.sortedBy { it.serialNumber }
         return rules.filter { it.rule.isNotBlank() }.sortedBy { it.serialNumber }
     }
     }
 
 
-       private suspend fun analyzeWithPattern(book: Book, pattern: Pattern): Pair<List<String>, Int> {
+    private suspend fun analyzeWithPattern(book: Book, pattern: Pattern): Pair<List<String>, Int> {
         val chapters = mutableListOf<String>()
         val chapters = mutableListOf<String>()
         var totalCount = 0
         var totalCount = 0
         val charset = book.fileCharset()
         val charset = book.fileCharset()

+ 3 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -423,7 +423,9 @@
 	<string name="sort_order">排序方向</string>
 	<string name="sort_order">排序方向</string>
 	<string name="layout_mode">布局模式</string>
 	<string name="layout_mode">布局模式</string>
 	<string name="layout_mode_list">列表</string>
 	<string name="layout_mode_list">列表</string>
-	<string name="layout_mode_grid">网格</string>
+    <string name="layout_mode_grid">网格</string>
+    <string name="manage">管理</string>
+    <string name="import_built_in_rules">导入内置规则</string>
 	<string name="grid_style">网格模式</string>
 	<string name="grid_style">网格模式</string>
 	<string name="compact_title_font">标题小字体</string>
 	<string name="compact_title_font">标题小字体</string>
 	<string name="center_aligned_title">标题居中</string>
 	<string name="center_aligned_title">标题居中</string>

+ 3 - 1
app/src/main/res/values/strings.xml

@@ -444,7 +444,9 @@
 	<string name="sort_order">Sort Order</string>
 	<string name="sort_order">Sort Order</string>
 	<string name="layout_mode">Layout Mode</string>
 	<string name="layout_mode">Layout Mode</string>
 	<string name="layout_mode_list">List</string>
 	<string name="layout_mode_list">List</string>
-	<string name="layout_mode_grid">Grid</string>
+    <string name="layout_mode_grid">Grid</string>
+    <string name="manage">Manage</string>
+    <string name="import_built_in_rules">Import Built-in Rules</string>
 	<string name="grid_style">Grid Style</string>
 	<string name="grid_style">Grid Style</string>
 	<string name="compact_title_font">Compact Title Font</string>
 	<string name="compact_title_font">Compact Title Font</string>
 	<string name="center_aligned_title">Center-Aligned Title</string>
 	<string name="center_aligned_title">Center-Aligned Title</string>