Просмотр исходного кода

[新增] 重写了Txt目录界面,优化了规则类的相关逻辑和界面

HapeLee 9 месяцев назад
Родитель
Сommit
b6a1814555
35 измененных файлов с 2134 добавлено и 2093 удалено
  1. 289 0
      app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt
  2. 12 1
      app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt
  3. 12 6
      app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt
  4. 9 24
      app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt
  5. 45 0
      app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt
  6. 15 12
      app/src/main/java/io/legado/app/di/appModule.kt
  7. 3 3
      app/src/main/java/io/legado/app/ui/about/AboutScreen.kt
  8. 2 2
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  9. 10 19
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  10. 1 1
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
  11. 351 0
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
  12. 8 253
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt
  13. 0 236
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleAdapter.kt
  14. 2 3
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleDialog.kt
  15. 124 52
      app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleViewModel.kt
  16. 0 84
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt
  17. 146 326
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
  18. 77 225
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt
  19. 1 1
      app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt
  20. 246 423
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt
  21. 122 309
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt
  22. 0 47
      app/src/main/java/io/legado/app/ui/widget/GlassTonalIconButton.kt
  23. 6 3
      app/src/main/java/io/legado/app/ui/widget/components/Calender.kt
  24. 2 1
      app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt
  25. 2 0
      app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt
  26. 1 0
      app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
  27. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt
  28. 40 0
      app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt
  29. 98 0
      app/src/main/java/io/legado/app/ui/widget/components/button/SmallTopBarButton.kt
  30. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
  31. 80 59
      app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt
  32. 2 1
      app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
  33. 19 0
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt
  34. 172 0
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt
  35. 235 0
      app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt

+ 289 - 0
app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt

@@ -0,0 +1,289 @@
+package io.legado.app.base
+
+import android.app.Application
+import android.net.Uri
+import androidx.core.net.toUri
+import androidx.lifecycle.viewModelScope
+import io.legado.app.constant.AppConst
+import io.legado.app.data.repository.UploadRepository
+import io.legado.app.help.http.decompressed
+import io.legado.app.help.http.newCallResponseBody
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.text
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
+import io.legado.app.ui.widget.components.importComponents.ImportStatus
+import io.legado.app.ui.widget.components.rules.RuleActionState
+import io.legado.app.ui.widget.components.rules.SelectableItem
+import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.isUri
+import io.legado.app.utils.readText
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+// 统一事件接口
+sealed interface BaseRuleEvent {
+    data class ShowSnackbar(
+        val message: String,
+        val actionLabel: String? = null,
+        val url: String? = null
+    ) : BaseRuleEvent
+}
+
+abstract class BaseRuleViewModel<T : SelectableItem<ID>, Entity, ID, S : RuleActionState<T>>(
+    application: Application,
+    initialState: S,
+    private val uploadRepository: UploadRepository? = null // 设为可空,提高灵活性
+) : BaseViewModel(application) {
+
+    protected val _searchKey = MutableStateFlow("")
+    protected val _selectedIds = MutableStateFlow<Set<ID>>(emptySet())
+    protected val _isSearchMode = MutableStateFlow(false)
+    protected val _isUploading = MutableStateFlow(false)
+    protected val _localItems = MutableStateFlow<List<T>?>(null)
+    protected val _importState = MutableStateFlow<BaseImportUiState<Entity>>(BaseImportUiState.Idle)
+    val importState = _importState.asStateFlow()
+    protected val _eventChannel = Channel<BaseRuleEvent>()
+    val events = _eventChannel.receiveAsFlow()
+
+    abstract val rawDataFlow: Flow<List<Entity>>
+
+    open fun filterData(data: List<Entity>, key: String): List<Entity> = data
+
+    @OptIn(FlowPreview::class)
+    private val itemsFlow: Flow<List<T>> by lazy {
+        combine(
+            rawDataFlow,
+            _searchKey.debounce(300L),
+            _localItems
+        ) { data, key, local ->
+            if (local != null && key.isEmpty()) {
+                local
+            } else {
+                filterData(data, key).map { it.toUiItem() }
+            }
+        }
+    }
+
+    val uiState: StateFlow<S> by lazy {
+        combine(
+            itemsFlow,
+            _selectedIds,
+            _isSearchMode,
+            _isUploading,
+            _importState
+        ) { items, selectedIds, isSearch, isUploading, importState ->
+            composeUiState(items, selectedIds, isSearch, isUploading, importState)
+        }.stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(5000),
+            initialValue = initialState
+        )
+    }
+
+    abstract fun composeUiState(
+        items: List<T>,
+        selectedIds: Set<ID>,
+        isSearch: Boolean,
+        isUploading: Boolean,
+        importState: BaseImportUiState<Entity>
+    ): S
+
+    // 抽象方法:将实体转换为UI Item
+    abstract fun Entity.toUiItem(): T
+
+    // 抽象方法:生成用于导出/上传的JSON
+    abstract suspend fun generateJson(entities: List<Entity>): String
+
+    // 抽象方法:解析导入的文本为实体列表
+    abstract fun parseImportRules(text: String): List<Entity>
+
+    // 抽象方法:判断新旧实体是否有变化 (用于导入去重)
+    abstract fun hasChanged(newRule: Entity, oldRule: Entity): Boolean
+
+    // 抽象方法:根据ID查找旧实体 (用于导入对比)
+    abstract suspend fun findOldRule(newRule: Entity): Entity?
+
+    // 抽象方法:保存导入的规则
+    abstract fun saveImportedRules()
+
+    fun moveItemInList(from: Int, to: Int) {
+        val currentList = uiState.value.items.toMutableList()
+        if (from !in currentList.indices || to !in currentList.indices) return
+        val item = currentList.removeAt(from)
+        currentList.add(to, item)
+        _localItems.value = currentList
+    }
+
+    fun setSearchKey(key: String?) {
+        _localItems.value = null
+        _searchKey.value = key ?: ""
+    }
+
+    fun setSearchMode(active: Boolean) {
+        _isSearchMode.value = active
+        if (!active) setSearchKey("")
+    }
+
+    fun toggleSelection(id: ID) {
+        _selectedIds.update { if (it.contains(id)) it - id else it + id }
+    }
+
+    fun setSelection(ids: Set<ID>) {
+        _selectedIds.value = ids
+    }
+
+    fun exportToUri(uri: Uri, rules: List<T>, selectedIds: Set<ID>) {
+        viewModelScope.launch(Dispatchers.IO) {
+            try {
+                val rulesToExport = rules
+                    .filter { selectedIds.contains(it.id) }
+                    .map { ruleItemToEntity(it) }
+
+                if (rulesToExport.isEmpty()) {
+                    _eventChannel.send(BaseRuleEvent.ShowSnackbar("没有选中的规则可导出"))
+                    return@launch
+                }
+
+                val json = generateJson(rulesToExport)
+
+                context.contentResolver.openOutputStream(uri)?.use { outputStream ->
+                    outputStream.bufferedWriter().use { writer ->
+                        writer.write(json)
+                        writer.flush()
+                    }
+                }
+                _eventChannel.send(BaseRuleEvent.ShowSnackbar("导出成功"))
+            } catch (e: Exception) {
+                e.printStackTrace()
+                _eventChannel.send(BaseRuleEvent.ShowSnackbar("导出失败: ${e.localizedMessage}"))
+            }
+        }
+    }
+
+    abstract fun ruleItemToEntity(item: T): Entity
+
+    fun uploadSelectedRules(selectedIds: Set<ID>, rules: List<T>) {
+        val repo = uploadRepository ?: return
+        viewModelScope.launch {
+            if (selectedIds.isEmpty()) return@launch
+
+            _isUploading.value = true
+            try {
+                val json = withContext(Dispatchers.Default) {
+                    val rulesToExport = rules
+                        .filter { selectedIds.contains(it.id) }
+                        .map { ruleItemToEntity(it) }
+                    generateJson(rulesToExport)
+                }
+
+                val url = repo.upload(
+                    fileName = "export_rules.json",
+                    file = json,
+                    contentType = "application/json"
+                )
+
+                _eventChannel.send(
+                    BaseRuleEvent.ShowSnackbar(
+                        message = "上传成功: $url",
+                        actionLabel = "复制链接",
+                        url = url
+                    )
+                )
+            } catch (e: Exception) {
+                _eventChannel.send(
+                    BaseRuleEvent.ShowSnackbar(
+                        message = "上传失败: ${e.localizedMessage}"
+                    )
+                )
+            } finally {
+                _isUploading.value = false
+            }
+        }
+    }
+
+    fun importSource(text: String) {
+        _importState.value = BaseImportUiState.Loading
+
+        viewModelScope.launch(Dispatchers.IO) {
+            runCatching {
+                val jsonText = resolveSource(text.trim())
+                val rules = parseImportRules(jsonText)
+                val wrappers = rules.map { newRule ->
+                    val oldRule = findOldRule(newRule)
+
+                    val status = when {
+                        oldRule == null -> ImportStatus.New
+                        hasChanged(newRule, oldRule) -> ImportStatus.Update
+                        else -> ImportStatus.Existing
+                    }
+
+                    ImportItemWrapper(
+                        data = newRule,
+                        oldData = oldRule,
+                        status = status,
+                        isSelected = status != ImportStatus.Existing
+                    )
+                }
+
+                _importState.value = BaseImportUiState.Success(
+                    source = text,
+                    items = wrappers
+                )
+            }.onFailure {
+                it.printStackTrace()
+                _importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
+            }
+        }
+    }
+
+    protected suspend fun resolveSource(text: String): String {
+        return when {
+            text.isAbsUrl() -> {
+                okHttpClient.newCallResponseBody {
+                    if (text.endsWith("#requestWithoutUA")) {
+                        url(text.substringBeforeLast("#requestWithoutUA"))
+                        header(AppConst.UA_NAME, "null")
+                    } else {
+                        url(text)
+                    }
+                }.decompressed().text("utf-8")
+            }
+
+            text.isUri() -> text.toUri().readText(context)
+            else -> text
+        }
+    }
+
+    fun cancelImport() {
+        _importState.value = BaseImportUiState.Idle
+    }
+
+    fun toggleImportSelection(index: Int) {
+        val currentState = _importState.value as? BaseImportUiState.Success<Entity> ?: return
+        val newItems = currentState.items.toMutableList()
+        val item = newItems[index]
+        newItems[index] = item.copy(isSelected = !item.isSelected)
+        _importState.value = currentState.copy(items = newItems)
+    }
+
+    fun toggleImportAll(isSelected: Boolean) {
+        val currentState = _importState.value as? BaseImportUiState.Success<Entity> ?: return
+        val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
+        _importState.value = currentState.copy(items = newItems)
+    }
+
+}

+ 12 - 1
app/src/main/java/io/legado/app/data/dao/TxtTocRuleDao.kt

@@ -1,6 +1,11 @@
 package io.legado.app.data.dao
 
-import androidx.room.*
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
 import io.legado.app.data.entities.TxtTocRule
 import kotlinx.coroutines.flow.Flow
 
