Bläddra i källkod

[新增] 补全阅读记录的备份删除等逻辑,修复了其他界面的一些问题

HapeLee 9 månader sedan
förälder
incheckning
df5387fb3c

+ 1 - 2
app/src/main/java/io/legado/app/base/BaseRuleViewModel.kt

@@ -27,7 +27,6 @@ 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
@@ -67,7 +66,7 @@ abstract class BaseRuleViewModel<T : SelectableItem<ID>, Entity, ID, S : RuleAct
     private val itemsFlow: Flow<List<T>> by lazy {
         combine(
             rawDataFlow,
-            _searchKey.debounce(300L),
+            _searchKey,
             _localItems
         ) { data, key, local ->
             if (local != null && key.isEmpty()) {

+ 32 - 1
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -18,6 +18,12 @@ interface ReadRecordDao {
     @get:Query("select * from readRecord")
     val all: List<ReadRecord>
 
+    @get:Query("select * from readRecordDetail")
+    val allDetail: List<ReadRecordDetail>
+
+    @get:Query("select * from readRecordSession")
+    val allSession: List<ReadRecordSession>
+
     @get:Query(
         """
         select bookName, sum(readTime) as readTime, max(lastRead) as lastRead 
@@ -127,7 +133,7 @@ interface ReadRecordDao {
         SELECT * FROM readRecordSession 
         WHERE deviceId = :deviceId 
         AND bookName = :bookName 
-        AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch')) = :date 
+        AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date 
         ORDER BY startTime ASC
     """)
     suspend fun getSessionsByBookAndDate(deviceId: String, bookName: String, date: String): List<ReadRecordSession>
@@ -159,6 +165,31 @@ interface ReadRecordDao {
     @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId ORDER BY startTime ASC")
     fun getAllSessions(deviceId: String): Flow<List<ReadRecordSession>>
 
+    @Query("SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
+    suspend fun getSessionsByBook(deviceId: String, bookName: String): List<ReadRecordSession>
+
     @Delete
     suspend fun deleteDetail(detail: ReadRecordDetail)
+
+    @Query(
+        """
+        DELETE FROM readRecordSession 
+        WHERE deviceId = :deviceId 
+        AND bookName = :bookName 
+        AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date
+    """
+    )
+    suspend fun deleteSessionsByBookAndDate(deviceId: String, bookName: String, date: String)
+
+    @Delete
+    suspend fun deleteSession(session: ReadRecordSession)
+
+    @Delete
+    suspend fun deleteReadRecord(record: ReadRecord)
+
+    @Query("DELETE FROM readRecordDetail WHERE deviceId = :deviceId AND bookName = :bookName")
+    suspend fun deleteDetailsByBook(deviceId: String, bookName: String)
+
+    @Query("DELETE FROM readRecordSession WHERE deviceId = :deviceId AND bookName = :bookName")
+    suspend fun deleteSessionsByBook(deviceId: String, bookName: String)
 }

+ 53 - 2
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -1,5 +1,6 @@
 package io.legado.app.data.repository
 
+import androidx.room.Transaction
 import cn.hutool.core.date.DatePattern
 import cn.hutool.core.date.DateUtil
 import io.legado.app.data.dao.ReadRecordDao
@@ -114,9 +115,59 @@ class ReadRecordRepository(
 
     suspend fun deleteDetail(detail: ReadRecordDetail) {
         dao.deleteDetail(detail)
+        dao.deleteSessionsByBookAndDate(detail.deviceId, detail.bookName, detail.date)
+        updateReadRecordTotal(detail.deviceId, detail.bookName)
     }
 
-    suspend fun clearAll() {
-        dao.clear()
+    @Transaction
+    suspend fun deleteSession(session: ReadRecordSession) {
+        dao.deleteSession(session)
+
+        val dateString = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
+        val remainingSessions =
+            dao.getSessionsByBookAndDate(session.deviceId, session.bookName, dateString)
+
+        if (remainingSessions.isEmpty()) {
+            val detail = dao.getDetail(session.deviceId, session.bookName, dateString)
+            detail?.let { dao.deleteDetail(it) }
+        } else {
+            val totalTime = remainingSessions.sumOf { it.endTime - it.startTime }
+            val totalWords = remainingSessions.sumOf { it.words }
+            val firstRead = remainingSessions.minOf { it.startTime }
+            val lastRead = remainingSessions.maxOf { it.endTime }
+
+            val existingDetail = dao.getDetail(session.deviceId, session.bookName, dateString)
+            existingDetail?.copy(
+                readTime = totalTime,
+                readWords = totalWords,
+                firstReadTime = firstRead,
+                lastReadTime = lastRead
+            )?.let { dao.insertDetail(it) }
+        }
+
+        updateReadRecordTotal(session.deviceId, session.bookName)
     }
+
+    private suspend fun updateReadRecordTotal(deviceId: String, bookName: String) {
+        val allRemainingSessions = dao.getSessionsByBook(deviceId, bookName)
+
+        if (allRemainingSessions.isEmpty()) {
+            dao.getReadRecord(deviceId, bookName)?.let { dao.deleteReadRecord(it) }
+        } else {
+            val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
+            val lastRead = allRemainingSessions.maxOf { it.endTime }
+
+            dao.getReadRecord(deviceId, bookName)?.copy(
+                readTime = totalTime,
+                lastRead = lastRead
+            )?.let { dao.update(it) }
+        }
+    }
+
+    suspend fun deleteReadRecord(record: ReadRecord) {
+        dao.deleteReadRecord(record)
+        dao.deleteDetailsByBook(record.deviceId, record.bookName)
+        dao.deleteSessionsByBook(record.deviceId, record.bookName)
+    }
+
 }

+ 21 - 5
app/src/main/java/io/legado/app/help/storage/Backup.kt

@@ -2,6 +2,8 @@ package io.legado.app.help.storage
 
 import android.content.Context
 import android.net.Uri
+import androidx.core.content.edit
+import androidx.core.net.toUri
 import androidx.documentfile.provider.DocumentFile
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.PreferKey
@@ -15,8 +17,20 @@ import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ThemeConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.BookCover
-import io.legado.app.utils.*
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.GSON
+import io.legado.app.utils.LogUtils
 import io.legado.app.utils.compress.ZipUtils
+import io.legado.app.utils.createFolderIfNotExist
+import io.legado.app.utils.defaultSharedPreferences
+import io.legado.app.utils.externalFiles
+import io.legado.app.utils.getFile
+import io.legado.app.utils.getSharedPreferences
+import io.legado.app.utils.isContentScheme
+import io.legado.app.utils.normalizeFileName
+import io.legado.app.utils.openOutputStream
+import io.legado.app.utils.outputStream
+import io.legado.app.utils.writeToOutputStream
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
@@ -28,11 +42,9 @@ import java.io.File
 import java.io.FileInputStream
 import java.io.FileOutputStream
 import java.text.SimpleDateFormat
-import java.util.*
+import java.util.Date
+import java.util.Locale
 import java.util.concurrent.TimeUnit
-import kotlin.coroutines.coroutineContext
-import androidx.core.content.edit
-import androidx.core.net.toUri
 
 /**
  * 备份
@@ -58,6 +70,8 @@ object Backup {
             "rssStar.json",
             "replaceRule.json",
             "readRecord.json",
+            "readRecordDetail.json",
+            "readRecordSession.json",
             "searchHistory.json",
             "sourceSub.json",
             "txtTocRule.json",
@@ -130,6 +144,8 @@ object Backup {
         writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath)
         writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath)
         writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath)
+        writeListToJson(appDb.readRecordDao.allDetail, "readRecordDetail.json", backupPath)
+        writeListToJson(appDb.readRecordDao.allSession, "readRecordSession.json", backupPath)
         writeListToJson(appDb.searchKeywordDao.all, "searchHistory.json", backupPath)
         writeListToJson(appDb.ruleSubDao.all, "sourceSub.json", backupPath)
         writeListToJson(appDb.txtTocRuleDao.all, "txtTocRule.json", backupPath)

+ 13 - 1
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -18,7 +18,6 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.KeyboardAssist
-import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.entities.RssStar
@@ -26,6 +25,9 @@ import io.legado.app.data.entities.RuleSub
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.data.entities.Server
 import io.legado.app.data.entities.TxtTocRule
+import io.legado.app.data.entities.readRecord.ReadRecord
+import io.legado.app.data.entities.readRecord.ReadRecordDetail
+import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.LauncherIconHelp
 import io.legado.app.help.book.isLocal
@@ -182,6 +184,16 @@ object Restore {
                 }
             }
         }
+        fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.let {
+            it.forEach { detail ->
+                appDb.readRecordDao.insertDetail(detail)
+            }
+        }
+        fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.let {
+            it.forEach { session ->
+                appDb.readRecordDao.insertSession(session)
+            }
+        }
         File(path, "servers.json").takeIf {
             it.exists()
         }?.runCatching {

+ 130 - 28
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -2,6 +2,7 @@ package io.legado.app.ui.book.readRecord
 
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -22,12 +23,14 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.List
 import androidx.compose.material.icons.filled.CalendarMonth
-import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Schedule
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Timeline
+import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.Card
 import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.Icon
@@ -36,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -47,6 +51,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.clip
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
@@ -66,6 +71,9 @@ 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.SectionHeader
+import io.legado.app.ui.widget.components.button.AlertButton
+import io.legado.app.ui.widget.components.swipe.SwipeAction
+import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
 import io.legado.app.utils.StringUtils.formatFriendlyDate
 import org.koin.androidx.compose.koinViewModel
 import java.time.LocalDate
@@ -86,6 +94,16 @@ fun ReadRecordScreen(
     val listState = rememberLazyListState()
     val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
 
+    var skipDeleteConfirm by remember { mutableStateOf(false) }
+    var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
+    val onConfirmDelete: (() -> Unit) -> Unit = { action ->
+        if (skipDeleteConfirm) {
+            action()
+        } else {
+            pendingDeleteAction = action
+        }
+    }
+
     LaunchedEffect(state.searchKey) {
         if (state.searchKey.isNullOrBlank()) {
             listState.animateScrollToItem(0)
@@ -205,14 +223,71 @@ fun ReadRecordScreen(
                             item(key = "summary_card") {
                                 SummarySection(state, viewModel)
                             }
-
-                            renderListByMode(displayMode, state, viewModel, onBookClick)
+                            renderListByMode(
+                                displayMode = displayMode,
+                                state = state,
+                                viewModel = viewModel,
+                                onBookClick = onBookClick,
+                                onConfirmDelete = onConfirmDelete
+                            )
                         }
                     }
                 }
             }
         }
     }
+
+    if (pendingDeleteAction != null) {
+        var skipDeleteConfirmTemp by remember { mutableStateOf(false) }
+        AlertDialog(
+            onDismissRequest = {
+                pendingDeleteAction = null
+            },
+            title = { Text("确认删除") },
+            text = {
+                Column {
+                    Text("确定要删除这条记录吗?")
+                    Spacer(modifier = Modifier.height(8.dp))
+                    Row(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .clip(MaterialTheme.shapes.small)
+                            .background(
+                                color = if (skipDeleteConfirmTemp) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
+                                shape = MaterialTheme.shapes.small
+                            )
+                            .clickable { skipDeleteConfirmTemp = !skipDeleteConfirmTemp }
+                            .padding(12.dp),
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        Checkbox(checked = skipDeleteConfirmTemp, onCheckedChange = null)
+                        Text(
+                            text = "不再提示",
+                            modifier = Modifier.padding(start = 8.dp),
+                            style = MaterialTheme.typography.bodyMedium
+                        )
+                    }
+                }
+            },
+            confirmButton = {
+                AlertButton(
+                    onClick = {
+                        pendingDeleteAction?.invoke()
+                        pendingDeleteAction = null
+                        skipDeleteConfirm = skipDeleteConfirmTemp
+                    },
+                    text = "删除"
+                )
+            },
+            dismissButton = {
+                TextButton(onClick = {
+                    pendingDeleteAction = null
+                }) {
+                    Text("取消")
+                }
+            }
+        )
+    }
 }
 
 @Composable
@@ -260,7 +335,8 @@ fun LazyListScope.renderListByMode(
     displayMode: DisplayMode,
     state: ReadRecordUiState,
     viewModel: ReadRecordViewModel,
-    onBookClick: (String) -> Unit
+    onBookClick: (String) -> Unit,
+    onConfirmDelete: (() -> Unit) -> Unit
 ) {
     when (displayMode) {
         DisplayMode.AGGREGATE -> {
@@ -268,37 +344,68 @@ fun LazyListScope.renderListByMode(
                 stickyHeader(key = "header_$date") {
                     DateHeader(date, details.sumOf { it.readTime })
                 }
-                items(items = details, key = { "${it.bookName}_${it.readTime}_$date" }) { detail ->
-                    ReadRecordItem(
-                        detail = detail,
-                        viewModel = viewModel,
-                        onClick = { onBookClick(detail.bookName) },
-                        onDelete = { viewModel.deleteDetail(detail) },
-                        modifier = Modifier.animateItem()
-                    )
+                items(items = details, key = { "${it.bookName}_${it.date}" }) { detail ->
+                    SwipeActionContainer(
+                        modifier = Modifier.animateItem(),
+                        startAction = SwipeAction(
+                            icon = Icons.Default.Delete,
+                            background = MaterialTheme.colorScheme.errorContainer,
+                            onSwipe = {
+                                onConfirmDelete { viewModel.deleteDetail(detail) }
+                            }
+                        )
+                    ) {
+                        ReadRecordItem(
+                            detail,
+                            viewModel,
+                            onClick = { onBookClick(detail.bookName) })
+                    }
                 }
             }
         }
+
         DisplayMode.TIMELINE -> {
             state.timelineRecords.forEach { (date, sessions) ->
                 stickyHeader(key = "timeline_header_$date") { DateHeader(date) }
                 items(items = sessions, key = { it.id }) { session ->
-                    TimelineSessionItem(
-                        item = TimelineItem(session, true),
-                        onBookClick = onBookClick,
-                        viewModel = viewModel
-                    )
+                    SwipeActionContainer(
+                        modifier = Modifier.animateItem(),
+                        startAction = SwipeAction(
+                            icon = Icons.Default.Delete,
+                            background = MaterialTheme.colorScheme.onErrorContainer,
+                            onSwipe = {
+                                onConfirmDelete { viewModel.deleteSession(session) }
+                            }
+                        )
+                    ) {
+                        TimelineSessionItem(
+                            item = TimelineItem(session, true),
+                            onBookClick = onBookClick,
+                            viewModel = viewModel
+                        )
+                    }
                 }
             }
         }
+
         DisplayMode.LATEST -> {
             items(items = state.latestRecords, key = { it.bookName }) { record ->
-                LatestReadItem(
-                    record = record,
-                    viewModel = viewModel,
-                    onClick = { onBookClick(record.bookName) },
-                    modifier = Modifier.animateItem()
-                )
+                SwipeActionContainer(
+                    modifier = Modifier.animateItem(),
+                    startAction = SwipeAction(
+                        icon = Icons.Default.Delete,
+                        background = MaterialTheme.colorScheme.error,
+                        onSwipe = {
+                            onConfirmDelete { viewModel.deleteReadRecord(record) }
+                        }
+                    )
+                ) {
+                    LatestReadItem(
+                        record = record,
+                        viewModel = viewModel,
+                        onClick = { onBookClick(record.bookName) }
+                    )
+                }
             }
         }
     }
@@ -445,7 +552,6 @@ fun ReadRecordItem(
     detail: ReadRecordDetail,
     viewModel: ReadRecordViewModel,
     onClick: () -> Unit,
-    onDelete: () -> Unit,
     modifier: Modifier = Modifier
 ) {
     var coverPath by remember { mutableStateOf<String?>(null) }
@@ -478,10 +584,6 @@ fun ReadRecordItem(
                 color = Color.Gray
             )
         }
-
-        IconButton(onClick = onDelete) {
-            Icon(Icons.Default.Close, "Delete", tint = Color.LightGray)
-        }
     }
 }
 

+ 13 - 7
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -31,7 +31,7 @@ data class ReadRecordUiState(
     //最后阅读列表
     val latestRecords: List<ReadRecord> = emptyList(),
     val selectedDate: LocalDate? = null,
-    val searchKey: String? = null,
+    val searchKey: String? = null
 )
 
 enum class DisplayMode {
@@ -48,11 +48,9 @@ class ReadRecordViewModel(
 
     private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
     val displayMode = _displayMode.asStateFlow()
-
     private val _searchKey = MutableStateFlow("")
     private val _selectedDate = MutableStateFlow<LocalDate?>(null)
 
-    // 数据源:直接从 Repository 获取 Flow
     @OptIn(ExperimentalCoroutinesApi::class)
     private val loadedDataFlow = _searchKey
         .flatMapLatest { query ->
@@ -71,11 +69,11 @@ class ReadRecordViewModel(
         _selectedDate,
         _searchKey
     ) { data, selectedDate, searchKey ->
-
         val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
-        val filteredDetails = if (dateStr != null) {
-            data.details.filter { it.date == dateStr }
-        } else data.details
+
+        val filteredDetails = data.details.filter { detail ->
+            dateStr == null || detail.date == dateStr
+        }
 
         val timelineMap = data.sessions
             .asSequence()
@@ -123,6 +121,14 @@ class ReadRecordViewModel(
         viewModelScope.launch { repository.deleteDetail(detail) }
     }
 
+    fun deleteSession(session: ReadRecordSession) {
+        viewModelScope.launch { repository.deleteSession(session) }
+    }
+
+    fun deleteReadRecord(record: ReadRecord) {
+        viewModelScope.launch { repository.deleteReadRecord(record) }
+    }
+
     private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
         if (sessions.isEmpty()) return emptyList()
         val mergedList = mutableListOf<ReadRecordSession>()

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

@@ -250,7 +250,7 @@ fun TxtRuleScreen(
     }
 
     RuleListScaffold(
-        title = "字典规则",
+        title = "目录规则",
         state = uiState,
         onBackClick = { onBackClick() },
         onSearchToggle = { active ->

+ 10 - 5
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -95,7 +95,7 @@ fun ReplaceRuleScreen(
 
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val rules = uiState.items
-    val groups = uiState.groups
+    val groups by viewModel.allGroups.collectAsStateWithLifecycle()
     val selectedIds = uiState.selectedIds
     val inSelectionMode = selectedIds.isNotEmpty()
 
@@ -116,10 +116,10 @@ fun ReplaceRuleScreen(
     var selectedTabIndex by remember { mutableIntStateOf(0) }
     val tabItems = remember(groups) { listOf("全部") + groups }
     val filteredRules = remember(uiState.items, selectedTabIndex, tabItems) {
-        if (selectedTabIndex == 0) {
+        val targetGroup = tabItems.getOrNull(selectedTabIndex)
+        if (targetGroup == null || selectedTabIndex == 0) {
             uiState.items
         } else {
-            val targetGroup = tabItems[selectedTabIndex]
             uiState.items.filter { it.group == targetGroup }
         }
     }
@@ -221,8 +221,10 @@ fun ReplaceRuleScreen(
     }
 
     LaunchedEffect(groups) {
-        if (selectedTabIndex > groups.size) {
+        val maxIndex = groups.size
+        if (selectedTabIndex > maxIndex) {
             selectedTabIndex = 0
+            viewModel.setGroup("全部")
         }
     }
 
@@ -339,7 +341,10 @@ fun ReplaceRuleScreen(
                     tabItems.forEachIndexed { index, title ->
                         Tab(
                             selected = selectedTabIndex == index,
-                            onClick = { selectedTabIndex = index },
+                            onClick = {
+                                selectedTabIndex = index
+                                viewModel.setGroup(title)
+                            },
                             text = {
                                 Text(
                                     text = title,

+ 27 - 3
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleViewModel.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.replace
 import android.app.Application
 import androidx.compose.runtime.Immutable
 import androidx.lifecycle.viewModelScope
+import io.legado.app.base.BaseRuleEvent
 import io.legado.app.base.BaseRuleViewModel
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.PreferKey
@@ -25,10 +26,13 @@ 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.map
+import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -67,6 +71,21 @@ class ReplaceRuleViewModel(
     private val _group = MutableStateFlow<String?>(null)
     val group = _group.asStateFlow()
 
+    val allGroups: StateFlow<List<String>> = repository.flowGroups()
+        .stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.WhileSubscribed(5000),
+            initialValue = emptyList()
+        )
+
+    fun setGroup(groupName: String?) {
+        _group.value = if (groupName == "全部" || groupName.isNullOrBlank()) {
+            null
+        } else {
+            groupName
+        }
+    }
+
     @OptIn(ExperimentalCoroutinesApi::class)
     override val rawDataFlow: Flow<List<ReplaceRule>> =
         combine(_group, _sortMode) { group, sortMode ->
@@ -156,9 +175,14 @@ class ReplaceRuleViewModel(
                     }
                     rule
                 }
-            repository.update(*rulesToSave.toTypedArray())
-            withContext(Dispatchers.Main) {
-                _importState.value = BaseImportUiState.Idle
+            if (rulesToSave.isNotEmpty()) {
+                rulesToSave.forEach { rule ->
+                    repository.insert(rule)
+                }
+                withContext(Dispatchers.Main) {
+                    _importState.value = BaseImportUiState.Idle
+                    _eventChannel.send(BaseRuleEvent.ShowSnackbar("成功导入 ${rulesToSave.size} 条规则"))
+                }
             }
         }
     }

+ 6 - 17
app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt

@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.HelpOutline
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.MoreVert
@@ -35,7 +34,6 @@ import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.AssistChip
 import androidx.compose.material3.BottomAppBar
-import androidx.compose.material3.ButtonDefaults
 import androidx.compose.material3.Checkbox
 import androidx.compose.material3.DropdownMenu
 import androidx.compose.material3.DropdownMenuItem
@@ -49,7 +47,6 @@ import androidx.compose.material3.FilterChipDefaults
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.OutlinedTextField
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
@@ -73,6 +70,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.button.AlertButton
+import io.legado.app.ui.widget.components.button.SmallTopBarButton
 import org.koin.androidx.compose.koinViewModel
 
 @Composable
@@ -114,12 +113,7 @@ fun ReplaceEditScreen(
                     GlassMediumFlexibleTopAppBar(
                         title = { Text(if (state.id > 0) "编辑替换规则" else "新增替换规则") },
                         navigationIcon = {
-                            IconButton(onClick = onBack) {
-                                Icon(
-                                    Icons.AutoMirrored.Filled.ArrowBack,
-                                    "Back"
-                                )
-                            }
+                            SmallTopBarButton(onClick = onBack)
                         },
                         actions = {
 
@@ -437,18 +431,13 @@ fun ManageGroupDialog(
             }
         },
         confirmButton = {
-            OutlinedButton(
+            AlertButton(
                 onClick = {
                     val toDelete = selected.filter { it.value }.keys.toList()
                     onDelete(toDelete)
                 },
-                colors = ButtonDefaults.outlinedButtonColors(
-                    contentColor = MaterialTheme.colorScheme.error,
-                    containerColor = Color.Transparent,
-                ),
-            ) {
-                Text("删除选中")
-            }
+                text = "删除选中"
+            )
         },
         dismissButton = { TextButton(onClick = onDismiss) { Text("关闭") } }
     )

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

@@ -0,0 +1,29 @@
+package io.legado.app.ui.widget.components.button
+
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+
+@Composable
+fun AlertButton(
+    onClick: () -> Unit,
+    modifier: Modifier = Modifier,
+    text: String = "删除",
+    enabled: Boolean = true
+) {
+    OutlinedButton(
+        onClick = onClick,
+        modifier = modifier,
+        enabled = enabled,
+        colors = ButtonDefaults.outlinedButtonColors(
+            contentColor = MaterialTheme.colorScheme.error,
+            containerColor = Color.Transparent,
+        )
+    ) {
+        Text(text = text)
+    }
+}

+ 11 - 0
app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeAction.kt

@@ -0,0 +1,11 @@
+package io.legado.app.ui.widget.components.swipe
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+
+data class SwipeAction(
+    val icon: ImageVector,
+    val background: Color,
+    val onSwipe: () -> Unit,
+    val hapticFeedback: Boolean = true
+)

+ 114 - 0
app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt

@@ -0,0 +1,114 @@
+package io.legado.app.ui.widget.components.swipe
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.SwipeToDismissBox
+import androidx.compose.material3.SwipeToDismissBoxValue
+import androidx.compose.material3.contentColorFor
+import androidx.compose.material3.rememberSwipeToDismissBoxState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SwipeActionContainer(
+    modifier: Modifier = Modifier,
+    startAction: SwipeAction? = null,
+    content: @Composable () -> Unit
+) {
+    val haptic = LocalHapticFeedback.current
+    val dismissState = rememberSwipeToDismissBoxState(
+        positionalThreshold = { totalDistance ->
+            totalDistance * 0.7f
+        }
+    )
+    val isThresholdReached =
+        dismissState.progress > 0.5f && dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd
+
+    LaunchedEffect(dismissState.targetValue) {
+        if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
+            if (startAction?.hapticFeedback == true) {
+                haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+            }
+        }
+    }
+
+    LaunchedEffect(dismissState.settledValue) {
+        if (dismissState.settledValue == SwipeToDismissBoxValue.StartToEnd) {
+            startAction?.onSwipe?.invoke()
+            dismissState.reset()
+        }
+    }
+
+    SwipeToDismissBox(
+        state = dismissState,
+        modifier = modifier,
+        enableDismissFromStartToEnd = startAction != null,
+        enableDismissFromEndToStart = false,
+        backgroundContent = {
+            val direction = dismissState.dismissDirection
+            val progress = dismissState.progress
+
+            if (direction == SwipeToDismissBoxValue.StartToEnd) {
+                val backgroundColor by animateColorAsState(
+                    targetValue = if (isThresholdReached) startAction!!.background
+                    else MaterialTheme.colorScheme.surfaceVariant,
+                    label = "bgColor"
+                )
+
+                Box(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .background(backgroundColor)
+                        .padding(horizontal = 24.dp),
+                    contentAlignment = Alignment.CenterStart
+                ) {
+                    val iconScale by animateFloatAsState(
+                        targetValue = if (isThresholdReached) 1.3f else progress.coerceIn(0.5f, 1f),
+                        label = "iconScale"
+                    )
+
+                    Icon(
+                        imageVector = startAction!!.icon,
+                        contentDescription = null,
+                        modifier = Modifier
+                            .size(24.dp)
+                            .graphicsLayer {
+                                scaleX = iconScale
+                                scaleY = iconScale
+                            },
+                        tint = if (isThresholdReached)
+                            contentColorFor(startAction.background)
+                        else MaterialTheme.colorScheme.onSurfaceVariant
+                    )
+                }
+            }
+        },
+        content = {
+            Surface(
+                modifier = Modifier.fillMaxSize(),
+                color = MaterialTheme.colorScheme.surface,
+                shape = RectangleShape
+            ) {
+                content()
+            }
+        }
+    )
+}