@@ -10,6 +15,12 @@ interface TxtTocRuleDao {
     @Query("select * from txtTocRules order by serialNumber")
     fun observeAll(): Flow<List<TxtTocRule>>
 
+    @Query("select * from txtTocRules where name LIKE '%' || :key || '%' order by serialNumber")
+    fun flowSearch(key: String): Flow<List<TxtTocRule>>
+
+    @Query("select * from txtTocRules where id in (:ids)")
+    fun getByIds(ids: Set<Long>): List<TxtTocRule>
+
     @get:Query("select * from txtTocRules order by serialNumber")
     val all: List<TxtTocRule>
 

+ 12 - 6
app/src/main/java/io/legado/app/data/repository/DictRuleRepository.kt

@@ -22,16 +22,22 @@ class DictRuleRepository {
         return dao.all
     }
 
-    fun insert(vararg rule: DictRule) {
-        dao.insert(*rule)
+    suspend fun insert(vararg rule: DictRule) {
+        withContext(Dispatchers.IO) {
+            dao.insert(*rule)
+        }
     }
 
-    fun delete(vararg rule: DictRule) {
-        dao.delete(*rule)
+    suspend fun delete(vararg rule: DictRule) {
+        withContext(Dispatchers.IO) {
+            dao.delete(*rule)
+        }
     }
 
-    fun update(vararg rule: DictRule) {
-        dao.update(*rule)
+    suspend fun update(vararg rule: DictRule) {
+        withContext(Dispatchers.IO) {
+            dao.update(*rule)
+        }
     }
 
     suspend fun findById(id: String): DictRule? = withContext(Dispatchers.IO) {

+ 9 - 24
app/src/main/java/io/legado/app/data/repository/ReplaceRuleRepository.kt

@@ -3,13 +3,11 @@ package io.legado.app.data.repository
 import android.text.TextUtils
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.ReplaceRule
-import io.legado.app.ui.replace.ReplaceRuleItemUi
 import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.withContext
-import kotlin.collections.map
 
 class ReplaceRuleRepository {
 
@@ -39,6 +37,12 @@ class ReplaceRuleRepository {
         }
     }
 
+    suspend fun insert(vararg rule: ReplaceRule) {
+        withContext(Dispatchers.IO) {
+            appDb.replaceRuleDao.insert(*rule)
+        }
+    }
+
     suspend fun delete(rule: ReplaceRule) {
         withContext(Dispatchers.IO) {
             appDb.replaceRuleDao.delete(rule)
@@ -80,24 +84,6 @@ class ReplaceRuleRepository {
         }
     }
 
-    suspend fun enableSelection(rules: List<ReplaceRule>) {
-        withContext(Dispatchers.IO) {
-            val array = Array(rules.size) {
-                rules[it].copy(isEnabled = true)
-            }
-            appDb.replaceRuleDao.update(*array)
-        }
-    }
-
-    suspend fun disableSelection(rules: List<ReplaceRule>) {
-        withContext(Dispatchers.IO) {
-            val array = Array(rules.size) {
-                rules[it].copy(isEnabled = false)
-            }
-            appDb.replaceRuleDao.update(*array)
-        }
-    }
-
     suspend fun addGroup(group: String) {
         withContext(Dispatchers.IO) {
             val sources = appDb.replaceRuleDao.noGroup
@@ -205,14 +191,13 @@ class ReplaceRuleRepository {
             }
         }
 
-    suspend fun moveOrder(currentRules: List<ReplaceRuleItemUi>, isDesc: Boolean = false) {
+    suspend fun moveOrder(currentRules: List<ReplaceRule>, isDesc: Boolean = false) {
         withContext(Dispatchers.IO) {
             val size = currentRules.size
-            val updatedRules = currentRules.mapIndexed { index, itemUi ->
+            val updatedRules = currentRules.mapIndexed { index, rule ->
                 val order = if (isDesc) size - index else index + 1
-                itemUi.rule.copy(order = order)
+                rule.copy(order = order)
             }
-
             appDb.replaceRuleDao.update(*updatedRules.toTypedArray())
         }
     }

+ 45 - 0
app/src/main/java/io/legado/app/data/repository/TxtTocRuleRepository.kt

@@ -0,0 +1,45 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.TxtTocRule
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+
+class TxtTocRuleRepository {
+    private val dao = appDb.txtTocRuleDao
+
+    fun flowAll(): Flow<List<TxtTocRule>> = dao.observeAll()
+
+    fun flowSearch(key: String): Flow<List<TxtTocRule>> = dao.flowSearch(key)
+
+    suspend fun insert(vararg rules: TxtTocRule) = withContext(Dispatchers.IO) {
+        dao.insert(*rules)
+    }
+
+    suspend fun update(vararg rules: TxtTocRule) = withContext(Dispatchers.IO) {
+        dao.update(*rules)
+    }
+
+    suspend fun delete(vararg rules: TxtTocRule) = withContext(Dispatchers.IO) {
+        dao.delete(*rules)
+    }
+
+    suspend fun deleteByIds(ids: Collection<Long>) = withContext(Dispatchers.IO) {
+        val rules = dao.getByIds(ids.toSet())
+        dao.delete(*rules.toTypedArray())
+    }
+
+    suspend fun enableByIds(ids: Collection<Long>, enable: Boolean) = withContext(Dispatchers.IO) {
+        val rules = dao.getByIds(ids.toSet())
+        val updated = rules.map { it.copy(enable = enable) }
+        dao.update(*updated.toTypedArray())
+    }
+
+    suspend fun saveOrder(rules: List<TxtTocRule>) = withContext(Dispatchers.IO) {
+        rules.forEachIndexed { index, rule ->
+            rule.serialNumber = index + 1
+        }
+        dao.update(*rules.toTypedArray())
+    }
+}

+ 15 - 12
app/src/main/java/io/legado/app/di/appModule.kt

@@ -12,13 +12,15 @@ 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.book.searchContent.SearchContentViewModel
+import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.main.my.MyViewModel
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleViewModel
 import io.legado.app.ui.replace.edit.ReplaceEditViewModel
-import org.koin.android.ext.koin.androidApplication
+import org.koin.core.module.dsl.singleOf
 import org.koin.core.module.dsl.viewModel
+import org.koin.core.module.dsl.viewModelOf
 import org.koin.dsl.module
 
 val appModule = module {
@@ -27,15 +29,21 @@ val appModule = module {
     single { get<AppDatabase>().bookDao }
     single { get<AppDatabase>().bookChapterDao }
 
-    single { ReadRecordRepository(get()) }
-
-    single { BookRepository(get(), get()) }
+    singleOf(::ReadRecordRepository)
+    singleOf(::BookRepository)
+    singleOf(::SearchContentRepository)
 
     single<UploadRepository> { DirectLinkUploadRepository() }
     single<ExploreRepository> { ExploreRepositoryImpl(get()) }
-    single { SearchContentRepository() }
 
-    viewModel { ReadRecordViewModel(get(), get()) }
+    viewModelOf(::DictRuleViewModel)
+    viewModelOf(::ReadRecordViewModel)
+    viewModelOf(::ExploreShowViewModel)
+    viewModelOf(::MyViewModel)
+    viewModelOf(::ReplaceRuleViewModel)
+    viewModelOf(::AllBookmarkViewModel)
+    viewModelOf(::TxtTocRuleViewModel)
+
     viewModel { (route: ReplaceEditRoute) ->
         ReplaceEditViewModel(
             app = get(),
@@ -43,11 +51,6 @@ val appModule = module {
             savedStateHandle = get()
         )
     }
-    viewModel { ReplaceRuleViewModel(androidApplication()) }
-    viewModel { DictRuleViewModel(androidApplication()) }
-    viewModel { ExploreShowViewModel(get()) }
-    viewModel { SearchContentViewModel(get(), get()) }
-    viewModel { MyViewModel(get()) }
 
-    viewModel { AllBookmarkViewModel(androidApplication(), get()) }
+    viewModelOf(::SearchContentViewModel)
 }

+ 3 - 3
app/src/main/java/io/legado/app/ui/about/AboutScreen.kt

@@ -33,11 +33,11 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import io.legado.app.R
 import io.legado.app.constant.AppConst.appInfo
-import io.legado.app.ui.widget.GlassBackIconButton
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.SettingItem
 import io.legado.app.ui.widget.components.SplicedColumnGroup
-import io.legado.app.ui.widget.components.TextCard
+import io.legado.app.ui.widget.components.button.SmallTopBarButton
+import io.legado.app.ui.widget.components.card.TextCard
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
 @Composable
@@ -61,7 +61,7 @@ fun AboutScreen(
             GlassMediumFlexibleTopAppBar(
                 title = { Text(stringResource(R.string.about)) },
                 navigationIcon = {
-                    GlassBackIconButton(onClick = onBack)
+                    SmallTopBarButton(onClick = onBack)
                 },
                 scrollBehavior = scrollBehavior
             )

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -91,12 +91,12 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
 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.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.SearchBarSection
-import io.legado.app.ui.widget.components.TextCard
+import io.legado.app.ui.widget.components.button.AnimatedTextButton
+import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow

+ 10 - 19
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -16,7 +16,6 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
 import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.filled.FolderOpen
 import androidx.compose.material.icons.filled.ImageSearch
 import androidx.compose.material.icons.filled.Replay
@@ -28,10 +27,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExposedDropdownMenuAnchorType
 import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.FilledTonalIconButton
 import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.IconButtonDefaults
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Scaffold
@@ -54,6 +50,8 @@ import io.legado.app.R
 import io.legado.app.ui.book.changecover.ChangeCoverDialog
 import io.legado.app.ui.widget.components.Cover
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.button.SmallTopBarButton
+import io.legado.app.ui.widget.components.button.TopBarButtonVariant
 import io.legado.app.utils.SelectImageContract
 import io.legado.app.utils.launch
 import io.legado.app.utils.showDialogFragment
@@ -74,22 +72,16 @@ fun BookInfoEditScreen(
             GlassMediumFlexibleTopAppBar(
                 title = { Text(text = stringResource(id = R.string.book_info_edit)) },
                 navigationIcon = {
-                    IconButton(onClick = onBack) {
-                        Icon(
-                            imageVector = Icons.AutoMirrored.Filled.ArrowBack,
-                            contentDescription = null
-                        )
-                    }
+                    SmallTopBarButton(
+                        onClick = onBack,
+                        style = TopBarButtonVariant.Outlined
+                    )
                 },
                 actions = {
-                    FilledTonalIconButton(
+                    SmallTopBarButton(
                         onClick = { viewModel.save(onSave) },
-                        shapes = IconButtonDefaults.shapes()) {
-                        Icon(
-                            imageVector = Icons.Default.Save,
-                            contentDescription = stringResource(id = R.string.action_save)
-                        )
-                    }
+                        imageVector = Icons.Default.Save
+                    )
                 },
                 scrollBehavior = scrollBehavior
             )
@@ -213,8 +205,7 @@ fun BookInfoEditContent(
             value = uiState.intro ?: "",
             onValueChange = { viewModel.onIntroChange(it) },
             label = { Text("简介") },
-            modifier = Modifier.fillMaxWidth(),
-            maxLines = 5
+            modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
         OutlinedTextField(

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -59,8 +59,8 @@ import io.legado.app.ui.widget.components.AnimatedText
 import io.legado.app.ui.widget.components.EmptyMessageView
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.SearchBarSection
-import io.legado.app.ui.widget.components.TextCard
 import io.legado.app.ui.widget.components.button.AnimatedActionButton
+import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
 import org.koin.androidx.compose.koinViewModel
 

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

@@ -0,0 +1,351 @@
+package io.legado.app.ui.book.toc.rule
+
+import android.content.ClipData
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.ClipEntry
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.base.BaseRuleEvent
+import io.legado.app.data.entities.TxtTocRule
+import io.legado.app.ui.widget.components.ActionItem
+import io.legado.app.ui.widget.components.DraggableSelectionHandler
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
+import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
+import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
+import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
+import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import io.legado.app.ui.widget.components.rules.RuleEditFields
+import io.legado.app.ui.widget.components.rules.RuleEditSheet
+import io.legado.app.ui.widget.components.rules.RuleListScaffold
+import org.koin.androidx.compose.koinViewModel
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun TxtRuleScreen(
+    viewModel: TxtTocRuleViewModel = koinViewModel(),
+    onBackClick: () -> Unit
+) {
+
+    val context = LocalContext.current
+    val uiState by viewModel.uiState.collectAsState()
+
+    val rules = uiState.items
+    val selectedIds = uiState.selectedIds
+    val inSelectionMode = selectedIds.isNotEmpty()
+
+    val listState = rememberLazyListState()
+    val hapticFeedback = LocalHapticFeedback.current
+
+    var showEditSheet by remember { mutableStateOf(false) }
+    var editingRule by remember { mutableStateOf<TxtTocRule?>(null) }
+    var showDeleteRuleDialog by remember { mutableStateOf<TxtTocRule?>(null) }
+    var showUrlInput by remember { mutableStateOf(false) }
+    var showFilePickerSheet by remember { mutableStateOf(false) }
+    var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
+
+    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+        viewModel.moveItemInList(from.index, to.index)
+        hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
+    }
+
+    val clipboardManager = LocalClipboard.current
+    val snackbarHostState = remember { SnackbarHostState() }
+    val importState by viewModel.importState.collectAsStateWithLifecycle()
+    val sheetState = rememberModalBottomSheetState()
+
+    LaunchedEffect(Unit) {
+        viewModel.events.collect { event ->
+            when (event) {
+                is BaseRuleEvent.ShowSnackbar -> {
+                    val result = snackbarHostState.showSnackbar(
+                        message = event.message,
+                        actionLabel = event.actionLabel,
+                        withDismissAction = true
+                    )
+                    if (result == SnackbarResult.ActionPerformed && event.url != null) {
+                        clipboardManager.setClipEntry(
+                            ClipEntry(
+                                ClipData.newPlainText(
+                                    "url",
+                                    event.url
+                                )
+                            )
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    val importDoc = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocument(),
+        onResult = { uri ->
+            uri?.let {
+                context.contentResolver.openInputStream(it)?.use { stream ->
+                    val text = stream.reader().readText()
+                    viewModel.importSource(text)
+                }
+            }
+        }
+    )
+
+    val exportDoc = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.CreateDocument("application/json"),
+        onResult = { uri ->
+            uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
+        }
+    )
+
+    if (showUrlInput) {
+        SourceInputDialog(
+            title = stringResource(R.string.import_on_line),
+            onDismissRequest = { showUrlInput = false },
+            onConfirm = {
+                showUrlInput = false
+                viewModel.importSource(it)
+            }
+        )
+    }
+
+    if (showFilePickerSheet) {
+        FilePickerSheet(
+            sheetState = sheetState,
+            onDismissRequest = { showFilePickerSheet = false },
+            mode = filePickerMode,
+            onSelectSysDir = {
+                showFilePickerSheet = false
+                exportDoc.launch("exportDictRule.json")
+            },
+            onSelectSysFile = {},
+            onUpload = {
+                showFilePickerSheet = false
+                viewModel.uploadSelectedRules(selectedIds, rules)
+            },
+            allowExtensions = arrayOf("json")
+        )
+    }
+
+    (importState as? BaseImportUiState.Success<TxtTocRule>)?.let { state ->
+        BatchImportDialog(
+            title = "导入词典规则",
+            importState = state,
+            onDismissRequest = { viewModel.cancelImport() },
+            onToggleItem = { viewModel.toggleImportSelection(it) },
+            onToggleAll = { viewModel.toggleImportAll(it) },
+            onConfirm = { viewModel.saveImportedRules() },
+            itemContent = { rule, _ ->
+                Column {
+                    Text(rule.name, style = MaterialTheme.typography.titleMedium)
+                    Text(rule.rule, style = MaterialTheme.typography.bodySmall, maxLines = 1)
+                }
+            }
+        )
+    }
+
+    LaunchedEffect(reorderableState.isAnyItemDragging) {
+        if (!reorderableState.isAnyItemDragging) {
+            viewModel.saveSortOrder()
+        }
+    }
+
+    showDeleteRuleDialog?.let { rule ->
+        AlertDialog(
+            onDismissRequest = { showDeleteRuleDialog = null },
+            title = { Text(stringResource(R.string.delete)) },
+            text = { Text(stringResource(R.string.del_msg)) },
+            confirmButton = {
+                OutlinedButton(onClick = {
+                    viewModel.delete(rule); showDeleteRuleDialog = null
+                }) { Text(stringResource(R.string.ok)) }
+            },
+            dismissButton = {
+                TextButton(onClick = { showDeleteRuleDialog = null }) {
+                    Text(
+                        stringResource(R.string.cancel)
+                    )
+                }
+            }
+        )
+    }
+
+    if (showEditSheet) {
+        RuleEditSheet(
+            rule = editingRule,
+            title = stringResource(R.string.dict_rule),
+            label1 = stringResource(R.string.url_rule),
+            label2 = stringResource(R.string.show_rule),
+            onDismissRequest = {
+                showEditSheet = false
+                editingRule = null
+            },
+            onSave = { updatedRule ->
+                viewModel.update(updatedRule)
+                showEditSheet = false
+                editingRule = null
+            },
+            onCopy = { viewModel.copyRule(it) },
+            onPaste = { viewModel.pasteRule() },
+            toFields = { r ->
+                RuleEditFields(
+                    name = r?.name ?: "",
+                    rule1 = r?.rule ?: "",
+                    rule2 = r?.example ?: ""
+                )
+            },
+            fromFields = { fields, old ->
+                old?.copy(
+                    name = fields.name,
+                    rule = fields.rule1,
+                    example = fields.rule2
+                ) ?: TxtTocRule(
+                    name = fields.name,
+                    rule = fields.rule1,
+                    example = fields.rule2
+                )
+            }
+        )
+    }
+
+    RuleListScaffold(
+        title = "字典规则",
+        state = uiState,
+        onBackClick = { onBackClick() },
+        onSearchToggle = { active ->
+            viewModel.setSearchMode(active)
+        },
+        onSearchQueryChange = { viewModel.setSearchKey(it) },
+        searchPlaceholder = stringResource(R.string.replace_purify_search),
+        onClearSelection = { viewModel.setSelection(emptySet()) },
+        onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
+        onSelectInvert = {
+            val allIds = rules.map { it.id }.toSet()
+            viewModel.setSelection(allIds - selectedIds)
+        },
+        selectionSecondaryActions = listOf(
+            ActionItem(text = stringResource(R.string.enable), onClick = {
+                viewModel.enableSelectionByIds(selectedIds)
+                viewModel.setSelection(emptySet())
+            }),
+            ActionItem(text = stringResource(R.string.disable_selection), onClick = {
+                viewModel.disableSelectionByIds(selectedIds)
+                viewModel.setSelection(emptySet())
+            }),
+            ActionItem(
+                text = stringResource(R.string.export),
+                onClick = { showFilePickerSheet = true })
+        ),
+        onDeleteSelected = { ids ->
+            @Suppress("UNCHECKED_CAST")
+            viewModel.delSelectionByIds(ids as Set<Long>)
+            viewModel.setSelection(emptySet())
+        },
+        onAddClick = {
+            editingRule = null
+            showEditSheet = true
+        },
+        snackbarHostState = snackbarHostState,
+        dropDownMenuContent = { dismiss ->
+            DropdownMenuItem(
+                text = { Text("在线导入") },
+                onClick = { dismiss(); showUrlInput = true })
+            DropdownMenuItem(
+                text = { Text("本地导入") },
+                onClick = {
+                    dismiss(); importDoc.launch(
+                    arrayOf(
+                        "text/plain",
+                        "application/json"
+                    )
+                )
+                })
+        }
+    ) { paddingValues ->
+        Box(modifier = Modifier
+            .fillMaxSize()
+            .padding(paddingValues)) {
+            FastScrollLazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                state = listState,
+                contentPadding = PaddingValues(top = 8.dp, bottom = 120.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                items(rules, key = { it.id }) { item ->
+                    ReorderableSelectionItem(
+                        state = reorderableState,
+                        key = item.id,
+                        title = item.name,
+                        subtitle = item.example,
+                        isEnabled = item.isEnabled,
+                        isSelected = selectedIds.contains(item.id),
+                        inSelectionMode = inSelectionMode,
+                        onToggleSelection = { viewModel.toggleSelection(item.id) },
+                        onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enable = enabled)) },
+                        onClickEdit = { editingRule = item.rule; showEditSheet = true },
+                        trailingAction = {
+                            SmallIconButton(
+                                onClick = { showDeleteRuleDialog = item.rule },
+                                icon = Icons.Default.Delete
+                            )
+                        }
+                    )
+                }
+            }
+            if (inSelectionMode) {
+                DraggableSelectionHandler(
+                    listState = listState,
+                    items = rules,
+                    selectedIds = selectedIds,
+                    onSelectionChange = { viewModel.setSelection(it) },
+                    idProvider = { it.id },
+                    modifier = Modifier
+                        .fillMaxHeight()
+                        .width(60.dp)
+                        .align(Alignment.TopStart)
+                )
+            }
+        }
+    }
+}

+ 8 - 253
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleActivity.kt

@@ -1,262 +1,17 @@
 package io.legado.app.ui.book.toc.rule
 
-import android.annotation.SuppressLint
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.activity.viewModels
-import androidx.appcompat.widget.PopupMenu
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.ItemTouchHelper
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.TxtTocRule
-import io.legado.app.databinding.ActivityTxtTocRuleBinding
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.DirectLinkUpload
-import io.legado.app.lib.dialogs.alert
 //import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.association.ImportTxtTocRuleDialog
-import io.legado.app.ui.file.HandleFileContract
-import io.legado.app.ui.qrcode.QrCodeResult
-import io.legado.app.ui.widget.SelectActionBar
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-import io.legado.app.ui.widget.recycler.VerticalDivider
-import io.legado.app.utils.ACache
-import io.legado.app.utils.GSON
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.launch
-import io.legado.app.utils.readText
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.showHelp
-import io.legado.app.utils.splitNotBlank
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.conflate
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.launch
+import androidx.compose.runtime.Composable
+import io.legado.app.base.BaseComposeActivity
+import io.legado.app.ui.theme.AppTheme
 
-class TxtTocRuleActivity : VMBaseActivity<ActivityTxtTocRuleBinding, TxtTocRuleViewModel>(),
-    TxtTocRuleAdapter.CallBack,
-    SelectActionBar.CallBack,
-    TxtTocRuleEditDialog.Callback,
-    PopupMenu.OnMenuItemClickListener {
+class TxtTocRuleActivity : BaseComposeActivity() {
 
-    override val viewModel: TxtTocRuleViewModel by viewModels()
-    override val binding: ActivityTxtTocRuleBinding by viewBinding(ActivityTxtTocRuleBinding::inflate)
-    private val adapter: TxtTocRuleAdapter by lazy {
-        TxtTocRuleAdapter(this, this)
-    }
-    private val importTocRuleKey = "tocRuleUrl"
-    private val qrCodeResult = registerForActivityResult(QrCodeResult()) {
-        it ?: return@registerForActivityResult
-        showDialogFragment(ImportTxtTocRuleDialog(it))
-    }
-    private val importDoc = registerForActivityResult(HandleFileContract()) {
-        kotlin.runCatching {
-            it.uri?.readText(this)?.let {
-                showDialogFragment(ImportTxtTocRuleDialog(it))
-            }
-        }.onFailure {
-            toastOnUi("readTextError:${it.localizedMessage}")
-        }
-    }
-    private val exportResult = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            alert(R.string.export_success) {
-                if (uri.toString().isAbsUrl()) {
-                    setMessage(DirectLinkUpload.getSummary())
-                }
-                val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                    editView.hint = getString(R.string.path)
-                    editView.setText(uri.toString())
-                }
-                customView { alertBinding.root }
-                okButton {
-                    sendToClip(uri.toString())
-                }
-            }
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        initView()
-        initBottomActionBar()
-        initData()
-    }
-
-    private fun initView() = binding.run {
-        //recyclerView.setEdgeEffectColor(primaryColor)
-        recyclerView.addItemDecoration(VerticalDivider(this@TxtTocRuleActivity))
-        recyclerView.adapter = adapter
-        // When this page is opened, it is in selection mode
-        val dragSelectTouchHelper =
-            DragSelectTouchHelper(adapter.dragSelectCallback).setSlideArea(16, 50)
-        dragSelectTouchHelper.attachToRecyclerView(binding.recyclerView)
-        dragSelectTouchHelper.activeSlideSelect()
-        // Note: need judge selection first, so add ItemTouchHelper after it.
-        val itemTouchCallback = ItemTouchCallback(adapter)
-        itemTouchCallback.isCanDrag = true
-        ItemTouchHelper(itemTouchCallback).attachToRecyclerView(binding.recyclerView)
-    }
-
-    private fun initBottomActionBar() {
-        binding.selectActionBar.setMainActionText(R.string.delete)
-        binding.selectActionBar.inflateMenu(R.menu.txt_toc_rule_sel)
-        binding.selectActionBar.setOnMenuItemClickListener(this)
-        binding.selectActionBar.setCallBack(this)
-    }
-
-    private fun initData() {
-        lifecycleScope.launch {
-            appDb.txtTocRuleDao.observeAll().catch {
-                AppLog.put("TXT目录规则界面获取数据失败\n${it.localizedMessage}", it)
-            }.flowOn(IO).conflate().collect { tocRules ->
-                adapter.setItems(tocRules, adapter.diffItemCallBack)
-            }
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.txt_toc_rule, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_add -> showDialogFragment(TxtTocRuleEditDialog())
-            R.id.menu_import_local -> importDoc.launch {
-                mode = HandleFileContract.FILE
-                allowExtensions = arrayOf("txt", "json")
-            }
-            R.id.menu_import_onLine -> showImportDialog()
-            R.id.menu_import_qr -> qrCodeResult.launch()
-            R.id.menu_import_default -> viewModel.importDefault()
-            R.id.menu_help -> showHelp("txtTocRuleHelp")
-
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    override fun del(source: TxtTocRule) {
-        alert(R.string.draw) {
-            setMessage(getString(R.string.sure_del) + "\n" + source.name)
-            noButton()
-            yesButton {
-                viewModel.del(source)
-            }
-        }
-    }
-
-    override fun edit(source: TxtTocRule) {
-        showDialogFragment(TxtTocRuleEditDialog(source.id))
-    }
-
-    override fun onClickSelectBarMainAction() {
-        delSourceDialog()
-    }
-
-    override fun revertSelection() {
-        adapter.revertSelection()
-    }
-
-    override fun selectAll(selectAll: Boolean) {
-        if (selectAll) {
-            adapter.selectAll()
-        } else {
-            adapter.revertSelection()
-        }
-    }
-
-    override fun saveTxtTocRule(txtTocRule: TxtTocRule) {
-        viewModel.save(txtTocRule)
-    }
-
-    override fun update(vararg source: TxtTocRule) {
-        viewModel.update(*source)
-    }
-
-    override fun toTop(source: TxtTocRule) {
-        viewModel.toTop(source)
-    }
-
-    override fun toBottom(source: TxtTocRule) {
-        viewModel.toBottom(source)
-    }
-
-    override fun upOrder() {
-        viewModel.upOrder()
-    }
-
-    override fun upCountView() {
-        binding.selectActionBar
-            .upCountView(adapter.selection.size, adapter.itemCount)
-    }
-
-    private fun delSourceDialog() {
-        alert(titleResource = R.string.draw, messageResource = R.string.sure_del) {
-            yesButton { viewModel.del(*adapter.selection.toTypedArray()) }
-            noButton()
-        }
-    }
-
-    @SuppressLint("InflateParams")
-    private fun showImportDialog() {
-        val aCache = ACache.get(cacheDir = false)
-        val defaultUrl = "https://gitee.com/fisher52/YueDuJson/raw/master/myTxtChapterRule.json"
-        val cacheUrls: MutableList<String> = aCache
-            .getAsString(importTocRuleKey)
-            ?.splitNotBlank(",")
-            ?.toMutableList()
-            ?: mutableListOf()
-        if (!cacheUrls.contains(defaultUrl)) {
-            cacheUrls.add(0, defaultUrl)
-        }
-        alert(titleResource = R.string.import_on_line) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.hint = "url"
-                editView.setFilterValues(cacheUrls)
-                editView.delCallBack = {
-                    cacheUrls.remove(it)
-                    aCache.put(importTocRuleKey, cacheUrls.joinToString(","))
-                }
-            }
-            customView { alertBinding.root }
-            okButton {
-                val text = alertBinding.editView.text?.toString()
-                text?.let {
-                    if (it.isAbsUrl() && !cacheUrls.contains(it)) {
-                        cacheUrls.add(0, it)
-                        aCache.put(importTocRuleKey, cacheUrls.joinToString(","))
-                    }
-                    showDialogFragment(ImportTxtTocRuleDialog(it))
-                }
-            }
-            cancelButton()
-        }
-    }
-
-    override fun onMenuItemClick(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_enable_selection -> viewModel.enableSelection(*adapter.selection.toTypedArray())
-            R.id.menu_disable_selection -> viewModel.disableSelection(*adapter.selection.toTypedArray())
-            R.id.menu_export_selection -> exportResult.launch {
-                mode = HandleFileContract.EXPORT
-                fileData = HandleFileContract.FileData(
-                    "exportTxtTocRule.json",
-                    GSON.toJson(adapter.selection).toByteArray(),
-                    "application/json"
-                )
-            }
+    @Composable
+    override fun Content() {
+        AppTheme {
+            TxtRuleScreen(onBackClick = { finish() })
         }
-        return true
     }
 
 }

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

@@ -1,236 +0,0 @@
-package io.legado.app.ui.book.toc.rule
-
-import android.content.Context
-import android.os.Bundle
-import android.view.View
-import android.view.ViewGroup
-import android.widget.PopupMenu
-import androidx.core.os.bundleOf
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.data.entities.TxtTocRule
-import io.legado.app.databinding.ItemTxtTocRuleBinding
-//import io.legado.app.lib.theme.backgroundColor
-import io.legado.app.ui.widget.recycler.DragSelectTouchHelper
-import io.legado.app.ui.widget.recycler.ItemTouchCallback
-
-class TxtTocRuleAdapter(context: Context, private val callBack: CallBack) :
-    RecyclerAdapter<TxtTocRule, ItemTxtTocRuleBinding>(context),
-    ItemTouchCallback.Callback {
-
-    private val selected = linkedSetOf<TxtTocRule>()
-
-    val selection: List<TxtTocRule>
-        get() = getItems().filter {
-            selected.contains(it)
-        }
-
-    val diffItemCallBack = object : DiffUtil.ItemCallback<TxtTocRule>() {
-
-        override fun areItemsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean {
-            return oldItem.id == newItem.id
-        }
-
-        override fun areContentsTheSame(oldItem: TxtTocRule, newItem: TxtTocRule): Boolean {
-            if (oldItem.name != newItem.name) {
-                return false
-            }
-            if (oldItem.enable != newItem.enable) {
-                return false
-            }
-            if (oldItem.example != newItem.example) {
-                return false
-            }
-            return true
-        }
-
-        override fun getChangePayload(oldItem: TxtTocRule, newItem: TxtTocRule): Any? {
-            val payload = Bundle()
-            if (oldItem.name != newItem.name) {
-                payload.putBoolean("upName", true)
-            }
-            if (oldItem.enable != newItem.enable) {
-                payload.putBoolean("enabled", newItem.enable)
-            }
-            if (oldItem.example != newItem.example) {
-                payload.putBoolean("upExample", true)
-            }
-            if (payload.isEmpty) {
-                return null
-            }
-            return payload
-        }
-    }
-
-    override fun getViewBinding(parent: ViewGroup): ItemTxtTocRuleBinding {
-        return ItemTxtTocRuleBinding.inflate(inflater, parent, false)
-    }
-
-    override fun convert(
-        holder: ItemViewHolder,
-        binding: ItemTxtTocRuleBinding,
-        item: TxtTocRule,
-        payloads: MutableList<Any>
-    ) {
-        binding.run {
-            if (payloads.isEmpty()) {
-                //root.setBackgroundColor(ColorUtils.withAlpha(context.backgroundColor, 0.5f))
-                cbSource.text = item.name
-                swtEnabled.isChecked = item.enable
-                cbSource.isChecked = selected.contains(item)
-                titleExample.text = item.example
-            } else {
-                for (i in payloads.indices) {
-                    val bundle = payloads[i] as Bundle
-                    bundle.keySet().forEach {
-                        when (it) {
-                            "selected" -> cbSource.isChecked = selected.contains(item)
-                            "upName" -> cbSource.text = item.name
-                            "upExample" -> titleExample.text = item.example
-                            "enabled" -> swtEnabled.isChecked = item.enable
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    override fun registerListener(holder: ItemViewHolder, binding: ItemTxtTocRuleBinding) {
-        binding.cbSource.setOnCheckedChangeListener { buttonView, isChecked ->
-            getItem(holder.layoutPosition)?.let {
-                if (buttonView.isPressed) {
-                    if (isChecked) {
-                        selected.add(it)
-                    } else {
-                        selected.remove(it)
-                    }
-                    callBack.upCountView()
-                }
-            }
-        }
-        binding.swtEnabled.setOnCheckedChangeListener { buttonView, isChecked ->
-            getItem(holder.layoutPosition)?.let {
-                if (buttonView.isPressed) {
-                    it.enable = isChecked
-                    callBack.update(it)
-                }
-            }
-        }
-        binding.ivEdit.setOnClickListener {
-            getItem(holder.layoutPosition)?.let {
-                callBack.edit(it)
-            }
-        }
-        binding.ivMenuMore.setOnClickListener {
-            showMenu(it, holder.layoutPosition)
-        }
-    }
-
-    override fun onCurrentListChanged() {
-        callBack.upCountView()
-    }
-
-    private fun showMenu(view: View, position: Int) {
-        val source = getItem(position) ?: return
-        val popupMenu = PopupMenu(context, view)
-        popupMenu.inflate(R.menu.txt_toc_rule_item)
-        popupMenu.setOnMenuItemClickListener { menuItem ->
-            when (menuItem.itemId) {
-                R.id.menu_top -> callBack.toTop(source)
-                R.id.menu_bottom -> callBack.toBottom(source)
-                R.id.menu_del ->  {
-                    callBack.del(source)
-                    selected.remove(source)
-                }
-            }
-            true
-        }
-        popupMenu.show()
-    }
-
-    fun selectAll() {
-        getItems().forEach {
-            selected.add(it)
-        }
-        notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
-        callBack.upCountView()
-    }
-
-    fun revertSelection() {
-        getItems().forEach {
-            if (selected.contains(it)) {
-                selected.remove(it)
-            } else {
-                selected.add(it)
-            }
-        }
-        notifyItemRangeChanged(0, itemCount, bundleOf(Pair("selected", null)))
-        callBack.upCountView()
-    }
-
-    override fun swap(srcPosition: Int, targetPosition: Int): Boolean {
-        val srcItem = getItem(srcPosition)
-        val targetItem = getItem(targetPosition)
-        if (srcItem != null && targetItem != null) {
-            if (srcItem.serialNumber == targetItem.serialNumber) {
-                callBack.upOrder()
-            } else {
-                val srcOrder = srcItem.serialNumber
-                srcItem.serialNumber = targetItem.serialNumber
-                targetItem.serialNumber = srcOrder
-                movedItems.add(srcItem)
-                movedItems.add(targetItem)
-            }
-        }
-        swapItem(srcPosition, targetPosition)
-        return true
-    }
-
-    private val movedItems = hashSetOf<TxtTocRule>()
-
-    override fun onClearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
-        if (movedItems.isNotEmpty()) {
-            callBack.update(*movedItems.toTypedArray())
-            movedItems.clear()
-        }
-    }
-
-    val dragSelectCallback: DragSelectTouchHelper.Callback =
-        object : DragSelectTouchHelper.AdvanceCallback<TxtTocRule>(Mode.ToggleAndReverse) {
-            override fun currentSelectedId(): MutableSet<TxtTocRule> {
-                return selected
-            }
-
-            override fun getItemId(position: Int): TxtTocRule {
-                return getItem(position)!!
-            }
-
-            override fun updateSelectState(position: Int, isSelected: Boolean): Boolean {
-                getItem(position)?.let {
-                    if (isSelected) {
-                        selected.add(it)
-                    } else {
-                        selected.remove(it)
-                    }
-                    notifyItemChanged(position, bundleOf(Pair("selected", null)))
-                    callBack.upCountView()
-                    return true
-                }
-                return false
-            }
-        }
-
-    interface CallBack {
-        fun del(source: TxtTocRule)
-        fun edit(source: TxtTocRule)
-        fun update(vararg source: TxtTocRule)
-        fun toTop(source: TxtTocRule)
-        fun toBottom(source: TxtTocRule)
-        fun upOrder()
-        fun upCountView()
-    }
-
-}

+ 2 - 3
app/src/main/java/io/legado/app/ui/book/toc/rule/TxtTocRuleDialog.kt

@@ -149,14 +149,13 @@ class TxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_toc_regex),
             }
             R.id.menu_import_onLine -> showImportDialog()
             R.id.menu_import_qr -> qrCodeResult.launch()
-            R.id.menu_import_default -> viewModel.importDefault()
             R.id.menu_help -> showHelp("txtTocRuleHelp")
         }
         return false
     }
 
     override fun saveTxtTocRule(txtTocRule: TxtTocRule) {
-        viewModel.save(txtTocRule)
+        viewModel.update(txtTocRule)
     }
 
     @SuppressLint("InflateParams")
@@ -294,7 +293,7 @@ class TxtTocRuleDialog() : BaseDialogFragment(R.layout.dialog_toc_regex),
                             setMessage(getString(R.string.sure_del) + "\n" + item.name)
                             noButton()
                             yesButton {
-                                viewModel.del(item)
+                                viewModel.delete(item)
                             }
                         }
                     }

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

@@ -1,79 +1,151 @@
 package io.legado.app.ui.book.toc.rule
 
 import android.app.Application
-import io.legado.app.base.BaseViewModel
-import io.legado.app.data.appDb
+import androidx.compose.runtime.Immutable
+import androidx.lifecycle.viewModelScope
+import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.data.entities.TxtTocRule
-import io.legado.app.help.DefaultData
+import io.legado.app.data.repository.TxtTocRuleRepository
+import io.legado.app.data.repository.UploadRepository
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.rules.InteractionState
+import io.legado.app.ui.widget.components.rules.RuleActionState
+import io.legado.app.ui.widget.components.rules.SelectableItem
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.getClipText
+import io.legado.app.utils.isJsonArray
+import io.legado.app.utils.isJsonObject
+import io.legado.app.utils.sendToClip
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
 
-class TxtTocRuleViewModel(app: Application) : BaseViewModel(app) {
+@Immutable
+data class TxtTocRuleItemUi(
+    override val id: Long,
+    val name: String,
+    val isEnabled: Boolean,
+    val rule: TxtTocRule,
+    val example: String = ""
+) : SelectableItem<Long>
 
-    fun save(txtTocRule: TxtTocRule) {
-        execute {
-            appDb.txtTocRuleDao.insert(txtTocRule)
-        }
+data class TxtTocRuleUiState(
+    override val items: List<TxtTocRuleItemUi> = emptyList(),
+    override val selectedIds: Set<Long> = emptySet(),
+    override val searchKey: String = "",
+    val interaction: InteractionState = InteractionState()
+) : RuleActionState<TxtTocRuleItemUi> {
+    override val isSearch: Boolean get() = interaction.isSearchMode
+    override val isUploading: Boolean get() = interaction.isUploading
+}
+
+
+class TxtTocRuleViewModel(
+    application: Application,
+    uploadRepository: UploadRepository
+) : BaseRuleViewModel<TxtTocRuleItemUi, TxtTocRule, Long, TxtTocRuleUiState>(
+    application,
+    TxtTocRuleUiState(interaction = InteractionState(isLoading = true)),
+    uploadRepository
+) {
+    private val repository = TxtTocRuleRepository()
+
+    override val rawDataFlow: Flow<List<TxtTocRule>> = repository.flowAll()
+
+    override fun TxtTocRule.toUiItem() =
+        TxtTocRuleItemUi(id, name, enable, this, example = example ?: "")
+
+    override fun filterData(data: List<TxtTocRule>, key: String): List<TxtTocRule> {
+        val filtered = if (key.isEmpty()) data
+        else data.filter { it.name.contains(key, ignoreCase = true) }
+        return filtered.sortedBy { it.serialNumber }
     }
 
-    fun del(vararg txtTocRule: TxtTocRule) {
-        execute {
-            appDb.txtTocRuleDao.delete(*txtTocRule)
-        }
+    override fun composeUiState(
+        items: List<TxtTocRuleItemUi>,
+        selectedIds: Set<Long>,
+        isSearch: Boolean,
+        isUploading: Boolean,
+        importState: BaseImportUiState<TxtTocRule>
+    ): TxtTocRuleUiState {
+        return TxtTocRuleUiState(
+            items = items,
+            selectedIds = selectedIds,
+            searchKey = _searchKey.value,
+            interaction = InteractionState(
+                isSearchMode = isSearch,
+                isUploading = isUploading || (importState is BaseImportUiState.Loading),
+                isLoading = false
+            )
+        )
     }
 
-    fun update(vararg txtTocRule: TxtTocRule) {
-        execute {
-            appDb.txtTocRuleDao.update(*txtTocRule)
+    fun saveSortOrder() {
+        val currentLocal = _localItems.value ?: return
+        viewModelScope.launch {
+            repository.saveOrder(currentLocal.map { it.rule })
+            _localItems.value = null
         }
     }
 
-    fun importDefault() {
-        execute {
-            DefaultData.importDefaultTocRules()
+    fun update(vararg rules: TxtTocRule) = viewModelScope.launch { repository.update(*rules) }
+    fun insert(vararg rules: TxtTocRule) = viewModelScope.launch { repository.insert(*rules) }
+    fun delete(vararg rules: TxtTocRule) = viewModelScope.launch { repository.delete(*rules) }
+
+    fun enableSelectionByIds(ids: Set<Long>) =
+        viewModelScope.launch { repository.enableByIds(ids, true) }
+
+    fun disableSelectionByIds(ids: Set<Long>) =
+        viewModelScope.launch { repository.enableByIds(ids, false) }
+
+    fun delSelectionByIds(ids: Set<Long>) = viewModelScope.launch { repository.deleteByIds(ids) }
+
+    override suspend fun generateJson(entities: List<TxtTocRule>): String = GSON.toJson(entities)
+
+    override fun parseImportRules(text: String): List<TxtTocRule> {
+        return when {
+            text.isJsonArray() -> GSON.fromJsonArray<TxtTocRule>(text).getOrThrow()
+            text.isJsonObject() -> listOf(GSON.fromJsonObject<TxtTocRule>(text).getOrThrow())
+            else -> throw Exception("格式不正确")
         }
     }
 
-    fun toTop(vararg rules: TxtTocRule) {
-        execute {
-            val minOrder = appDb.txtTocRuleDao.minOrder - 1
-            rules.forEachIndexed { index, source ->
-                source.serialNumber = minOrder - index
-            }
-            appDb.txtTocRuleDao.update(*rules)
-        }
+    override fun hasChanged(newRule: TxtTocRule, oldRule: TxtTocRule): Boolean {
+        return newRule.name != oldRule.name || newRule.rule != oldRule.rule || newRule.enable != oldRule.enable
     }
 
-    fun toBottom(vararg sources: TxtTocRule) {
-        execute {
-            val maxOrder = appDb.txtTocRuleDao.maxOrder + 1
-            sources.forEachIndexed { index, source ->
-                source.serialNumber = maxOrder + index
-            }
-            appDb.txtTocRuleDao.update(*sources)
-        }
+    override suspend fun findOldRule(newRule: TxtTocRule): TxtTocRule? {
+        return null
     }
 
-    fun upOrder() {
-        execute {
-            val sources = appDb.txtTocRuleDao.all
-            for ((index: Int, source: TxtTocRule) in sources.withIndex()) {
-                source.serialNumber = index + 1
-            }
-            appDb.txtTocRuleDao.update(*sources.toTypedArray())
+    override fun ruleItemToEntity(item: TxtTocRuleItemUi): TxtTocRule = item.rule
+
+    override fun saveImportedRules() {
+        val state = _importState.value as? BaseImportUiState.Success<TxtTocRule> ?: return
+        viewModelScope.launch {
+            val rulesToSave = state.items.filter { it.isSelected }.map { it.data }
+            repository.insert(*rulesToSave.toTypedArray())
+            _importState.value = BaseImportUiState.Idle
         }
     }
 
-    fun enableSelection(vararg txtTocRule: TxtTocRule) {
-        execute {
-            val array = txtTocRule.map { it.copy(enable = true) }.toTypedArray()
-            appDb.txtTocRuleDao.insert(*array)
-        }
+    fun copyRule(rule: TxtTocRule) {
+        context.sendToClip(GSON.toJson(rule))
     }
 
-    fun disableSelection(vararg txtTocRule: TxtTocRule) {
-        execute {
-            val array = txtTocRule.map { it.copy(enable = false) }.toTypedArray()
-            appDb.txtTocRuleDao.insert(*array)
+    fun pasteRule(): TxtTocRule? {
+        val text = context.getClipText()
+        if (text.isNullOrBlank()) {
+            context.toastOnUi("剪贴板没有内容")
+            return null
+        }
+        return try {
+            GSON.fromJsonObject<TxtTocRule>(text).getOrThrow()
+        } catch (e: Exception) {
+            context.toastOnUi("格式不对")
+            null
         }
     }
-
-}
+}

+ 0 - 84
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleActivity.kt

@@ -1,64 +1,12 @@
 package io.legado.app.ui.dict.rule
 
 //import io.legado.app.lib.theme.primaryColor
-import android.annotation.SuppressLint
-import android.os.Bundle
 import androidx.compose.runtime.Composable
-import io.legado.app.R
 import io.legado.app.base.BaseComposeActivity
-import io.legado.app.databinding.DialogEditTextBinding
-import io.legado.app.help.DirectLinkUpload
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.ui.association.ImportDictRuleDialog
-import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.theme.AppTheme
-import io.legado.app.utils.ACache
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.readText
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.splitNotBlank
-import io.legado.app.utils.toastOnUi
 
 class DictRuleActivity : BaseComposeActivity() {
 
-    private val importRecordKey = "dictRuleUrls"
-
-    private val importDoc = registerForActivityResult(HandleFileContract()) {
-        kotlin.runCatching {
-            it.uri?.readText(this)?.let {
-                showDialogFragment(
-                    ImportDictRuleDialog(it)
-                )
-            }
-        }.onFailure {
-            toastOnUi("readTextError:${it.localizedMessage}")
-        }
-    }
-    private val exportResult = registerForActivityResult(HandleFileContract()) {
-        it.uri?.let { uri ->
-            alert(R.string.export_success) {
-                if (uri.toString().isAbsUrl()) {
-                    setMessage(DirectLinkUpload.getSummary())
-                }
-                val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                    editView.hint = getString(R.string.path)
-                    editView.setText(uri.toString())
-                }
-                customView { alertBinding.root }
-                okButton {
-                    sendToClip(uri.toString())
-                }
-            }
-        }
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-
-        //observeDictRuleData()
-    }
-
     @Composable
     override fun Content() {
         AppTheme {
@@ -66,36 +14,4 @@ class DictRuleActivity : BaseComposeActivity() {
         }
     }
 
-    @SuppressLint("InflateParams")
-    private fun showImportDialog() {
-        val aCache = ACache.get(cacheDir = false)
-        val cacheUrls: MutableList<String> = aCache
-            .getAsString(importRecordKey)
-            ?.splitNotBlank(",")
-            ?.toMutableList() ?: mutableListOf()
-        alert(titleResource = R.string.import_on_line) {
-            val alertBinding = DialogEditTextBinding.inflate(layoutInflater).apply {
-                editView.hint = "url"
-                editView.setFilterValues(cacheUrls)
-                editView.delCallBack = {
-                    cacheUrls.remove(it)
-                    aCache.put(importRecordKey, cacheUrls.joinToString(","))
-                }
-            }
-            customView { alertBinding.root }
-            okButton {
-                val text = alertBinding.editView.text?.toString()
-                text?.let {
-                    if (it.isAbsUrl() && !cacheUrls.contains(it)) {
-                        cacheUrls.add(0, it)
-                        aCache.put(importRecordKey, cacheUrls.joinToString(","))
-                    }
-                    showDialogFragment(
-                        ImportDictRuleDialog(it)
-                    )
-                }
-            }
-            cancelButton()
-        }
-    }
 }

+ 146 - 326
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt

@@ -3,96 +3,63 @@ package io.legado.app.ui.dict.rule
 import android.content.ClipData
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Search
 import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.DropdownMenu
 import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.FloatingToolbarDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.PlainTooltip
-import androidx.compose.material3.Scaffold
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
-import androidx.compose.material3.TooltipAnchorPosition
-import androidx.compose.material3.TooltipBox
-import androidx.compose.material3.TooltipDefaults
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.material3.rememberModalBottomSheetState
-import androidx.compose.material3.rememberTooltipState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
-import androidx.compose.ui.zIndex
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.google.gson.Gson
 import io.legado.app.R
+import io.legado.app.base.BaseRuleEvent
 import io.legado.app.data.entities.DictRule
-import io.legado.app.data.repository.UploadRepository
 import io.legado.app.ui.widget.components.ActionItem
-import io.legado.app.ui.widget.components.AnimatedText
 import io.legado.app.ui.widget.components.DraggableSelectionHandler
-import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
-import io.legado.app.ui.widget.components.ReorderableSelectionItem
-import io.legado.app.ui.widget.components.SearchBarSection
-import io.legado.app.ui.widget.components.SelectionBottomBar
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
 import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
-import io.legado.app.utils.GSON
-import kotlinx.coroutines.launch
+import io.legado.app.ui.widget.components.rules.RuleEditFields
+import io.legado.app.ui.widget.components.rules.RuleEditSheet
+import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import org.koin.androidx.compose.koinViewModel
-import org.koin.compose.koinInject
 import sh.calvin.reorderable.rememberReorderableLazyListState
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -102,23 +69,22 @@ fun DictRuleScreen(
     onBackClick: () -> Unit
 ) {
 
-    val uploadRepository: UploadRepository = koinInject()
-
     val context = LocalContext.current
-    val scope = rememberCoroutineScope()
     val uiState by viewModel.uiState.collectAsState()
+
     val rules = uiState.items
-    val listState = rememberLazyListState()
-    var isSearch by remember { mutableStateOf(false) }
-    var showMenu by remember { mutableStateOf(false) }
-    val selectedIds by viewModel.selectedIds.collectAsState()
+    val selectedIds = uiState.selectedIds
     val inSelectionMode = selectedIds.isNotEmpty()
-    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+
+    val listState = rememberLazyListState()
     val hapticFeedback = LocalHapticFeedback.current
-    var showDeleteSelectedDialog by remember { mutableStateOf(false) }
+
     var showEditSheet by remember { mutableStateOf(false) }
     var editingRule by remember { mutableStateOf<DictRule?>(null) }
     var showDeleteRuleDialog by remember { mutableStateOf<DictRule?>(null) }
+    var showUrlInput by remember { mutableStateOf(false) }
+    var showFilePickerSheet by remember { mutableStateOf(false) }
+    var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
 
     val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
         viewModel.moveItemInList(from.index, to.index)
@@ -127,13 +93,33 @@ fun DictRuleScreen(
 
     val clipboardManager = LocalClipboard.current
     val snackbarHostState = remember { SnackbarHostState() }
-    var showUrlInput by remember { mutableStateOf(false) }
-    var showFilePickerSheet by remember { mutableStateOf(false) }
-    var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
-    var isUploading by remember { mutableStateOf(false) }
     val importState by viewModel.importState.collectAsStateWithLifecycle()
     val sheetState = rememberModalBottomSheetState()
 
+    LaunchedEffect(Unit) {
+        viewModel.events.collect { event ->
+            when (event) {
+                is BaseRuleEvent.ShowSnackbar -> {
+                    val result = snackbarHostState.showSnackbar(
+                        message = event.message,
+                        actionLabel = event.actionLabel,
+                        withDismissAction = true
+                    )
+                    if (result == SnackbarResult.ActionPerformed && event.url != null) {
+                        clipboardManager.setClipEntry(
+                            ClipEntry(
+                                ClipData.newPlainText(
+                                    "url",
+                                    event.url
+                                )
+                            )
+                        )
+                    }
+                }
+            }
+        }
+    }
+
     val importDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.OpenDocument(),
         onResult = { uri ->
@@ -149,18 +135,7 @@ fun DictRuleScreen(
     val exportDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.CreateDocument("application/json"),
         onResult = { uri ->
-            uri?.let { it ->
-                scope.launch {
-                    val rulesToExport = rules
-                        .filter { selectedIds.contains(it.name) }
-                        .map { it.rule }
-
-                    val json = Gson().toJson(rulesToExport)
-                    context.contentResolver.openOutputStream(it)?.use { stream ->
-                        stream.writer().write(json)
-                    }
-                }
-            }
+            uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
         }
     )
 
@@ -187,39 +162,7 @@ fun DictRuleScreen(
             onSelectSysFile = {},
             onUpload = {
                 showFilePickerSheet = false
-                scope.launch {
-                    val selectedRules = viewModel.getSelectedRules()
-                    val json = GSON.toJson(selectedRules)
-
-                    isUploading = true
-
-                    try {
-                        runCatching {
-                            uploadRepository.upload(
-                                fileName = "exportDictRule.json",
-                                file = json,
-                                contentType = "application/json"
-                            )
-                        }.onSuccess { url ->
-                            isUploading = false
-                            val result = snackbarHostState.showSnackbar(
-                                message = "上传成功: $url",
-                                actionLabel = "复制链接",
-                                withDismissAction = true
-                            )
-                            if (result == SnackbarResult.ActionPerformed) {
-                                clipboardManager.setClipEntry(
-                                    ClipEntry(ClipData.newPlainText("export url", url))
-                                )
-                            }
-                        }.onFailure { e ->
-                            isUploading = false
-                            snackbarHostState.showSnackbar("上传失败: ${e.localizedMessage}")
-                        }
-                    } finally {
-                        isUploading = false
-                    }
-                }
+                viewModel.uploadSelectedRules(selectedIds, rules)
             },
             allowExtensions = arrayOf("json")
         )
@@ -248,220 +191,143 @@ fun DictRuleScreen(
         }
     }
 
-    if (showDeleteRuleDialog != null) {
+    showDeleteRuleDialog?.let { rule ->
         AlertDialog(
             onDismissRequest = { showDeleteRuleDialog = null },
             title = { Text(stringResource(R.string.delete)) },
-            text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
-            confirmButton = {
-                OutlinedButton(
-                    onClick = {
-                        viewModel.delete(showDeleteRuleDialog!!)
-                        showDeleteRuleDialog = null
-                    },
-                    colors = ButtonDefaults.outlinedButtonColors(
-                        contentColor = MaterialTheme.colorScheme.error,
-                        containerColor = Color.Transparent,
-                    ),
-                ) {
-                    Text(stringResource(R.string.ok))
-                }
-            },
-            dismissButton = {
-                TextButton(onClick = { showDeleteRuleDialog = null }) {
-                    Text(stringResource(R.string.cancel))
-                }
-            }
-        )
-    }
-
-    if (showDeleteSelectedDialog) {
-        AlertDialog(
-            onDismissRequest = { showDeleteSelectedDialog = false },
-            title = { Text(stringResource(R.string.delete)) },
             text = { Text(stringResource(R.string.del_msg)) },
             confirmButton = {
-                OutlinedButton(
-                    onClick = {
-                        viewModel.delSelectionByIds(selectedIds)
-                        viewModel.setSelection(emptySet())
-                        showDeleteSelectedDialog = false
-                    },
-                    colors = ButtonDefaults.outlinedButtonColors(
-                        contentColor = MaterialTheme.colorScheme.error,
-                        containerColor = Color.Transparent,
-                    ),
-                ) {
-                    Text(stringResource(R.string.ok))
-                }
+                OutlinedButton(onClick = {
+                    viewModel.delete(rule); showDeleteRuleDialog = null
+                }) { Text(stringResource(R.string.ok)) }
             },
             dismissButton = {
-                TextButton(onClick = { showDeleteSelectedDialog = false }) {
-                    Text(stringResource(R.string.cancel))
+                TextButton(onClick = { showDeleteRuleDialog = null }) {
+                    Text(
+                        stringResource(R.string.cancel)
+                    )
                 }
             }
         )
     }
 
     if (showEditSheet) {
-        DictRuleEditSheet(
+        RuleEditSheet(
             rule = editingRule,
-            onDismissRequest = { showEditSheet = false },
-            onSave = {
-                if (editingRule == null) {
-                    viewModel.insert(it)
-                } else {
-                    viewModel.update(it)
-                }
+            title = stringResource(R.string.dict_rule),
+            label1 = stringResource(R.string.url_rule),
+            label2 = stringResource(R.string.show_rule),
+            onDismissRequest = {
                 showEditSheet = false
+                editingRule = null
+            },
+            onSave = { updatedRule ->
+                viewModel.update(updatedRule)
+                showEditSheet = false
+                editingRule = null
             },
             onCopy = { viewModel.copyRule(it) },
-            onPaste = { viewModel.pasteRule() }
+            onPaste = { viewModel.pasteRule() },
+            toFields = { r ->
+                RuleEditFields(
+                    name = r?.name ?: "",
+                    rule1 = r?.urlRule ?: "",
+                    extra = r?.showRule ?: ""
+                )
+            },
+            fromFields = { fields, old ->
+                old?.copy(
+                    name = fields.name,
+                    urlRule = fields.rule1,
+                    showRule = fields.extra
+                ) ?: DictRule(
+                    name = fields.name,
+                    urlRule = fields.rule1,
+                    showRule = fields.extra
+                )
+            }
         )
     }
 
-    Scaffold(
-        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
-        topBar = {
-            Column {
-                GlassMediumFlexibleTopAppBar(
-                    title = {
-                        val titleText = remember(isUploading, inSelectionMode, selectedIds, rules) {
-                            when {
-                                isUploading -> "正在上传..."
-                                inSelectionMode -> "已选择 ${selectedIds.size}/${rules.size}"
-                                else -> "字典规则"
-                            }
-                        }
-                        AnimatedText(
-                            text = titleText
-                        )
-                    },
-                    navigationIcon = {
-                        IconButton(
-                            onClick = {
-                                if (inSelectionMode) {
-                                    viewModel.setSelection(emptySet())
-                                } else {
-                                    onBackClick()
-                                }
-                            }
-                        ) {
-                            Icon(
-                                imageVector = if (inSelectionMode) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
-                                contentDescription = if (inSelectionMode) "Cancel" else "Back"
-                            )
-                        }
-                    },
-                    actions = {
-                        if (!inSelectionMode) {
-                            IconButton(onClick = { isSearch = !isSearch }) {
-                                Icon(Icons.Default.Search, contentDescription = "Search")
-                            }
-                            IconButton(onClick = { showMenu = !showMenu }) {
-                                Icon(Icons.Default.MoreVert, contentDescription = "More")
-                            }
-                            DropdownMenu(
-                                expanded = showMenu,
-                                onDismissRequest = { showMenu = false }
-                            ) {
-                                DropdownMenuItem(
-                                    text = { Text("在线导入") },
-                                    onClick = {
-                                        showMenu = false
-                                        showUrlInput = true
-                                    }
-                                )
-                                DropdownMenuItem(
-                                    text = { Text("本地导入") },
-                                    onClick = {
-                                        importDoc.launch(
-                                            arrayOf(
-                                                "text/plain",
-                                                "application/json"
-                                            )
-                                        ); showMenu = false
-                                    }
-                                )
-                            }
-                        }
-                    },
-                    scrollBehavior = scrollBehavior
-                )
-                AnimatedVisibility(visible = isSearch && !inSelectionMode) {
-                    SearchBarSection(
-                        query = uiState.searchKey ?: "",
-                        onQueryChange = {
-                            viewModel.setSearchKey(it)
-                        },
-                        placeholder = stringResource(id = R.string.search)
-                    )
-                }
-            }
+    RuleListScaffold(
+        title = "字典规则",
+        state = uiState,
+        onBackClick = { onBackClick() },
+        onSearchToggle = { active ->
+            viewModel.setSearchMode(active)
         },
-        floatingActionButton = {
-            TooltipBox(
-                positionProvider =
-                    TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
-                tooltip = { PlainTooltip { Text("Localized description") } },
-                state = rememberTooltipState(),
-            ) {
-                FloatingActionButton(
-                    modifier = Modifier.animateFloatingActionButton(
-                        visible = !inSelectionMode,
-                        alignment = Alignment.BottomEnd,
-                    ),
-                    onClick = {
-                        editingRule = null
-                        showEditSheet = true
-                    }
-                ) {
-                    Icon(Icons.Default.Add, contentDescription = "Add Rule")
-                }
-            }
+        onSearchQueryChange = { viewModel.setSearchKey(it) },
+        searchPlaceholder = stringResource(R.string.replace_purify_search),
+        onClearSelection = { viewModel.setSelection(emptySet()) },
+        onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
+        onSelectInvert = {
+            val allIds = rules.map { it.id }.toSet()
+            viewModel.setSelection(allIds - selectedIds)
+        },
+        selectionSecondaryActions = listOf(
+            ActionItem(text = stringResource(R.string.enable), onClick = {
+                viewModel.enableSelectionByIds(selectedIds)
+                viewModel.setSelection(emptySet())
+            }),
+            ActionItem(text = stringResource(R.string.disable_selection), onClick = {
+                viewModel.disableSelectionByIds(selectedIds)
+                viewModel.setSelection(emptySet())
+            }),
+            ActionItem(
+                text = stringResource(R.string.export),
+                onClick = { showFilePickerSheet = true })
+        ),
+        onDeleteSelected = { ids ->
+            @Suppress("UNCHECKED_CAST")
+            viewModel.delSelectionByIds(ids as Set<String>)
+            viewModel.setSelection(emptySet())
+        },
+        onAddClick = {
+            editingRule = null
+            showEditSheet = true
+        },
+        snackbarHostState = snackbarHostState,
+        dropDownMenuContent = { dismiss ->
+            DropdownMenuItem(
+                text = { Text("在线导入") },
+                onClick = { dismiss(); showUrlInput = true })
+            DropdownMenuItem(
+                text = { Text("本地导入") },
+                onClick = {
+                    dismiss(); importDoc.launch(
+                    arrayOf(
+                        "text/plain",
+                        "application/json"
+                    )
+                )
+                })
         }
     ) { paddingValues ->
-        Box(
-            modifier = Modifier
-                .fillMaxSize()
-                .padding(paddingValues)
-        ) {
+        Box(modifier = Modifier
+            .fillMaxSize()
+            .padding(paddingValues)) {
             FastScrollLazyColumn(
                 modifier = Modifier.fillMaxSize(),
                 state = listState,
-                contentPadding = PaddingValues(
-                    top = 8.dp,
-                    bottom = 120.dp
-                ),
+                contentPadding = PaddingValues(top = 8.dp, bottom = 120.dp),
                 verticalArrangement = Arrangement.spacedBy(8.dp)
             ) {
-                items(rules, key = { it.name }) { item ->
+                items(rules, key = { it.id }) { item ->
                     ReorderableSelectionItem(
                         state = reorderableState,
-                        key = item.name,
-                        title = item.name,
+                        key = item.id,
+                        title = item.id,
                         isEnabled = item.isEnabled,
-                        isSelected = selectedIds.contains(item.name),
+                        isSelected = selectedIds.contains(item.id),
                         inSelectionMode = inSelectionMode,
-                        onToggleSelection = {
-                            viewModel.toggleSelection(item.name)
-                        },
-                        onEnabledChange = { enabled ->
-                            viewModel.update(item.rule.copy(enabled = enabled))
-                        },
-                        onClickEdit = {
-                            editingRule = item.rule
-                            showEditSheet = true
-                        },
-                        modifier = Modifier.padding(horizontal = 12.dp),
+                        onToggleSelection = { viewModel.toggleSelection(item.id) },
+                        onEnabledChange = { enabled -> viewModel.update(item.rule.copy(enabled = enabled)) },
+                        onClickEdit = { editingRule = item.rule; showEditSheet = true },
                         trailingAction = {
-                            IconButton(
-                                onClick = {
-                                    showDeleteRuleDialog = item.rule
-                                }
-                            ) {
-                                Icon(Icons.Default.Delete, contentDescription = "Delete")
-                            }
+                            SmallIconButton(
+                                onClick = { showDeleteRuleDialog = item.rule },
+                                icon = Icons.Default.Delete
+                            )
                         }
                     )
                 }
@@ -471,60 +337,14 @@ fun DictRuleScreen(
                     listState = listState,
                     items = rules,
                     selectedIds = selectedIds,
-                    onSelectionChange = viewModel::setSelection,
-                    idProvider = { it.name },
+                    onSelectionChange = { viewModel.setSelection(it) },
+                    idProvider = { it.id },
                     modifier = Modifier
                         .fillMaxHeight()
                         .width(60.dp)
                         .align(Alignment.TopStart)
                 )
             }
-            AnimatedVisibility(
-                visible = inSelectionMode,
-                modifier =
-                    Modifier
-                        .align(Alignment.BottomCenter)
-                        .offset(y = -FloatingToolbarDefaults.ScreenOffset)
-                        .zIndex(1f),
-                enter = slideInVertically { it } + fadeIn(),
-                exit = slideOutVertically { it } + fadeOut()
-            ) {
-                SelectionBottomBar(
-                    onSelectAll = {
-                        viewModel.setSelection(rules.map { it.name }.toSet())
-                    },
-                    onSelectInvert = {
-                        val allIds = rules.map { it.name }.toSet()
-                        viewModel.setSelection(allIds - selectedIds)
-                    },
-                    primaryAction = ActionItem(
-                        text = stringResource(R.string.delete),
-                        icon = { Icon(Icons.Default.Delete, null) },
-                        onClick = { showDeleteSelectedDialog = true }
-                    ),
-                    secondaryActions = listOf(
-                        ActionItem(
-                            text = stringResource(R.string.enable),
-                            onClick = {
-                                viewModel.enableSelectionByIds(selectedIds)
-                                viewModel.setSelection(emptySet())
-                            }
-                        ),
-                        ActionItem(
-                            text = stringResource(R.string.disable_selection),
-                            onClick = {
-                                viewModel.disableSelectionByIds(selectedIds)
-                                viewModel.setSelection(emptySet())
-                            }
-                        ),
-                        ActionItem(
-                            text = stringResource(R.string.export),
-                            onClick = { showFilePickerSheet = true }
-                        )
-                    )
-                )
-            }
-
         }
     }
 }

+ 77 - 225
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleViewModel.kt

@@ -2,245 +2,91 @@ package io.legado.app.ui.dict.rule
 
 import android.app.Application
 import androidx.compose.runtime.Immutable
-import androidx.core.net.toUri
 import androidx.lifecycle.viewModelScope
-import io.legado.app.base.BaseViewModel
-import io.legado.app.constant.AppConst
+import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.repository.DictRuleRepository
-import io.legado.app.help.http.decompressed
-import io.legado.app.help.http.newCallResponseBody
-import io.legado.app.help.http.okHttpClient
-import io.legado.app.help.http.text
+import io.legado.app.data.repository.UploadRepository
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
-import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
-import io.legado.app.ui.widget.components.importComponents.ImportStatus
+import io.legado.app.ui.widget.components.rules.InteractionState
+import io.legado.app.ui.widget.components.rules.RuleActionState
+import io.legado.app.ui.widget.components.rules.SelectableItem
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getClipText
-import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
-import io.legado.app.utils.isUri
-import io.legado.app.utils.readText
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 
 @Immutable
 data class DictRuleItemUi(
-    val name: String,
+    override val id: String,
     val urlRule: String,
     val showRule: String,
     val isEnabled: Boolean,
     val rule: DictRule
-)
+) : SelectableItem<String>
 
 data class DictRuleUiState(
-    val searchKey: String? = null,
-    val items: List<DictRuleItemUi> = emptyList(),
-    val dictRule: List<DictRule> = emptyList(),
-    val selectedIds: Set<String> = emptySet(),
-    val isLoading: Boolean = false
-)
-
-class DictRuleViewModel(application: Application) : BaseViewModel(application) {
+    override val items: List<DictRuleItemUi> = emptyList(),
+    override val selectedIds: Set<String> = emptySet(),
+    override val searchKey: String = "",
+    val interaction: InteractionState = InteractionState()
+) : RuleActionState<DictRuleItemUi> {
+    override val isSearch: Boolean get() = interaction.isSearchMode
+    override val isUploading: Boolean get() = interaction.isUploading
+}
 
+class DictRuleViewModel(
+    application: Application,
+    uploadRepository: UploadRepository
+) : BaseRuleViewModel<DictRuleItemUi, DictRule, String, DictRuleUiState>(
+    application,
+    DictRuleUiState(interaction = InteractionState(isLoading = true)),
+    uploadRepository
+) {
     private val repository = DictRuleRepository()
-    private val _searchKey = MutableStateFlow<String?>(null)
-    private val _uiRules = MutableStateFlow<List<DictRuleItemUi>>(emptyList())
-
-    private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
-    val selectedIds: StateFlow<Set<String>> = _selectedIds
-    private val _importState = MutableStateFlow<BaseImportUiState<DictRule>>(BaseImportUiState.Idle)
-    val importState = _importState.asStateFlow()
-    fun toggleSelection(id: String) {
-        _selectedIds.update {
-            if (it.contains(id)) it - id else it + id
-        }
-    }
 
-    @OptIn(ExperimentalCoroutinesApi::class)
-    private val rulesFlow = _searchKey.flatMapLatest { searchKey ->
-        val baseFlow = if (searchKey.isNullOrEmpty()) {
-            repository.flowAll()
-        } else {
-            repository.flowSearch("%$searchKey%")
-        }
+    override val rawDataFlow: Flow<List<DictRule>> = repository.flowAll()
 
-        baseFlow.map { rules ->
-            rules.sortedBy { it.sortNumber }
-        }
-    }.flowOn(Dispatchers.Default)
-
-    private val ruleUiFlow: Flow<List<DictRuleItemUi>> =
-        rulesFlow.map { rules ->
-            rules.map { rule ->
-                DictRuleItemUi(
-                    name = rule.name,
-                    urlRule = rule.urlRule,
-                    showRule = rule.showRule,
-                    isEnabled = rule.enabled,
-                    rule = rule
-                )
-            }
-        }
+    override fun filterData(data: List<DictRule>, key: String): List<DictRule> {
+        val filtered = if (key.isEmpty()) data
+        else data.filter { it.name.contains(key, ignoreCase = true) }
+        return filtered.sortedBy { it.sortNumber }
+    }
 
-    val uiState: StateFlow<DictRuleUiState> = combine(
-        _searchKey,
-        _uiRules,
-        _selectedIds
-    ) { searchKey, rules, selectedIds ->
-        DictRuleUiState(
-            searchKey = searchKey,
-            items = rules,
+    override fun composeUiState(
+        items: List<DictRuleItemUi>,
+        selectedIds: Set<String>,
+        isSearch: Boolean,
+        isUploading: Boolean,
+        importState: BaseImportUiState<DictRule>
+    ): DictRuleUiState {
+        return DictRuleUiState(
+            items = items,
             selectedIds = selectedIds,
-            isLoading = false
+            searchKey = _searchKey.value,
+            interaction = InteractionState(
+                isSearchMode = isSearch,
+                isUploading = isUploading || (importState is BaseImportUiState.Loading),
+                isLoading = false
+            )
         )
-    }.stateIn(
-        scope = viewModelScope,
-        started = SharingStarted.WhileSubscribed(5000),
-        initialValue = DictRuleUiState(isLoading = true)
-    )
-
-    init {
-        viewModelScope.launch {
-            ruleUiFlow.collect { rules ->
-                _uiRules.value = rules
-            }
-        }
-    }
-
-    fun setSearchKey(key: String?) {
-        _searchKey.value = key
-    }
-
-    fun setSelection(ids: Set<String>) {
-        _selectedIds.value = ids
-    }
-
-
-    fun enableSelectionByIds(ids: Set<String>) {
-        execute {
-            repository.enableByIds(ids)
-        }
     }
 
-    fun disableSelectionByIds(ids: Set<String>) {
-        execute {
-            repository.disableByIds(ids)
-        }
-    }
+    override fun DictRule.toUiItem() = DictRuleItemUi(name, urlRule, showRule, enabled, this)
+    override fun ruleItemToEntity(item: DictRuleItemUi): DictRule = item.rule
 
-    fun delSelectionByIds(ids: Set<String>) {
-        execute {
-            repository.deleteByIds(ids)
-            _selectedIds.update { it - ids }
-        }
-    }
+    override suspend fun generateJson(entities: List<DictRule>): String = GSON.toJson(entities)
 
-    fun update(vararg rule: DictRule) {
-        execute {
-            repository.update(*rule)
-        }
-    }
-
-    fun insert(vararg rule: DictRule) {
-        execute {
-            repository.insert(*rule)
-        }
-    }
-
-    fun moveItemInList(fromIndex: Int, toIndex: Int) {
-        _uiRules.update { currentList ->
-            val list = currentList.toMutableList()
-            val item = list.removeAt(fromIndex)
-            list.add(toIndex, item)
-            list
-        }
-    }
-
-    fun saveSortOrder() {
-        val currentRules = _uiRules.value
-        execute {
-            repository.moveOrder(currentRules.map { it.rule })
-        }
-    }
-
-    fun delete(vararg dictRule: DictRule) {
-        execute {
-            repository.delete(*dictRule)
-        }
-    }
-
-    fun importSource(text: String) {
-        _importState.value = BaseImportUiState.Loading
-        viewModelScope.launch(Dispatchers.IO) {
-            runCatching {
-                val jsonText = resolveSource(text.trim())
-                val rules = parseDictRules(jsonText)
-                val wrappers = rules.map { newRule ->
-                    val oldRule = repository.findById(newRule.name)
-
-                    val status = when {
-                        oldRule == null -> ImportStatus.New
-                        hasChanged(newRule, oldRule) -> ImportStatus.Update
-                        else -> ImportStatus.Existing
-                    }
-
-                    ImportItemWrapper(
-                        data = newRule,
-                        oldData = oldRule,
-                        status = status,
-                        isSelected = status != ImportStatus.Existing
-                    )
-                }
-
-                _importState.value = BaseImportUiState.Success(
-                    source = text,
-                    items = wrappers
-                )
-            }.onFailure {
-                it.printStackTrace()
-                _importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
-            }
-        }
-    }
-
-    private suspend fun resolveSource(text: String): String {
-        return when {
-            text.isAbsUrl() -> {
-                okHttpClient.newCallResponseBody {
-                    if (text.endsWith("#requestWithoutUA")) {
-                        url(text.substringBeforeLast("#requestWithoutUA"))
-                        header(AppConst.UA_NAME, "null")
-                    } else {
-                        url(text)
-                    }
-                }.decompressed().text("utf-8")
-            }
-
-            text.isUri() -> text.toUri().readText(context)
-            else -> text
-        }
-    }
-
-    private fun parseDictRules(text: String): List<DictRule> {
+    override fun parseImportRules(text: String): List<DictRule> {
         return when {
             text.isJsonArray() -> GSON.fromJsonArray<DictRule>(text).getOrThrow()
             text.isJsonObject() -> listOf(GSON.fromJsonObject<DictRule>(text).getOrThrow())
@@ -248,50 +94,57 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
         }
     }
 
-    private fun hasChanged(newRule: DictRule, oldRule: DictRule): Boolean {
+    override fun hasChanged(newRule: DictRule, oldRule: DictRule): Boolean {
         return newRule.name != oldRule.name
                 || newRule.urlRule != oldRule.urlRule
                 || newRule.showRule != oldRule.showRule
+                || newRule.enabled != oldRule.enabled
     }
 
-    fun toggleImportSelection(index: Int) {
-        val currentState = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
-        val newItems = currentState.items.toMutableList()
-        val item = newItems[index]
-        newItems[index] = item.copy(isSelected = !item.isSelected)
-        _importState.value = currentState.copy(items = newItems)
+    override suspend fun findOldRule(newRule: DictRule): DictRule? {
+        return repository.findById(newRule.name)
     }
 
-    fun toggleImportAll(isSelected: Boolean) {
-        val currentState = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
-        val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
-        _importState.value = currentState.copy(items = newItems)
-    }
-
-    fun saveImportedRules() {
+    override fun saveImportedRules() {
         val state = _importState.value as? BaseImportUiState.Success<DictRule> ?: return
         viewModelScope.launch(Dispatchers.IO) {
             val rulesToSave = state.items
                 .filter { it.isSelected }
                 .map { it.data }
-
             repository.insert(*rulesToSave.toTypedArray())
-
             withContext(Dispatchers.Main) {
                 _importState.value = BaseImportUiState.Idle
             }
         }
     }
 
-    fun cancelImport() {
-        _importState.value = BaseImportUiState.Idle
+    fun saveSortOrder() {
+        val currentLocal = _localItems.value ?: return
+        viewModelScope.launch {
+            repository.moveOrder(currentLocal.map { it.rule })
+            _localItems.value = null
+        }
+    }
+
+    fun enableSelectionByIds(ids: Set<String>) {
+        viewModelScope.launch { repository.enableByIds(ids) }
+    }
+
+    fun disableSelectionByIds(ids: Set<String>) {
+        viewModelScope.launch { repository.disableByIds(ids) }
     }
 
-    suspend fun getSelectedRules(): List<DictRule> {
-        val selectedIds = _selectedIds.value
-        return repository.getByNames(selectedIds)
+    fun delSelectionByIds(ids: Set<String>) {
+        viewModelScope.launch {
+            repository.deleteByIds(ids)
+            _selectedIds.update { it - ids }
+        }
     }
 
+    fun update(vararg rule: DictRule) = viewModelScope.launch { repository.update(*rule) }
+    fun insert(vararg rule: DictRule) = viewModelScope.launch { repository.insert(*rule) }
+    fun delete(vararg dictRule: DictRule) = viewModelScope.launch { repository.delete(*dictRule) }
+
     fun copyRule(dictRule: DictRule) {
         context.sendToClip(GSON.toJson(dictRule))
     }
@@ -309,5 +162,4 @@ class DictRuleViewModel(application: Application) : BaseViewModel(application) {
             null
         }
     }
-
-}
+}

+ 1 - 1
app/src/main/java/io/legado/app/ui/replace/GroupManageBottomSheet.kt

@@ -29,7 +29,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.ui.widget.components.GlassCard
+import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
 
 @OptIn(ExperimentalMaterial3Api::class)

+ 246 - 423
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -3,13 +3,8 @@ package io.legado.app.ui.replace
 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
-import androidx.compose.animation.slideOutVertically
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -17,37 +12,24 @@ import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.wrapContentWidth
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.MoreVert
-import androidx.compose.material.icons.filled.Search
 import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.DropdownMenu
 import androidx.compose.material3.DropdownMenuItem
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
 import androidx.compose.material3.LoadingIndicator
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.PlainTooltip
 import androidx.compose.material3.PrimaryScrollableTabRow
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.SnackbarResult
 import androidx.compose.material3.Tab
@@ -56,13 +38,11 @@ import androidx.compose.material3.TextButton
 import androidx.compose.material3.TooltipAnchorPosition
 import androidx.compose.material3.TooltipBox
 import androidx.compose.material3.TooltipDefaults
-import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.material3.animateFloatingActionButton
 import androidx.compose.material3.rememberModalBottomSheetState
 import androidx.compose.material3.rememberTooltipState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -71,9 +51,7 @@ import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.ClipEntry
 import androidx.compose.ui.platform.LocalClipboard
 import androidx.compose.ui.platform.LocalContext
@@ -82,29 +60,22 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Dialog
-import androidx.compose.ui.zIndex
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.google.gson.Gson
 import io.legado.app.R
+import io.legado.app.base.BaseRuleEvent
 import io.legado.app.data.entities.ReplaceRule
-import io.legado.app.data.repository.UploadRepository
 import io.legado.app.ui.widget.components.ActionItem
-import io.legado.app.ui.widget.components.AnimatedText
 import io.legado.app.ui.widget.components.DraggableSelectionHandler
-import io.legado.app.ui.widget.components.EmptyMessageView
-import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
-import io.legado.app.ui.widget.components.ReorderableSelectionItem
-import io.legado.app.ui.widget.components.SearchBarSection
-import io.legado.app.ui.widget.components.SelectionBottomBar
+import io.legado.app.ui.widget.components.card.ReorderableSelectionItem
 import io.legado.app.ui.widget.components.exportComponents.FilePickerSheet
 import io.legado.app.ui.widget.components.exportComponents.FilePickerSheetMode
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
 import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
+import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
-import org.koin.compose.koinInject
 import sh.calvin.reorderable.rememberReorderableLazyListState
 
 @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class,
@@ -119,42 +90,40 @@ fun ReplaceRuleScreen(
     onNavigateToEdit: (ReplaceEditRoute) -> Unit,
 ) {
 
-    val uploadRepository: UploadRepository = koinInject()
-
     val context = LocalContext.current
     val scope = rememberCoroutineScope()
-    val listState = rememberLazyListState()
-    val snackbarHostState = remember { SnackbarHostState() }
 
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
-    val rules = uiState.rules
+    val rules = uiState.items
     val groups = uiState.groups
+    val selectedIds = uiState.selectedIds
+    val inSelectionMode = selectedIds.isNotEmpty()
 
-    var isSearch by remember { mutableStateOf(false) }
-    var showMenu by remember { mutableStateOf(false) }
-    var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
-    var showDeleteSelectedDialog by remember { mutableStateOf(false) }
+    val listState = rememberLazyListState()
+    val clipboardManager = LocalClipboard.current
+    val snackbarHostState = remember { SnackbarHostState() }
+    val hapticFeedback = LocalHapticFeedback.current
 
-    val sheetState = rememberModalBottomSheetState()
+    var showUrlInput by remember { mutableStateOf(false) }
+    var showFilePickerSheet by remember { mutableStateOf(false) }
+    var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
+    var showDeleteRuleDialog by remember { mutableStateOf<ReplaceRule?>(null) }
     var showGroupManageSheet by remember { mutableStateOf(false) }
 
-    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
-
-    val selectedRuleIds by viewModel.selectedRuleIds.collectAsState()
-    val inSelectionMode = selectedRuleIds.isNotEmpty()
-
-    var selectedTabIndex by remember { mutableIntStateOf(0) }
-    val tabItems = listOf(stringResource(R.string.all)) + groups
-
     val importState by viewModel.importState.collectAsStateWithLifecycle()
-    var showUrlInput by remember { mutableStateOf(false) }
+    val sheetState = rememberModalBottomSheetState()
 
-    var showFilePickerSheet by remember { mutableStateOf(false) }
-    var filePickerMode by remember { mutableStateOf(FilePickerSheetMode.EXPORT) }
-    var isUploading by remember { mutableStateOf(false) }
-    val clipboardManager = LocalClipboard.current
+    var selectedTabIndex by remember { mutableIntStateOf(0) }
+    val tabItems = remember(groups) { listOf("全部") + groups }
+    val filteredRules = remember(uiState.items, selectedTabIndex, tabItems) {
+        if (selectedTabIndex == 0) {
+            uiState.items
+        } else {
+            val targetGroup = tabItems[selectedTabIndex]
+            uiState.items.filter { it.group == targetGroup }
+        }
+    }
 
-    val hapticFeedback = LocalHapticFeedback.current
     val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
         viewModel.moveItemInList(from.index, to.index)
         hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
@@ -179,18 +148,7 @@ fun ReplaceRuleScreen(
     val exportDoc = rememberLauncherForActivityResult(
         contract = ActivityResultContracts.CreateDocument("application/json"),
         onResult = { uri ->
-            uri?.let { it ->
-                scope.launch {
-                    val rulesToExport = rules
-                        .filter { selectedRuleIds.contains(it.id) }
-                        .map { it.rule }
-
-                    val json = Gson().toJson(rulesToExport)
-                    context.contentResolver.openOutputStream(it)?.use { stream ->
-                        stream.writer().write(json)
-                    }
-                }
-            }
+            uri?.let { viewModel.exportToUri(it, rules, selectedIds) }
         }
     )
 
@@ -217,41 +175,7 @@ fun ReplaceRuleScreen(
             onSelectSysFile = {},
             onUpload = {
                 showFilePickerSheet = false
-                scope.launch {
-                    val rulesToExport = rules
-                        .filter { selectedRuleIds.contains(it.id) }
-                        .map { it.rule }
-                    val json = Gson().toJson(rulesToExport)
-
-                    isUploading = true
-
-                    try {
-                        runCatching {
-                            uploadRepository.upload(
-                                fileName = "exportReplaceRule.json",
-                                file = json,
-                                contentType = "application/json"
-                            )
-                        }.onSuccess { url ->
-                            isUploading = false
-                            val result = snackbarHostState.showSnackbar(
-                                message = "上传成功: $url",
-                                actionLabel = "复制链接",
-                                withDismissAction = true
-                            )
-                            if (result == SnackbarResult.ActionPerformed) {
-                                clipboardManager.setClipEntry(
-                                    ClipEntry(ClipData.newPlainText("export url", url))
-                                )
-                            }
-                        }.onFailure { e ->
-                            isUploading = false
-                            snackbarHostState.showSnackbar("上传失败: ${e.localizedMessage}")
-                        }
-                    } finally {
-                        isUploading = false
-                    }
-                }
+                viewModel.uploadSelectedRules(selectedIds, rules)
             },
             allowExtensions = arrayOf("json")
         )
@@ -297,10 +221,32 @@ fun ReplaceRuleScreen(
     }
 
     LaunchedEffect(groups) {
-        val maxIndex = groups.size
-        if (selectedTabIndex > maxIndex) {
+        if (selectedTabIndex > groups.size) {
             selectedTabIndex = 0
-            viewModel.setSearchKey("")
+        }
+    }
+
+    LaunchedEffect(Unit) {
+        viewModel.events.collect { event ->
+            when (event) {
+                is BaseRuleEvent.ShowSnackbar -> {
+                    val result = snackbarHostState.showSnackbar(
+                        message = event.message,
+                        actionLabel = event.actionLabel,
+                        withDismissAction = true
+                    )
+                    if (result == SnackbarResult.ActionPerformed && event.url != null) {
+                        clipboardManager.setClipEntry(
+                            ClipEntry(
+                                ClipData.newPlainText(
+                                    "url",
+                                    event.url
+                                )
+                            )
+                        )
+                    }
+                }
+            }
         }
     }
 
@@ -313,204 +259,97 @@ fun ReplaceRuleScreen(
         )
     }
 
-    if (showDeleteRuleDialog != null) {
+    showDeleteRuleDialog?.let { rule ->
         AlertDialog(
             onDismissRequest = { showDeleteRuleDialog = null },
             title = { Text(stringResource(R.string.delete)) },
-            text = { Text(stringResource(R.string.sure_del) + showDeleteRuleDialog!!.name) },
+            text = { Text(stringResource(R.string.del_msg)) },
             confirmButton = {
-                OutlinedButton(
-                    onClick = {
-                        viewModel.delete(showDeleteRuleDialog!!)
-                        showDeleteRuleDialog = null
-                    },
-                    colors = ButtonDefaults.outlinedButtonColors(
-                        contentColor = MaterialTheme.colorScheme.error,
-                        containerColor = Color.Transparent,
-                    ),
-                ) {
-                    Text(stringResource(R.string.ok))
-                }
+                OutlinedButton(onClick = {
+                    viewModel.delete(rule); showDeleteRuleDialog = null
+                }) { Text(stringResource(R.string.ok)) }
             },
             dismissButton = {
                 TextButton(onClick = { showDeleteRuleDialog = null }) {
-                    Text(stringResource(R.string.cancel))
+                    Text(
+                        stringResource(R.string.cancel)
+                    )
                 }
             }
         )
     }
 
-    if (showDeleteSelectedDialog) {
-        AlertDialog(
-            onDismissRequest = { showDeleteSelectedDialog = false },
-            title = { Text(stringResource(R.string.delete)) },
-            text = { Text(stringResource(R.string.del_msg)) },
-            confirmButton = {
-                OutlinedButton(
-                    onClick = {
-                        viewModel.delSelectionByIds(selectedRuleIds)
-                        viewModel.setSelection(emptySet())
-                        showDeleteSelectedDialog = false
-                    },
-                    colors = ButtonDefaults.outlinedButtonColors(
-                        contentColor = MaterialTheme.colorScheme.error,
-                        containerColor = Color.Transparent,
-                    ),
-                ) {
-                    Text(stringResource(R.string.ok))
+    RuleListScaffold(
+        title = "替换规则",
+        state = uiState,
+        onBackClick = { onBackClick() },
+        onSearchToggle = { viewModel.setSearchMode(!uiState.isSearch) },
+        onSearchQueryChange = { viewModel.setSearchKey(it) },
+        searchPlaceholder = stringResource(R.string.replace_purify_search),
+        onClearSelection = { viewModel.setSelection(emptySet()) },
+        onSelectAll = { viewModel.setSelection(rules.map { it.id }.toSet()) },
+        onSelectInvert = { viewModel.setSelection(rules.map { it.id }.toSet() - selectedIds) },
+        selectionSecondaryActions = listOf(
+            ActionItem(
+                text = stringResource(R.string.enable),
+                onClick = {
+                    viewModel.enableSelectionByIds(selectedIds)
+                    viewModel.setSelection(emptySet())
                 }
-            },
-            dismissButton = {
-                TextButton(onClick = { showDeleteSelectedDialog = false }) {
-                    Text(stringResource(R.string.cancel))
+            ),
+            ActionItem(
+                text = stringResource(R.string.disable_selection),
+                onClick = {
+                    viewModel.disableSelectionByIds(selectedIds)
+                    viewModel.setSelection(emptySet())
                 }
-            }
-        )
-    }
-
-    Scaffold(
-        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
-        snackbarHost = { SnackbarHost(snackbarHostState) },
-        topBar = {
-            Column {
-                GlassMediumFlexibleTopAppBar(
-                    title = {
-                        val titleText = remember(isUploading, inSelectionMode, selectedRuleIds, rules) {
-                            when {
-                                isUploading -> "正在上传..."
-                                inSelectionMode -> "已选择 ${rules.count { it.id in selectedRuleIds }}/${rules.size}"
-                                else -> "替换规则"
-                            }
-                        }
-                        AnimatedText(
-                            text = titleText
-                        )
-                    },
-                    navigationIcon = {
-                        IconButton(
-                            onClick = {
-                                if (inSelectionMode) {
-                                    viewModel.setSelection(emptySet())
-                                } else {
-                                    onBackClick()
-                                }
-                            }
-                        ) {
-                            Icon(
-                                imageVector = if (inSelectionMode) Icons.Default.Close else Icons.AutoMirrored.Filled.ArrowBack,
-                                contentDescription = if (inSelectionMode) "Cancel" else "Back"
-                            )
-                        }
-                    },
-                    actions = {
-                        if (!inSelectionMode) {
-                            IconButton(onClick = { isSearch = !isSearch }) {
-                                Icon(Icons.Default.Search, contentDescription = "Search")
-                            }
-                            IconButton(onClick = { showMenu = !showMenu }) {
-                                Icon(Icons.Default.MoreVert, contentDescription = "More")
-                            }
-                            DropdownMenu(
-                                expanded = showMenu,
-                                onDismissRequest = { showMenu = false }
-                            ) {
-                                DropdownMenuItem(
-                                    text = { Text("在线导入") },
-                                    onClick = {
-                                        showMenu = false
-                                        showUrlInput = true // 触发输入框
-                                    }
-                                )
-                                DropdownMenuItem(
-                                    text = { Text("本地导入") },
-                                    onClick = { importDoc.launch(arrayOf("text/plain", "application/json")); showMenu = false }
-                                )
-                                DropdownMenuItem(
-                                    text = { Text("分组管理") },
-                                    onClick = { showGroupManageSheet = true; showMenu = false }
-                                )
-                                DropdownMenuItem(
-                                    text = { Text("帮助") },
-                                    onClick = { /*TODO*/ showMenu = false }
-                                )
-                                HorizontalDivider()
-                                DropdownMenuItem(
-                                    text = { Text("旧的在前") },
-                                    onClick = { viewModel.setSortMode("asc"); showMenu = false }
-                                )
-                                DropdownMenuItem(
-                                    text = { Text("新的在前") },
-                                    onClick = { viewModel.setSortMode("desc"); showMenu = false }
-                                )
-                                DropdownMenuItem(
-                                    text = { Text("名称升序") },
-                                    onClick = {
-                                        viewModel.setSortMode("name_asc")
-                                        showMenu = false
-                                        scope.launch {
-                                            snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
-                                        }
-                                    }
-                                )
-                                DropdownMenuItem(
-                                    text = { Text("名称降序") },
-                                    onClick = {
-                                        viewModel.setSortMode("name_desc")
-                                        showMenu = false
-                                        scope.launch {
-                                            snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
-                                        }
-                                    }
+            ),
+            ActionItem(
+                text = stringResource(R.string.to_top),
+                onClick = {
+                    viewModel.topSelectByIds(selectedIds)
+                    viewModel.setSelection(emptySet())
+                }
+            ),
+            ActionItem(
+                text = stringResource(R.string.to_bottom),
+                onClick = {
+                    viewModel.bottomSelectByIds(selectedIds)
+                    viewModel.setSelection(emptySet())
+                }
+            ),
+            ActionItem(
+                text = stringResource(R.string.export),
+                onClick = { showFilePickerSheet = true }
+            )
+        ),
+        onDeleteSelected = { ids ->
+            @Suppress("UNCHECKED_CAST")
+            viewModel.delSelectionByIds(ids as Set<Long>)
+            viewModel.setSelection(emptySet())
+        },
+        stickySubContent = {
+            if (tabItems.size > 1) {
+                PrimaryScrollableTabRow(
+                    selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1)
+                        .coerceAtLeast(0),
+                    edgePadding = 0.dp,
+                    divider = {},
+                ) {
+                    tabItems.forEachIndexed { index, title ->
+                        Tab(
+                            selected = selectedTabIndex == index,
+                            onClick = { selectedTabIndex = index },
+                            text = {
+                                Text(
+                                    text = title,
+                                    maxLines = 1,
+                                    overflow = TextOverflow.Ellipsis,
+                                    modifier = Modifier.padding(horizontal = 16.dp),
+                                    color = MaterialTheme.colorScheme.onSurfaceVariant
                                 )
                             }
-                        }
-                    },
-                    scrollBehavior = scrollBehavior
-                )
-                AnimatedVisibility(visible = isSearch && !inSelectionMode) {
-                    SearchBarSection(
-                        query = uiState.searchKey ?: "",
-                        onQueryChange = {
-                            viewModel.setSearchKey(it)
-                            selectedTabIndex = 0
-                        },
-                        placeholder = stringResource(id = R.string.replace_purify_search)
-                    )
-                }
-
-                val allString = stringResource(R.string.all)
-                val tabItems = remember(groups, allString) { listOf(allString) + groups }
-                AnimatedVisibility(visible = groups.isNotEmpty()) {
-                    PrimaryScrollableTabRow(
-                        selectedTabIndex = selectedTabIndex.coerceAtMost(tabItems.size - 1)
-                            .coerceAtLeast(0),
-                        edgePadding = 0.dp,
-                        divider = {},
-                    ) {
-                        tabItems.forEachIndexed { index, title ->
-                            Tab(
-                                selected = selectedTabIndex == index,
-                                onClick = {
-                                    selectedTabIndex = index
-                                    val group = tabItems.getOrNull(index)
-                                    if (group == allString) {
-                                        viewModel.setSearchKey("")
-                                    } else if (group != null) {
-                                        viewModel.setSearchKey("group:$group")
-                                    }
-                                },
-                                modifier = Modifier.wrapContentWidth(),
-                                text = {
-                                    Text(
-                                        text = title,
-                                        maxLines = 1,
-                                        overflow = TextOverflow.Ellipsis,
-                                        modifier = Modifier.padding(horizontal = 16.dp),
-                                        color = MaterialTheme.colorScheme.onSurfaceVariant
-                                    )
-                                }
-                            )
-                        }
+                        )
                     }
                 }
             }
@@ -542,6 +381,57 @@ fun ReplaceRuleScreen(
                     }
                 }
             }
+        },
+        snackbarHostState = snackbarHostState,
+        dropDownMenuContent = { dismiss ->
+            DropdownMenuItem(
+                text = { Text("在线导入") },
+                onClick = {
+                    dismiss()
+                    showUrlInput = true // 触发输入框
+                }
+            )
+            DropdownMenuItem(
+                text = { Text("本地导入") },
+                onClick = { importDoc.launch(arrayOf("text/plain", "application/json")); dismiss() }
+            )
+            DropdownMenuItem(
+                text = { Text("分组管理") },
+                onClick = { showGroupManageSheet = true; dismiss() }
+            )
+            DropdownMenuItem(
+                text = { Text("帮助") },
+                onClick = { /*TODO*/ dismiss() }
+            )
+            HorizontalDivider()
+            DropdownMenuItem(
+                text = { Text("旧的在前") },
+                onClick = { viewModel.setSortMode("asc"); dismiss() }
+            )
+            DropdownMenuItem(
+                text = { Text("新的在前") },
+                onClick = { viewModel.setSortMode("desc"); dismiss() }
+            )
+            DropdownMenuItem(
+                text = { Text("名称升序") },
+                onClick = {
+                    viewModel.setSortMode("name_asc")
+                    dismiss()
+                    scope.launch {
+                        snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
+                    }
+                }
+            )
+            DropdownMenuItem(
+                text = { Text("名称降序") },
+                onClick = {
+                    viewModel.setSortMode("name_desc")
+                    dismiss()
+                    scope.launch {
+                        snackbarHostState.showSnackbar("非时间排序模式下将禁用拖动")
+                    }
+                }
+            )
         }
     ) { padding ->
         Box(
@@ -549,145 +439,78 @@ fun ReplaceRuleScreen(
                 .padding(padding)
                 .fillMaxSize()
         ) {
-            if (rules.isEmpty()) {
-                EmptyMessageView(
-                    modifier = Modifier.fillMaxSize(),
-                    message = "没有替换规则!"
-                )
-            } else {
-                FastScrollLazyColumn(
-                    state = listState,
-                    modifier = Modifier
-                        .fillMaxSize(),
-                    contentPadding = PaddingValues(
-                        top = 8.dp,
-                        bottom = 120.dp
-                    ),
-                    verticalArrangement = Arrangement.spacedBy(8.dp)
-                ) {
-                    items(rules, key = { it.id }) { ui ->
-                        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)
-                                    .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() }
+            FastScrollLazyColumn(
+                state = listState,
+                modifier = Modifier
+                    .fillMaxSize(),
+                contentPadding = PaddingValues(
+                    top = 8.dp,
+                    bottom = 120.dp
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp)
+            ) {
+                items(filteredRules, key = { it.id }) { ui ->
+                    with(sharedTransitionScope) {
+                        ReorderableSelectionItem(
+                            state = reorderableState,
+                            key = ui.id,
+                            title = ui.name,
+                            isEnabled = ui.isEnabled,
+                            isSelected = selectedIds.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
                                     )
-                                }
-                            )
-                        }
-                    }
-                }
-                if (inSelectionMode) {
-                    DraggableSelectionHandler(
-                        listState = listState,
-                        items = rules,
-                        selectedIds = selectedRuleIds,
-                        onSelectionChange = viewModel::setSelection,
-                        idProvider = { it.id },
-                        modifier = Modifier
-                            .fillMaxHeight()
-                            .width(60.dp)
-                            .align(Alignment.TopStart)
-                    )
-                }
-                AnimatedVisibility(
-                    visible = inSelectionMode,
-                    modifier =
-                        Modifier
-                            .align(Alignment.BottomCenter)
-                            .offset(y = -ScreenOffset)
-                            .zIndex(1f),
-                    enter = slideInVertically { it } + fadeIn(),
-                    exit = slideOutVertically { it } + fadeOut()
-                ) {
-                    SelectionBottomBar(
-                        onSelectAll = {
-                            viewModel.setSelection(rules.map { it.id }.toSet())
-                        },
-                        onSelectInvert = {
-                            val allIds = rules.map { it.id }.toSet()
-                            viewModel.setSelection(allIds - selectedRuleIds)
-                        },
-                        primaryAction = ActionItem(
-                            text = stringResource(R.string.delete),
-                            icon = { Icon(Icons.Default.Delete, null) },
-                            onClick = { showDeleteSelectedDialog = true }
-                        ),
-                        secondaryActions = listOf(
-                            ActionItem(
-                                text = stringResource(R.string.enable),
-                                onClick = {
-                                    viewModel.enableSelectionByIds(selectedRuleIds)
-                                    viewModel.setSelection(emptySet())
-                                }
-                            ),
-                            ActionItem(
-                                text = stringResource(R.string.disable_selection),
-                                onClick = {
-                                    viewModel.disableSelectionByIds(selectedRuleIds)
-                                    viewModel.setSelection(emptySet())
-                                }
-                            ),
-                            ActionItem(
-                                text = stringResource(R.string.to_top),
-                                onClick = {
-                                    viewModel.topSelectByIds(selectedRuleIds)
-                                    viewModel.setSelection(emptySet())
-                                }
-                            ),
-                            ActionItem(
-                                text = stringResource(R.string.to_bottom),
-                                onClick = {
-                                    viewModel.bottomSelectByIds(selectedRuleIds)
-                                    viewModel.setSelection(emptySet())
-                                }
-                            ),
-                            ActionItem(
-                                text = stringResource(R.string.export),
-                                onClick = { showFilePickerSheet = true }
-                            )
+                                )
+                            },
+                            modifier = Modifier
+                                .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) {
+                DraggableSelectionHandler(
+                    listState = listState,
+                    items = rules,
+                    selectedIds = selectedIds,
+                    onSelectionChange = { viewModel.setSelection(it) },
+                    idProvider = { it.id },
+                    modifier = Modifier
+                        .fillMaxHeight()
+                        .width(60.dp)
+                        .align(Alignment.TopStart)
+                )
+            }
         }
     }
 }

+ 122 - 309
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt

@@ -2,330 +2,117 @@ package io.legado.app.ui.replace
 
 import android.app.Application
 import androidx.compose.runtime.Immutable
-import androidx.core.net.toUri
 import androidx.lifecycle.viewModelScope
-import io.legado.app.R
-import io.legado.app.base.BaseViewModel
-import io.legado.app.constant.AppConst
+import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.repository.ReplaceRuleRepository
+import io.legado.app.data.repository.UploadRepository
 import io.legado.app.help.ReplaceAnalyzer
-import io.legado.app.help.http.decompressed
-import io.legado.app.help.http.newCallResponseBody
-import io.legado.app.help.http.okHttpClient
-import io.legado.app.help.http.text
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
-import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
-import io.legado.app.ui.widget.components.importComponents.ImportStatus
+import io.legado.app.ui.widget.components.rules.InteractionState
+import io.legado.app.ui.widget.components.rules.RuleActionState
+import io.legado.app.ui.widget.components.rules.SelectableItem
+import io.legado.app.utils.GSON
 import io.legado.app.utils.getPrefString
-import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isJsonArray
 import io.legado.app.utils.isJsonObject
-import io.legado.app.utils.isUri
 import io.legado.app.utils.putPrefString
-import io.legado.app.utils.readText
 import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
-import splitties.init.appCtx
 
 @Immutable
 data class ReplaceRuleItemUi(
-    val id: Long,
+    override val id: Long,
     val name: String,
     val isEnabled: Boolean,
     val group: String?,
     val rule: ReplaceRule
-)
+) : SelectableItem<Long>
 
 data class ReplaceRuleUiState(
+    override val items: List<ReplaceRuleItemUi> = emptyList(),
+    override val selectedIds: Set<Long> = emptySet(),
+    override val searchKey: String = "",
     val sortMode: String = "desc",
-    val searchKey: String? = null,
     val groups: List<String> = emptyList(),
-    val rules: List<ReplaceRuleItemUi> = emptyList(),
-    val isLoading: Boolean = false
-)
-
-/**
- * 替换规则数据修改
- * 修改数据要copy,直接修改会导致界面不刷新
- */
-class ReplaceRuleViewModel(application: Application) : BaseViewModel(application) {
+    val interaction: InteractionState = InteractionState()
+) : RuleActionState<ReplaceRuleItemUi> {
+    override val isSearch: Boolean get() = interaction.isSearchMode
+    override val isUploading: Boolean get() = interaction.isUploading
+}
 
+class ReplaceRuleViewModel(
+    application: Application,
+    uploadRepository: UploadRepository
+) : BaseRuleViewModel<ReplaceRuleItemUi, ReplaceRule, Long, ReplaceRuleUiState>(
+    application,
+    ReplaceRuleUiState(interaction = InteractionState(isLoading = true)),
+    uploadRepository
+) {
     private val repository = ReplaceRuleRepository()
     private val _sortMode = MutableStateFlow(context.getPrefString(PreferKey.replaceSortMode, "desc") ?: "desc")
-    private val _searchKey = MutableStateFlow<String?>(null)
-    private val _uiRules = MutableStateFlow<List<ReplaceRuleItemUi>>(emptyList())
-    private val _selectedRuleIds = MutableStateFlow<Set<Long>>(emptySet())
-    val selectedRuleIds: StateFlow<Set<Long>> = _selectedRuleIds
-    private val _importState = MutableStateFlow<BaseImportUiState<ReplaceRule>>(BaseImportUiState.Idle)
-    val importState: StateFlow<BaseImportUiState<ReplaceRule>> = _importState.asStateFlow()
-
-    fun toggleSelection(id: Long) {
-        _selectedRuleIds.update {
-            if (it.contains(id)) it - id else it + id
-        }
-    }
+    private val _group = MutableStateFlow<String?>(null)
+    val group = _group.asStateFlow()
 
     @OptIn(ExperimentalCoroutinesApi::class)
-    private val rulesFlow = combine(_searchKey, _sortMode) { search, sort ->
-        Pair(search, sort)
-    }.flatMapLatest { (searchKey, sortMode) ->
-        // 先获取基础数据
-        val baseFlow = when {
-            searchKey.isNullOrEmpty() -> repository.flowAll()
-            searchKey == appCtx.getString(R.string.no_group) -> repository.flowNoGroup()
-            searchKey.startsWith("group:") -> {
-                val key = searchKey.substringAfter("group:")
-                repository.flowGroupSearch("%$key%")
-            }
-            else -> repository.flowSearch("%$searchKey%")
-        }
-
-        baseFlow
-            .map { rules ->
-                val comparator = when (sortMode) {
-                    "asc" -> compareBy<ReplaceRule> { it.order.toLong() }
-                    "desc" -> compareByDescending<ReplaceRule> { it.order.toLong() }
-                    "name_asc" -> compareBy<ReplaceRule> { it.name.lowercase() }
-                    "name_desc" -> compareByDescending<ReplaceRule> { it.name.lowercase() }
-                    else -> null
-                }
-
-                if (comparator != null) rules.sortedWith(comparator) else rules
+    override val rawDataFlow: Flow<List<ReplaceRule>> =
+        combine(_group, _sortMode) { group, sortMode ->
+            group to sortMode
+        }.flatMapLatest { (group, sortMode) ->
+            val baseFlow = when (group) {
+                null -> repository.flowAll()
+                "未分组" -> repository.flowNoGroup()
+                else -> repository.flowGroupSearch(group)
             }
-    }.flowOn(Dispatchers.Default)
 
-    private val ruleUiFlow: Flow<List<ReplaceRuleItemUi>> =
-        rulesFlow.map { rules ->
-            rules.map { rule ->
-                ReplaceRuleItemUi(
-                    id = rule.id,
-                    name = rule.name,
-                    isEnabled = rule.isEnabled,
-                    group = rule.group,
-                    rule = rule
-                )
+            baseFlow.map { rules ->
+                sortRules(rules, sortMode)
             }
         }
 
-    val uiState: StateFlow<ReplaceRuleUiState> = combine(
-        _sortMode,
-        _searchKey,
-        repository.flowGroups(),
-        _uiRules
-    ) { sortMode, searchKey, groups, rules ->
-        ReplaceRuleUiState(
-            sortMode = sortMode,
-            searchKey = searchKey,
-            groups = groups,
-            rules = rules,
-            isLoading = false
+    override fun filterData(data: List<ReplaceRule>, key: String): List<ReplaceRule> =
+        if (key.isEmpty()) data
+        else data.filter { it.name.contains(key, ignoreCase = true) }
+
+
+    override fun composeUiState(
+        items: List<ReplaceRuleItemUi>,
+        selectedIds: Set<Long>,
+        isSearch: Boolean,
+        isUploading: Boolean,
+        importState: BaseImportUiState<ReplaceRule>
+    ): ReplaceRuleUiState {
+        return ReplaceRuleUiState(
+            items = items,
+            selectedIds = selectedIds,
+            searchKey = _searchKey.value,
+            interaction = InteractionState(
+                isSearchMode = isSearch,
+                isUploading = isUploading || (importState is BaseImportUiState.Loading),
+                isLoading = false
+            )
         )
-    }.stateIn(
-        scope = viewModelScope,
-        started = SharingStarted.WhileSubscribed(5000),
-        initialValue = ReplaceRuleUiState(isLoading = true)
-    )
-
-    init {
-        viewModelScope.launch {
-            ruleUiFlow.collect { rules ->
-                _uiRules.value = rules
-            }
-        }
-    }
-
-    fun setSortMode(mode: String) {
-        _sortMode.value = mode
-        context.putPrefString(PreferKey.replaceSortMode, mode)
-    }
-
-    fun setSearchKey(key: String?) {
-        _searchKey.value = key
-    }
-
-    fun setSelection(ids: Set<Long>) {
-        _selectedRuleIds.value = ids
-    }
-
-    fun update(vararg rule: ReplaceRule) {
-        execute {
-            repository.update(*rule)
-        }
     }
 
-    fun delete(rule: ReplaceRule) {
-        execute {
-            repository.delete(rule)
-        }
-    }
+    override fun ReplaceRule.toUiItem() = ReplaceRuleItemUi(id, name, isEnabled, group, this)
+    override fun ruleItemToEntity(item: ReplaceRuleItemUi): ReplaceRule = item.rule
 
-    fun toTop(rule: ReplaceRule) {
-        execute {
-            repository.toTop(rule, _sortMode.value == "desc")
-        }
-    }
+    override suspend fun generateJson(entities: List<ReplaceRule>): String = GSON.toJson(entities)
 
-    fun toBottom(rule: ReplaceRule) {
-        execute {
-            repository.toBottom(rule, _sortMode.value == "desc")
-        }
-    }
-
-    fun upOrder() {
-        execute {
-            repository.upOrder()
-        }
-    }
-
-    fun enableSelection(rules: List<ReplaceRule>) {
-        execute {
-            repository.enableSelection(rules)
-        }
-    }
-
-    fun disableSelection(rules: List<ReplaceRule>) {
-        execute {
-            repository.disableSelection(rules)
-        }
-    }
-
-    fun enableSelectionByIds(ids: Set<Long>) {
-        execute {
-            repository.enableByIds(ids)
-        }
-    }
-
-    fun disableSelectionByIds(ids: Set<Long>) {
-        execute {
-            repository.disableByIds(ids)
-        }
-    }
-
-    fun delSelectionByIds(ids: Set<Long>) {
-        execute {
-            repository.deleteByIds(ids)
-        }
-    }
-
-    fun topSelectByIds(ids: Set<Long>) {
-        execute {
-            repository.topByIds(ids, _sortMode.value == "desc")
-        }
-    }
-
-    fun bottomSelectByIds(ids: Set<Long>) {
-        execute {
-            repository.bottomByIds(ids, _sortMode.value == "desc")
-        }
-    }
-
-
-    fun addGroup(group: String) {
-        execute {
-            repository.addGroup(group)
-        }
-    }
-
-    fun upGroup(oldGroup: String, newGroup: String?) {
-        execute {
-            repository.upGroup(oldGroup, newGroup)
-        }
-    }
-
-    fun delGroup(group: String) {
-        execute {
-            repository.delGroup(group)
-        }
-    }
-
-    fun moveItemInList(fromIndex: Int, toIndex: Int) {
-        _uiRules.update { currentList ->
-            val list = currentList.toMutableList()
-            val item = list.removeAt(fromIndex)
-            list.add(toIndex, item)
-            list
-        }
-    }
-
-    fun saveSortOrder() {
-        val currentRules = _uiRules.value
-        val isDesc = _sortMode.value == "desc"
-        execute {
-            repository.moveOrder(currentRules, isDesc)
-        }
-    }
-
-    fun importSource(text: String) {
-        _importState.value = BaseImportUiState.Loading
-
-        viewModelScope.launch(Dispatchers.IO) {
-            runCatching {
-                val jsonText = resolveSource(text.trim())
-                val rules = parseRules(jsonText)
-                val wrappers = rules.map { newRule ->
-                    val oldRule = appDb.replaceRuleDao.findById(newRule.id)
-
-                    val status = when {
-                        oldRule == null -> ImportStatus.New
-                        hasChanged(newRule, oldRule) -> ImportStatus.Update
-                        else -> ImportStatus.Existing
-                    }
-
-                    ImportItemWrapper(
-                        data = newRule,
-                        oldData = oldRule,
-                        status = status,
-                        isSelected = status != ImportStatus.Existing
-                    )
-                }
-
-                _importState.value = BaseImportUiState.Success(
-                    source = text,
-                    items = wrappers
-                )
-            }.onFailure {
-                it.printStackTrace()
-                _importState.value = BaseImportUiState.Error(it.localizedMessage ?: "Unknown Error")
-            }
-        }
-    }
-
-    private suspend fun resolveSource(text: String): String {
-        return when {
-            text.isAbsUrl() -> {
-                okHttpClient.newCallResponseBody {
-                    if (text.endsWith("#requestWithoutUA")) {
-                        url(text.substringBeforeLast("#requestWithoutUA"))
-                        header(AppConst.UA_NAME, "null")
-                    } else {
-                        url(text)
-                    }
-                }.decompressed().text("utf-8")
-            }
-            text.isUri() -> text.toUri().readText(appCtx)
-            else -> text
-        }
-    }
-
-    private fun parseRules(text: String): List<ReplaceRule> {
+    override fun parseImportRules(text: String): List<ReplaceRule> {
         return when {
             text.isJsonArray() -> ReplaceAnalyzer.jsonToReplaceRules(text).getOrThrow()
             text.isJsonObject() -> listOf(ReplaceAnalyzer.jsonToReplaceRule(text).getOrThrow())
@@ -333,44 +120,19 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
         }
     }
 
-    private fun hasChanged(newRule: ReplaceRule, oldRule: ReplaceRule): Boolean {
+    override fun hasChanged(newRule: ReplaceRule, oldRule: ReplaceRule): Boolean {
         return newRule.pattern != oldRule.pattern
                 || newRule.replacement != oldRule.replacement
                 || newRule.isRegex != oldRule.isRegex
                 || newRule.scope != oldRule.scope
     }
 
-    fun cancelImport() {
-        _importState.value = BaseImportUiState.Idle
-    }
-
-    fun toggleImportSelection(index: Int) {
-        val currentState = _importState.value as? BaseImportUiState.Success ?: return
-        val newItems = currentState.items.toMutableList()
-        val item = newItems[index]
-        newItems[index] = item.copy(isSelected = !item.isSelected)
-        _importState.value = currentState.copy(items = newItems)
-    }
-
-    fun toggleImportAll(isSelected: Boolean) {
-        val currentState = _importState.value as? BaseImportUiState.Success ?: return
-        val newItems = currentState.items.map { it.copy(isSelected = isSelected) }
-        _importState.value = currentState.copy(items = newItems)
-    }
-
-    // 更新分组配置
-    fun updateImportConfig(keepName: Boolean? = null, group: String? = null, isAdd: Boolean? = null) {
-        val currentState = _importState.value as? BaseImportUiState.Success ?: return
-        _importState.value = currentState.copy(
-            keepOriginalName = keepName ?: currentState.keepOriginalName,
-            customGroup = group ?: currentState.customGroup,
-            isAddGroup = isAdd ?: currentState.isAddGroup
-        )
+    override suspend fun findOldRule(newRule: ReplaceRule): ReplaceRule? {
+        return appDb.replaceRuleDao.findById(newRule.id)
     }
 
-    fun saveImportedRules() {
-        val state = _importState.value as? BaseImportUiState.Success ?: return
-
+    override fun saveImportedRules() {
+        val state = _importState.value as? BaseImportUiState.Success<ReplaceRule> ?: return
         viewModelScope.launch(Dispatchers.IO) {
             val rulesToSave = state.items
                 .filter { it.isSelected }
@@ -381,7 +143,6 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
                     if (state.keepOriginalName && oldRule != null) {
                         rule.name = oldRule.name
                     }
-
                     val targetGroup = state.customGroup?.trim()
                     if (!targetGroup.isNullOrEmpty()) {
                         if (state.isAddGroup) {
@@ -395,13 +156,65 @@ class ReplaceRuleViewModel(application: Application) : BaseViewModel(application
                     }
                     rule
                 }
-
-            appDb.replaceRuleDao.insert(*rulesToSave.toTypedArray())
-
+            repository.update(*rulesToSave.toTypedArray())
             withContext(Dispatchers.Main) {
                 _importState.value = BaseImportUiState.Idle
             }
         }
     }
 
-}
+    private fun sortRules(rules: List<ReplaceRule>, mode: String): List<ReplaceRule> {
+        val comparator = when (mode) {
+            "asc" -> compareBy<ReplaceRule> { it.order.toLong() }
+            "desc" -> compareByDescending<ReplaceRule> { it.order.toLong() }
+            "name_asc" -> compareBy<ReplaceRule> { it.name.lowercase() }
+            "name_desc" -> compareByDescending<ReplaceRule> { it.name.lowercase() }
+            else -> null
+        }
+        return if (comparator != null) rules.sortedWith(comparator) else rules
+    }
+
+    fun setSortMode(mode: String) {
+        _sortMode.value = mode
+        context.putPrefString(PreferKey.replaceSortMode, mode)
+    }
+
+    fun saveSortOrder() {
+        val currentLocal = _localItems.value ?: return
+        viewModelScope.launch {
+            repository.moveOrder(currentLocal.map { it.rule }, _sortMode.value == "desc")
+            _localItems.value = null
+        }
+    }
+
+
+    fun update(vararg rule: ReplaceRule) = viewModelScope.launch { repository.update(*rule) }
+    fun delete(rule: ReplaceRule) = viewModelScope.launch { repository.delete(rule) }
+    fun enableSelectionByIds(ids: Set<Long>) = viewModelScope.launch { repository.enableByIds(ids) }
+    fun disableSelectionByIds(ids: Set<Long>) =
+        viewModelScope.launch { repository.disableByIds(ids) }
+
+    fun delSelectionByIds(ids: Set<Long>) = viewModelScope.launch {
+        repository.deleteByIds(ids)
+        _selectedIds.update { it - ids }
+    }
+
+    fun addGroup(group: String) = viewModelScope.launch { repository.addGroup(group) }
+    fun delGroup(group: String) = viewModelScope.launch { repository.delGroup(group) }
+
+    fun toTop(rule: ReplaceRule) =
+        viewModelScope.launch { repository.toTop(rule, _sortMode.value == "desc") }
+
+    fun toBottom(rule: ReplaceRule) =
+        viewModelScope.launch { repository.toBottom(rule, _sortMode.value == "desc") }
+
+    fun upOrder() = viewModelScope.launch { repository.upOrder() }
+    fun topSelectByIds(ids: Set<Long>) =
+        viewModelScope.launch { repository.topByIds(ids, _sortMode.value == "desc") }
+
+    fun bottomSelectByIds(ids: Set<Long>) =
+        viewModelScope.launch { repository.bottomByIds(ids, _sortMode.value == "desc") }
+
+    fun upGroup(oldGroup: String, newGroup: String?) =
+        viewModelScope.launch { repository.upGroup(oldGroup, newGroup) }
+}

+ 0 - 47
app/src/main/java/io/legado/app/ui/widget/GlassTonalIconButton.kt

@@ -1,47 +0,0 @@
-package io.legado.app.ui.widget
-
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.FilledTonalIconButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.unit.dp
-import io.legado.app.ui.theme.ThemeState
-
-@Composable
-fun GlassBackIconButton(
-    onClick: () -> Unit,
-    modifier: Modifier = Modifier,
-    imageVector: ImageVector = Icons.AutoMirrored.Filled.ArrowBack,
-    contentDescription: String? = null
-) {
-    val themeOpacity by ThemeState.containerOpacity.collectAsState()
-
-    val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh
-    val glassColor = baseColor.copy(alpha = (themeOpacity / 100f).coerceAtLeast(0.6f))
-
-    FilledTonalIconButton(
-        onClick = onClick,
-        modifier = modifier
-            .padding(horizontal = 12.dp)
-            .size(36.dp),
-        colors = IconButtonDefaults.filledTonalIconButtonColors(
-            containerColor = glassColor,
-            contentColor = MaterialTheme.colorScheme.onSurface
-        )
-    ) {
-        Icon(
-            imageVector = imageVector,
-            contentDescription = contentDescription,
-            modifier = Modifier.size(20.dp)
-        )
-    }
-}

+ 6 - 3
app/src/main/java/io/legado/app/ui/widget/components/Calender.kt

@@ -39,6 +39,7 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.card.GlassCard
 import kotlinx.coroutines.launch
 import java.time.DayOfWeek
 import java.time.LocalDate
@@ -83,9 +84,11 @@ fun Calendar(
         shape = MaterialTheme.shapes.medium,
         color = MaterialTheme.colorScheme.surfaceContainer
     ) {
-        Column(modifier = Modifier
-            .padding(vertical = 12.dp)
-            .animateContentSize()) {
+        Column(
+            modifier = Modifier
+                .padding(vertical = 12.dp)
+                .animateContentSize()
+        ) {
             MonthNavigation(
                 currentMonth = currentMonth,
                 selectedDate = selectedDate,

+ 2 - 1
app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt

@@ -45,6 +45,7 @@ fun SearchBarSection(
     query: String,
     onQueryChange: (String) -> Unit,
     placeholder: String = "搜索书名",
+    leadingIcon: @Composable (() -> Unit)? = { Icon(Icons.Default.Search, null) },
     backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
     scrollState: LazyListState? = null,
     scope: CoroutineScope = rememberCoroutineScope(),
@@ -73,7 +74,7 @@ fun SearchBarSection(
                 .fillMaxWidth()
                 .padding(horizontal = 4.dp),
             placeholder = { Text(placeholder) },
-            leadingIcon = { Icon(Icons.Default.Search, null) },
+            leadingIcon = leadingIcon,
             trailingIcon = {
                 Row(
                     verticalAlignment = Alignment.CenterVertically,

+ 2 - 0
app/src/main/java/io/legado/app/ui/widget/components/SettingItem.kt

@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.card.GlassCard
 
 @Preview(showBackground = true)
 @Composable
@@ -127,6 +128,7 @@ fun SettingItem(
                         contentDescription = null,
                         tint = MaterialTheme.colorScheme.onSurfaceVariant
                     )
+
                     imageVector != null -> Icon(
                         imageVector = imageVector,
                         contentDescription = null,

+ 1 - 0
app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt

@@ -11,6 +11,7 @@ import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.card.GlassCard
 
 /**
  * Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/AnimatedTextButton.kt → app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedTextButton.kt

@@ -1,4 +1,4 @@
-package io.legado.app.ui.widget.components
+package io.legado.app.ui.widget.components.button
 
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.animateContentSize

+ 40 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt

@@ -0,0 +1,40 @@
+package io.legado.app.ui.widget.components.button
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun SmallIconButton(
+    onClick: () -> Unit,
+    icon: ImageVector,
+    contentDescription: String? = null
+) {
+    CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
+        IconButton(
+            onClick = onClick,
+            modifier = Modifier.size(
+                IconButtonDefaults.extraSmallContainerSize(
+                    IconButtonDefaults.IconButtonWidthOption.Narrow
+                )
+            ),
+            shape = IconButtonDefaults.extraSmallRoundShape,
+        ) {
+            Icon(
+                imageVector = icon,
+                contentDescription = contentDescription,
+                modifier = Modifier.size(IconButtonDefaults.extraSmallIconSize),
+            )
+        }
+    }
+}

+ 98 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/SmallTopBarButton.kt

@@ -0,0 +1,98 @@
+package io.legado.app.ui.widget.components.button
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.ThemeState
+
+enum class TopBarButtonVariant {
+    Filled, Outlined, Icon
+}
+
+@Composable
+fun SmallTopBarButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    imageVector: ImageVector = Icons.AutoMirrored.Filled.ArrowBack,
+    contentDescription: String? = null,
+    style: TopBarButtonVariant = TopBarButtonVariant.Filled
+) {
+    val themeOpacity by ThemeState.containerOpacity.collectAsState()
+    val baseColor = MaterialTheme.colorScheme.surfaceContainerHigh
+    val glassColor = baseColor.copy(alpha = (themeOpacity / 100f).coerceAtLeast(0.6f))
+
+    val commonModifier = modifier
+        .padding(horizontal = 12.dp)
+        .size(36.dp)
+
+    when (style) {
+        TopBarButtonVariant.Filled -> {
+            FilledTonalIconButton(
+                onClick = onClick,
+                modifier = commonModifier,
+                colors = IconButtonDefaults.filledTonalIconButtonColors(
+                    containerColor = glassColor,
+                    contentColor = MaterialTheme.colorScheme.onSurface
+                )
+            ) {
+                SmallIcon(imageVector, contentDescription)
+            }
+        }
+
+        TopBarButtonVariant.Outlined -> {
+            OutlinedIconButton(
+                onClick = onClick,
+                modifier = commonModifier,
+                border = ButtonDefaults.outlinedButtonBorder()
+            ) {
+                SmallIcon(imageVector, contentDescription)
+            }
+        }
+
+        TopBarButtonVariant.Icon -> {
+            IconButton(
+                onClick = onClick,
+                modifier = commonModifier
+            ) {
+                SmallIcon(imageVector, contentDescription)
+            }
+        }
+    }
+}
+
+@Composable
+private fun SmallIcon(imageVector: ImageVector, contentDescription: String?) {
+    AnimatedContent(
+        targetState = imageVector,
+        transitionSpec = {
+            (fadeIn() + scaleIn(initialScale = 0.8f))
+                .togetherWith(fadeOut())
+        },
+        label = "IconTransition"
+    ) { targetIcon ->
+        Icon(
+            imageVector = targetIcon,
+            contentDescription = contentDescription,
+            modifier = Modifier.size(20.dp)
+        )
+    }
+}

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/GlassCard.kt → app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt

@@ -1,4 +1,4 @@
-package io.legado.app.ui.widget.components
+package io.legado.app.ui.widget.components.card
 
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.layout.ColumnScope

+ 80 - 59
app/src/main/java/io/legado/app/ui/widget/components/SelectionItemCard.kt → app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt

@@ -1,4 +1,4 @@
-package io.legado.app.ui.widget.components
+package io.legado.app.ui.widget.components.card
 
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.animateColorAsState
@@ -11,19 +11,19 @@ import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.shrinkHorizontally
 import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyItemScope
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Edit
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material3.Checkbox
 import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
 import androidx.compose.material3.ListItem
 import androidx.compose.material3.ListItemDefaults
 import androidx.compose.material3.MaterialTheme
@@ -36,6 +36,7 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
 import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -43,6 +44,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.zIndex
+import io.legado.app.ui.widget.components.button.SmallIconButton
 import sh.calvin.reorderable.ReorderableItem
 import sh.calvin.reorderable.ReorderableLazyListState
 
@@ -73,78 +75,97 @@ fun SelectionItemCard(
 
     GlassCard(
         onClick = onToggleSelection,
-        modifier = modifier.fillMaxWidth(),
+        modifier = modifier
+            .padding(horizontal = 16.dp)
+            .fillMaxWidth(),
         shape = MaterialTheme.shapes.medium,
         color = containerColor
     ) {
-        ListItem(
-            modifier = Modifier.animateContentSize(),
-            headlineContent = {
-                Text(
-                    text = title,
-                    style = MaterialTheme.typography.titleMedium,
-                    maxLines = 1,
-                    overflow = TextOverflow.Ellipsis
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 4.dp)
+                .animateContentSize(),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AnimatedVisibility(
+                visible = inSelectionMode,
+                enter = fadeIn() + expandHorizontally(),
+                exit = fadeOut() + shrinkHorizontally()
+            ) {
+                Checkbox(
+                    checked = isSelected,
+                    onCheckedChange = null,
+                    modifier = Modifier.padding(start = 12.dp)
                 )
-            },
-            supportingContent = subtitle?.let {
-                {
+            }
+
+            ListItem(
+                modifier = Modifier.weight(1f),
+                headlineContent = {
                     Text(
-                        text = it,
-                        style = MaterialTheme.typography.bodySmall,
+                        text = title,
+                        style = MaterialTheme.typography.titleSmall,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis
                     )
-                }
-            },
-            leadingContent = {
-                AnimatedVisibility(
-                    visible = inSelectionMode,
-                    enter = fadeIn() + expandHorizontally(),
-                    exit = fadeOut() + shrinkHorizontally()
-                ) {
-                    Checkbox(
-                        checked = isSelected,
-                        onCheckedChange = null
-                    )
-                }
-            },
-            trailingContent = {
-                Row(verticalAlignment = Alignment.CenterVertically) {
-                    onEnabledChange?.let {
-                        Switch(
-                            checked = isEnabled,
-                            onCheckedChange = it
+                },
+                supportingContent = if (!subtitle.isNullOrBlank()) {
+                    {
+                        Text(
+                            text = subtitle,
+                            style = MaterialTheme.typography.bodySmall,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis
                         )
                     }
+                } else null,
+                colors = ListItemDefaults.colors(containerColor = Color.Transparent)
+            )
 
-                    if (onClickEdit != null) {
-                        IconButton(onClick = onClickEdit) {
-                            Icon(Icons.Default.Edit, contentDescription = "Edit")
-                        }
-                    }
+            Row(
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                modifier = Modifier
+                    .padding(end = 8.dp)
+            ) {
+                onEnabledChange?.let {
+                    Switch(
+                        modifier = Modifier.scale(0.8f),
+                        checked = isEnabled,
+                        onCheckedChange = it
+                    )
+                }
 
-                    if (trailingAction != null) {
-                        trailingAction()
-                    }
+                if (onClickEdit != null) {
+                    SmallIconButton(
+                        onClick = onClickEdit,
+                        icon = Icons.Default.Edit,
+                        contentDescription = "Edit"
+                    )
+                }
 
-                    if (dropdownContent != null) {
-                        Box {
-                            IconButton(onClick = { showMenu = true }) {
-                                Icon(Icons.Default.MoreVert, contentDescription = "More")
-                            }
-                            DropdownMenu(
-                                expanded = showMenu,
-                                onDismissRequest = { showMenu = false }
-                            ) {
-                                dropdownContent { showMenu = false }
-                            }
+                if (trailingAction != null) {
+                    trailingAction()
+                }
+
+                if (dropdownContent != null) {
+                    Box {
+                        SmallIconButton(
+                            onClick = { showMenu = true },
+                            icon = Icons.Default.MoreVert,
+                            contentDescription = "More"
+                        )
+                        DropdownMenu(
+                            expanded = showMenu,
+                            onDismissRequest = { showMenu = false }
+                        ) {
+                            dropdownContent { showMenu = false }
                         }
                     }
                 }
-            },
-            colors = ListItemDefaults.colors(containerColor = Color.Transparent)
-        )
+            }
+        }
     }
 }
 

+ 2 - 1
app/src/main/java/io/legado/app/ui/widget/components/TextCard.kt → app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt

@@ -1,4 +1,4 @@
-package io.legado.app.ui.widget.components
+package io.legado.app.ui.widget.components.card
 
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
@@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.AnimatedText
 
 @Preview(showBackground = true)
 @Composable

+ 19 - 0
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleActionState.kt

@@ -0,0 +1,19 @@
+package io.legado.app.ui.widget.components.rules
+
+interface RuleActionState<T> {
+    val items: List<T>
+    val selectedIds: Set<Any>
+    val searchKey: String
+    val isSearch: Boolean
+    val isUploading: Boolean
+}
+
+data class InteractionState(
+    val isSearchMode: Boolean = false,
+    val isUploading: Boolean = false,
+    val isLoading: Boolean = false
+)
+
+interface SelectableItem<T> {
+    val id: T
+}

+ 172 - 0
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleEditSheet.kt

@@ -0,0 +1,172 @@
+package io.legado.app.ui.widget.components.rules
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.NoteAdd
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ContentPaste
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.widget.components.modalBottomSheet.GlassModalBottomSheet
+import kotlinx.coroutines.launch
+
+/**
+ * 通用编辑数据包装,用于适配不同的规则实体
+ */
+data class RuleEditFields(
+    val name: String = "",
+    val rule1: String = "",
+    val rule2: String = "",
+    val extra: String = ""
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun <T> RuleEditSheet(
+    rule: T?,
+    title: String,
+    label1: String,
+    label2: String,
+    onDismissRequest: () -> Unit,
+    onSave: (T) -> Unit,
+    onCopy: (T) -> Unit,
+    onPaste: () -> T?,
+    toFields: (T?) -> RuleEditFields,
+    fromFields: (RuleEditFields, T?) -> T
+) {
+    val scope = rememberCoroutineScope()
+    val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+    val initialFields = remember(rule) { toFields(rule) }
+    var name by remember(initialFields) { mutableStateOf(initialFields.name) }
+    var rule1 by remember(initialFields) { mutableStateOf(initialFields.rule1) }
+    var rule2 by remember(initialFields) { mutableStateOf(initialFields.rule2) }
+
+    var showMenu by remember { mutableStateOf(false) }
+
+    fun getCurrentEntity() = fromFields(RuleEditFields(name, rule1, rule2), rule)
+
+    GlassModalBottomSheet(
+        onDismissRequest = onDismissRequest,
+        sheetState = sheetState,
+    ) {
+        Box(modifier = Modifier.fillMaxWidth()) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .verticalScroll(rememberScrollState())
+            ) {
+                CenterAlignedTopAppBar(
+                    title = { Text(title) },
+                    navigationIcon = {
+                        IconButton(onClick = onDismissRequest) {
+                            Icon(
+                                Icons.Default.Close,
+                                contentDescription = stringResource(R.string.cancel)
+                            )
+                        }
+                    },
+                    actions = {
+                        IconButton(onClick = { showMenu = true }) {
+                            Icon(Icons.Default.MoreVert, contentDescription = "More")
+                        }
+                        DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
+                            DropdownMenuItem(
+                                text = { Text(stringResource(R.string.copy_rule)) },
+                                leadingIcon = { Icon(Icons.AutoMirrored.Filled.NoteAdd, null) },
+                                onClick = {
+                                    onCopy(getCurrentEntity())
+                                    showMenu = false
+                                }
+                            )
+                            DropdownMenuItem(
+                                text = { Text(stringResource(R.string.paste_rule)) },
+                                leadingIcon = { Icon(Icons.Default.ContentPaste, null) },
+                                onClick = {
+                                    scope.launch {
+                                        onPaste()?.let { pasted ->
+                                            val fields = toFields(pasted)
+                                            name = fields.name
+                                            rule1 = fields.rule1
+                                            rule2 = fields.rule2
+                                        }
+                                    }
+                                    showMenu = false
+                                }
+                            )
+                        }
+                    },
+                    colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
+                )
+
+                Column(
+                    modifier = Modifier
+                        .padding(horizontal = 16.dp)
+                        .padding(bottom = 96.dp),
+                    verticalArrangement = Arrangement.spacedBy(8.dp)
+                ) {
+                    OutlinedTextField(
+                        modifier = Modifier.fillMaxWidth(),
+                        value = name,
+                        onValueChange = { name = it },
+                        label = { Text(stringResource(R.string.name)) },
+                        singleLine = true
+                    )
+                    OutlinedTextField(
+                        modifier = Modifier.fillMaxWidth(),
+                        value = rule1,
+                        onValueChange = { rule1 = it },
+                        label = { Text(label1) }
+                    )
+                    OutlinedTextField(
+                        modifier = Modifier.fillMaxWidth(),
+                        value = rule2,
+                        onValueChange = { rule2 = it },
+                        label = { Text(label2) },
+                        minLines = 3
+                    )
+                }
+            }
+
+            FloatingActionButton(
+                onClick = { onSave(getCurrentEntity()) },
+                modifier = Modifier
+                    .align(Alignment.BottomEnd)
+                    .padding(16.dp),
+                containerColor = MaterialTheme.colorScheme.primaryContainer
+            ) {
+                Icon(Icons.Default.Save, contentDescription = "Save")
+            }
+        }
+    }
+}

+ 235 - 0
app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt

@@ -0,0 +1,235 @@
+package io.legado.app.ui.widget.components.rules
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingToolbarDefaults.ScreenOffset
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.PlainTooltip
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TooltipAnchorPosition
+import androidx.compose.material3.TooltipBox
+import androidx.compose.material3.TooltipDefaults
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.animateFloatingActionButton
+import androidx.compose.material3.rememberTooltipState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import io.legado.app.R
+import io.legado.app.ui.widget.components.ActionItem
+import io.legado.app.ui.widget.components.AnimatedText
+import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.SearchBarSection
+import io.legado.app.ui.widget.components.SelectionBottomBar
+import io.legado.app.ui.widget.components.button.SmallTopBarButton
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun <T> RuleListScaffold(
+    title: String,
+    state: RuleActionState<T>,
+    onBackClick: () -> Unit,
+    onSearchToggle: (Boolean) -> Unit,
+    onSearchQueryChange: (String) -> Unit,
+    searchPlaceholder: String,
+    searchLeadingIcon: ImageVector = Icons.Default.Search,
+    searchTrailingIcon: @Composable (() -> Unit)? = null,
+    searchDropdownMenu: (@Composable (onDismiss: () -> Unit) -> Unit)? = null,
+    topBarActions: @Composable RowScope.() -> Unit = {},
+    stickySubContent: @Composable (ColumnScope.() -> Unit)? = null,
+    dropDownMenuContent: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit = {},
+    onClearSelection: () -> Unit,
+    onSelectAll: () -> Unit,
+    onSelectInvert: () -> Unit,
+    selectionSecondaryActions: List<ActionItem>,
+    onDeleteSelected: (Set<Any>) -> Unit,
+    onAddClick: (() -> Unit)? = null,
+    floatingActionButton: @Composable () -> Unit = {
+        onAddClick?.let { onClick ->
+            TooltipBox(
+                positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
+                    TooltipAnchorPosition.Above
+                ),
+                tooltip = { PlainTooltip { Text("添加") } },
+                state = rememberTooltipState(),
+            ) {
+                FloatingActionButton(
+                    modifier = Modifier.animateFloatingActionButton(
+                        visible = state.selectedIds.isEmpty(),
+                        alignment = Alignment.BottomEnd,
+                    ),
+                    onClick = onClick
+                ) {
+                    Icon(Icons.Default.Add, contentDescription = "Add")
+                }
+            }
+        }
+    },
+    snackbarHostState: SnackbarHostState,
+    content: @Composable (PaddingValues) -> Unit
+) {
+    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+    var showMenu by remember { mutableStateOf(false) }
+    var showDeleteConfirmDialog by remember { mutableStateOf(false) }
+
+    if (showDeleteConfirmDialog) {
+        AlertDialog(
+            onDismissRequest = { showDeleteConfirmDialog = false },
+            title = { Text(stringResource(R.string.delete)) },
+            text = { Text(stringResource(R.string.del_msg)) },
+            confirmButton = {
+                OutlinedButton(
+                    onClick = {
+                        onDeleteSelected(state.selectedIds)
+                        showDeleteConfirmDialog = false
+                    },
+                    colors = ButtonDefaults.outlinedButtonColors(
+                        contentColor = MaterialTheme.colorScheme.error
+                    )
+                ) {
+                    Text(stringResource(R.string.ok))
+                }
+            },
+            dismissButton = {
+                TextButton(onClick = { showDeleteConfirmDialog = false }) {
+                    Text(stringResource(R.string.cancel))
+                }
+            }
+        )
+    }
+
+    Scaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        topBar = {
+            Column {
+                GlassMediumFlexibleTopAppBar(
+                    title = {
+                        val titleText = when {
+                            state.isUploading -> "请稍后..."
+                            state.selectedIds.isNotEmpty() -> "已选择 ${state.selectedIds.size}/${state.items.size}"
+                            else -> title
+                        }
+                        AnimatedText(text = titleText)
+                    },
+                    navigationIcon = {
+                        SmallTopBarButton(
+                            onClick = {
+                                if (state.selectedIds.isNotEmpty()) onClearSelection() else onBackClick()
+                            },
+                            imageVector = if (state.selectedIds.isNotEmpty()) {
+                                Icons.Default.Close
+                            } else {
+                                Icons.AutoMirrored.Filled.ArrowBack
+                            },
+                            contentDescription = if (state.selectedIds.isNotEmpty()) "取消选择" else "返回"
+                        )
+                    },
+                    actions = {
+                        if (!state.selectedIds.isNotEmpty()) {
+                            IconButton(onClick = { onSearchToggle(!state.isSearch) }) {
+                                Icon(Icons.Default.Search, null)
+                            }
+                            topBarActions()
+                            Box {
+                                IconButton(onClick = { showMenu = true }) {
+                                    Icon(Icons.Default.MoreVert, null)
+                                }
+                                DropdownMenu(
+                                    expanded = showMenu,
+                                    onDismissRequest = { showMenu = false }) {
+                                    dropDownMenuContent { showMenu = false }
+                                }
+                            }
+                        }
+                    },
+                    scrollBehavior = scrollBehavior
+                )
+
+                AnimatedVisibility(visible = state.isSearch && !state.selectedIds.isNotEmpty()) {
+                    SearchBarSection(
+                        query = state.searchKey,
+                        onQueryChange = onSearchQueryChange,
+                        placeholder = searchPlaceholder,
+                        leadingIcon = { Icon(searchLeadingIcon, null) },
+                        trailingIcon = searchTrailingIcon,
+                        dropdownMenu = searchDropdownMenu
+                    )
+                }
+
+                AnimatedVisibility(
+                    visible = stickySubContent != null
+                ) {
+                    stickySubContent?.let { it() }
+                }
+            }
+        },
+        floatingActionButton = floatingActionButton,
+        content = { paddingValues ->
+            Box(modifier = Modifier.fillMaxSize()) {
+                content(paddingValues)
+                AnimatedVisibility(
+                    visible = state.selectedIds.isNotEmpty(),
+                    modifier = Modifier
+                        .align(Alignment.BottomCenter)
+                        .offset(y = -ScreenOffset)
+                        .padding(bottom = 16.dp)
+                        .zIndex(1f),
+                    enter = slideInVertically { it } + fadeIn(),
+                    exit = slideOutVertically { it } + fadeOut()
+                ) {
+                    SelectionBottomBar(
+                        onSelectAll = onSelectAll,
+                        onSelectInvert = onSelectInvert,
+                        primaryAction = ActionItem(
+                            text = stringResource(R.string.delete),
+                            icon = { Icon(Icons.Default.Delete, null) },
+                            onClick = { showDeleteConfirmDialog = true }
+                        ),
+                        secondaryActions = selectionSecondaryActions
+                    )
+                }
+            }
+        }
+    )
+}