Răsfoiți Sursa

fix(read-record): 修复阅读记录删除确认与会话删除

- 修复聚合阅读记录删除时无法准确定位底层会话的问题
- 删除阅读时段时按会话分组同步清理连续阅读记录
- 增加本页跳过确认和长期跳过确认两种删除选项
- 持久化长期跳过删除确认设置,并支持从操作面板恢复确认提示
- 修复取消删除后滑动操作仍保持展开的问题
- 为批量删除、单条删除和滑动删除统一增加确认处理
- 将删除失败提示改为资源文案,并补充多语言字符串
- 增加删除记录返回状态,避免无数据时误报操作成功
- 补充阅读记录仓库删除逻辑测试

Fixes #2291
coastline-echo 1 lună în urmă
părinte
comite
15c3a62d82

+ 1 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -35,6 +35,7 @@ object LocalPreferencesKeys {
     val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
     val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
     val READ_RECORD_DISPLAY_MODE = stringPreferencesKey("readRecordDisplayMode")
+    val READ_RECORD_SKIP_DELETE_CONFIRM = booleanPreferencesKey("readRecordSkipDeleteConfirm")
     val READ_RECORD_ALIAS_DECISIONS = stringPreferencesKey("readRecordAliasDecisions")
     val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")
     val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_cover_album_id")

+ 70 - 18
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -41,10 +41,17 @@ class ReadRecordRepository(
     val readRecordEnabled: Flow<Boolean> =
         localPreferencesRepository.getPreference(LocalPreferencesKeys.ENABLE_READ_RECORD, true)
 
+    val skipDeleteConfirm: Flow<Boolean> =
+        localPreferencesRepository.getBoolean(LocalPreferencesKeys.READ_RECORD_SKIP_DELETE_CONFIRM.name, false)
+
     suspend fun setReadRecordEnabled(enabled: Boolean) {
         localPreferencesRepository.updatePreference(LocalPreferencesKeys.ENABLE_READ_RECORD, enabled)
     }
 
+    suspend fun setSkipDeleteConfirm(enabled: Boolean) {
+        localPreferencesRepository.putBoolean(LocalPreferencesKeys.READ_RECORD_SKIP_DELETE_CONFIRM.name, enabled)
+    }
+
     /**
      * 获取总阅读时长流
      */
@@ -430,8 +437,11 @@ class ReadRecordRepository(
         }
     }
 
-    suspend fun deleteDetail(detail: ReadRecordDetail) {
-        database.withTransaction {
+    suspend fun deleteDetail(detail: ReadRecordDetail): Boolean {
+        return database.withTransaction {
+            val existed = dao.allDetail.any {
+                it.bookName == detail.bookName && it.bookAuthor == detail.bookAuthor && it.date == detail.date
+            }
             // 聚合详情代表所有设备同一天的阅读,删除时必须同步删除底层阅读时段记录。
             val affectedDevices = dao.allSession.asSequence()
                 .filter {
@@ -459,18 +469,59 @@ class ReadRecordRepository(
                     legacyReadTimes[deviceId] ?: 0L,
                 )
             }
+            existed
         }
     }
 
-    suspend fun deleteSession(session: ReadRecordSession) {
-        database.withTransaction {
+    suspend fun deleteSession(requestedSession: ReadRecordSession): Boolean {
+        return database.withTransaction {
+            // 时间线展示项可能经过合并或跨设备聚合,优先使用 Room 行 ID 定位真实记录,
+            // 避免使用展示层的结束时间反查不到底层会话。
+            val session = if (requestedSession.id > 0L) {
+                dao.allSession.firstOrNull { it.id == requestedSession.id }
+            } else {
+                dao.allSession.firstOrNull {
+                    it.bookName == requestedSession.bookName &&
+                        it.bookAuthor == requestedSession.bookAuthor &&
+                        it.startTime == requestedSession.startTime &&
+                        it.endTime == requestedSession.endTime &&
+                        it.words == requestedSession.words
+                }
+            } ?: return@withTransaction false
+            val sessionGroup = dao.allSession
+                .asSequence()
+                .filter {
+                    it.bookName == session.bookName &&
+                        it.bookAuthor == session.bookAuthor &&
+                        it.startTime.toDateString() == session.startTime.toDateString()
+                }
+                .sortedBy { it.startTime }
+                .toList()
+                .let { sessions ->
+                    val groups = sessions.fold(mutableListOf<MutableList<ReadRecordSession>>()) { groups, current ->
+                        val previous = groups.lastOrNull()?.lastOrNull()
+                        if (previous != null && current.startTime - previous.endTime <= 20 * 60 * 1000L) {
+                            groups.last().add(current)
+                        } else {
+                            groups.add(mutableListOf(current))
+                        }
+                        groups
+                    }
+                    groups.firstOrNull { group -> group.any { it.id == session.id } }
+                        ?: groups.firstOrNull { group ->
+                            group.any {
+                                it.startTime == session.startTime &&
+                                    it.endTime == session.endTime &&
+                                    it.words == session.words
+                            }
+                        }
+                        .orEmpty()
+                }
             val affectedDevices = dao.allSession.asSequence()
                 .filter {
                     it.bookName == session.bookName &&
                         it.bookAuthor == session.bookAuthor &&
-                        it.startTime == session.startTime &&
-                        it.endTime == session.endTime &&
-                        it.words == session.words
+                        it.id in sessionGroup.map { grouped -> grouped.id }
                 }
                 .mapTo(linkedSetOf()) { it.deviceId }
                 .apply { addAll(dao.getReadRecordsByName(session.bookName, session.bookAuthor).map { it.deviceId }) }
@@ -488,13 +539,7 @@ class ReadRecordRepository(
                     session.startTime.toDateString(),
                 )
             }
-            dao.deleteSessionByIdentity(
-                session.bookName,
-                session.bookAuthor,
-                session.startTime,
-                session.endTime,
-                session.words,
-            )
+            sessionGroup.forEach { dao.deleteSession(it) }
             val dateString = session.startTime.toDateString()
             affectedDevices.forEach { deviceId ->
                     val record = ReadRecord(
@@ -559,6 +604,7 @@ class ReadRecordRepository(
                         legacyReadTimes[deviceId] ?: 0L,
                     )
                 }
+            sessionGroup.isNotEmpty()
         }
     }
 
@@ -605,19 +651,25 @@ class ReadRecordRepository(
         }
     }
 
-    suspend fun deleteReadRecord(record: ReadRecord) {
-        database.withTransaction {
+    suspend fun deleteReadRecord(record: ReadRecord): Boolean {
+        return database.withTransaction {
+            val existed = dao.all.any {
+                it.bookName == record.bookName && it.bookAuthor == record.bookAuthor
+            }
             dao.deleteByName(record.bookName, record.bookAuthor)
             dao.deleteDetailByName(record.bookName, record.bookAuthor)
             dao.deleteSessionByName(record.bookName, record.bookAuthor)
+            existed
         }
     }
 
-    suspend fun clearReadRecords() {
-        database.withTransaction {
+    suspend fun clearReadRecords(): Boolean {
+        return database.withTransaction {
+            val existed = dao.all.isNotEmpty() || dao.allDetail.isNotEmpty() || dao.allSession.isNotEmpty()
             dao.clearReadRecordSessions()
             dao.clearReadRecordDetails()
             dao.clearReadRecords()
+            existed
         }
     }
 

+ 82 - 13
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -41,6 +41,7 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.produceState
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
@@ -53,6 +54,7 @@ import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.contentDescription
@@ -75,6 +77,7 @@ import io.legado.app.ui.theme.adaptiveContentPaddingOnlyVertical
 import io.legado.app.ui.theme.adaptiveHorizontalPadding
 import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppRadioButton
 import io.legado.app.ui.widget.components.CollapsibleHeader
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
@@ -147,6 +150,9 @@ fun ReadRecordRouteScreen(
         onScanRepair = { viewModel.onIntent(ReadRecordIntent.ScanRepair) },
         onRepairDatabase = { viewModel.onIntent(ReadRecordIntent.RepairDatabase) },
         effects = viewModel.effects,
+        onSetSkipDeleteConfirm = { enabled ->
+            viewModel.onIntent(ReadRecordIntent.SetSkipDeleteConfirm(enabled))
+        },
     )
 }
 
@@ -164,11 +170,12 @@ fun ReadRecordScreen(
     effects: Flow<ReadRecordEffect> = emptyFlow(),
     onScanRepair: () -> Unit = {},
     onRepairDatabase: () -> Unit = {},
+    onSetSkipDeleteConfirm: (Boolean) -> Unit = {},
 ) {
     val snackbarHostState = remember { SnackbarHostState() }
     val scope = rememberCoroutineScope()
+    val context = LocalContext.current
     val noMergeCandidatesMessage = stringResource(R.string.no_merge_candidates)
-    val operationFailedMessage = stringResource(R.string.operation_failed)
 
     val displayMode = state.displayMode
     val readRecordEnabled = state.readRecordEnabled
@@ -184,19 +191,20 @@ fun ReadRecordScreen(
     LaunchedEffect(Unit) {
         effects.collectLatest { effect ->
             when (effect) {
-                is ReadRecordEffect.ShowError -> {
-                    snackbarHostState.showSnackbar(effect.message.ifBlank { operationFailedMessage })
+                is ReadRecordEffect.ShowMessage -> {
+                    snackbarHostState.showSnackbar(context.getString(effect.messageRes))
                 }
             }
         }
     }
 
-    var skipDeleteConfirm by remember { mutableStateOf(false) }
+    var skipDeleteConfirmForPage by remember { mutableStateOf(false) }
+    var swipeResetKey by remember { mutableIntStateOf(0) }
     var pendingDeleteAction by remember { mutableStateOf<(() -> Unit)?>(null) }
     var pendingDeleteCount by remember { mutableStateOf(1) }
     var mergeDialogData by remember { mutableStateOf<Pair<ReadRecord, List<ReadRecord>>?>(null) }
     val onConfirmDelete: (Int, () -> Unit) -> Unit = { count, action ->
-        if (skipDeleteConfirm) {
+        if (skipDeleteConfirmForPage || state.skipDeleteConfirm) {
             action()
         } else {
             pendingDeleteCount = count
@@ -401,6 +409,7 @@ fun ReadRecordScreen(
                                 loadChapterTitle = loadChapterTitle,
                                 onBookClick = onBookClick,
                                 onConfirmDelete = onConfirmDelete,
+                                swipeResetKey = swipeResetKey,
                                 selectedItemKeys = selectedItemKeys,
                                 inSelectionMode = inSelectionMode,
                                 onToggleSelection = { key ->
@@ -463,6 +472,7 @@ fun ReadRecordScreen(
         show = showActionsSheet,
         showCalendar = showCalendar,
         readRecordEnabled = readRecordEnabled,
+        skipDeleteConfirm = state.skipDeleteConfirm,
         onDismissRequest = { showActionsSheet = false },
         onToggleCalendar = {
             showCalendar = !showCalendar
@@ -479,7 +489,12 @@ fun ReadRecordScreen(
             }
         },
         onScanRepair = { showActionsSheet = false; onScanRepair() },
-        onRepairDatabase = { showActionsSheet = false; onRepairDatabase() }
+        onRepairDatabase = { showActionsSheet = false; onRepairDatabase() },
+        onResetDeleteConfirm = {
+            showActionsSheet = false
+            skipDeleteConfirmForPage = false
+            onIntent(ReadRecordIntent.RestoreDeleteConfirmation)
+        }
     )
 
     state.repairReport?.let { report ->
@@ -499,10 +514,14 @@ fun ReadRecordScreen(
     }
 
     var skipDeleteConfirmTemp by remember(pendingDeleteAction != null) { mutableStateOf(false) }
+    var skipDeleteConfirmLongTermTemp by remember(pendingDeleteAction != null) { mutableStateOf(false) }
 
     AppAlertDialog(
         data = pendingDeleteAction,
-        onDismissRequest = { pendingDeleteAction = null },
+        onDismissRequest = {
+            pendingDeleteAction = null
+            swipeResetKey++
+        },
         title = stringResource(R.string.confirm_delete_read_record),
         content = { _ ->
             Column {
@@ -517,10 +536,21 @@ fun ReadRecordScreen(
                 )
                 if (pendingDeleteCount != -1) {
                     Spacer(modifier = Modifier.height(8.dp))
-                    CheckboxItem(
-                        title = stringResource(R.string.do_not_remind_again),
-                        checked = skipDeleteConfirmTemp,
-                        onCheckedChange = { skipDeleteConfirmTemp = it }
+                    DeleteConfirmationChoice(
+                        text = stringResource(R.string.do_not_remind_again_this_page),
+                        selected = skipDeleteConfirmTemp,
+                        onClick = {
+                            skipDeleteConfirmTemp = !skipDeleteConfirmTemp
+                            if (skipDeleteConfirmTemp) skipDeleteConfirmLongTermTemp = false
+                        },
+                    )
+                    DeleteConfirmationChoice(
+                        text = stringResource(R.string.do_not_remind_again_long_term),
+                        selected = skipDeleteConfirmLongTermTemp,
+                        onClick = {
+                            skipDeleteConfirmLongTermTemp = !skipDeleteConfirmLongTermTemp
+                            if (skipDeleteConfirmLongTermTemp) skipDeleteConfirmTemp = false
+                        },
                     )
                 }
             }
@@ -530,12 +560,16 @@ fun ReadRecordScreen(
             action.invoke()
             pendingDeleteAction = null
             if (pendingDeleteCount != -1) {
-                skipDeleteConfirm = skipDeleteConfirmTemp
+                skipDeleteConfirmForPage = skipDeleteConfirmTemp || skipDeleteConfirmLongTermTemp
+                if (skipDeleteConfirmLongTermTemp) {
+                    onSetSkipDeleteConfirm(true)
+                }
             }
         },
         dismissText = stringResource(R.string.cancel),
         onDismiss = {
             pendingDeleteAction = null
+            swipeResetKey++
         }
     )
 
@@ -677,12 +711,14 @@ private fun ReadRecordActionsSheet(
     show: Boolean,
     showCalendar: Boolean,
     readRecordEnabled: Boolean,
+    skipDeleteConfirm: Boolean,
     onDismissRequest: () -> Unit,
     onToggleCalendar: () -> Unit,
     onReadRecordEnabledChange: (Boolean) -> Unit,
     onClearReadRecords: () -> Unit,
     onScanRepair: () -> Unit,
     onRepairDatabase: () -> Unit,
+    onResetDeleteConfirm: () -> Unit,
 ) {
     AppModalBottomSheet(
         show = show,
@@ -721,6 +757,14 @@ private fun ReadRecordActionsSheet(
                 imageVector = Icons.Default.Merge,
                 onClick = onRepairDatabase
             )
+            if (skipDeleteConfirm) {
+                CompactClickableSettingItem(
+                    title = stringResource(R.string.restore_delete_confirmation),
+                    description = stringResource(R.string.restore_delete_confirmation_summary),
+                    imageVector = Icons.Default.Delete,
+                    onClick = onResetDeleteConfirm,
+                )
+            }
             CompactClickableSettingItem(
                 title = stringResource(R.string.clear_read_records),
                 description = stringResource(R.string.clear_read_records_summary),
@@ -731,6 +775,24 @@ private fun ReadRecordActionsSheet(
     }
 }
 
+@Composable
+private fun DeleteConfirmationChoice(
+    text: String,
+    selected: Boolean,
+    onClick: () -> Unit,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .combinedClickable(onClick = onClick)
+            .semantics { role = Role.RadioButton },
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        AppRadioButton(selected = selected, onClick = onClick)
+        AppText(text = text)
+    }
+}
+
 private fun ReadRecord.mergeKey(): String {
     return "$deviceId|$bookName|$bookAuthor"
 }
@@ -923,6 +985,7 @@ fun LazyListScope.renderListByMode(
     loadChapterTitle: suspend (String, String, Long) -> String?,
     onBookClick: (String, String) -> Unit,
     onConfirmDelete: (Int, () -> Unit) -> Unit,
+    swipeResetKey: Int,
     selectedItemKeys: Set<String>,
     inSelectionMode: Boolean,
     onToggleSelection: (String) -> Unit,
@@ -964,6 +1027,7 @@ fun LazyListScope.renderListByMode(
                         itemContent(Modifier.animateItem())
                     } else {
                         SwipeActionContainer(
+                            resetKey = swipeResetKey,
                             modifier = Modifier.animateItem(),
                             startAction = SwipeAction(
                                 icon = Icons.Default.Delete,
@@ -974,6 +1038,7 @@ fun LazyListScope.renderListByMode(
                                     }
                                 },
                                 contentDescription = deleteActionDescription
+                                ,resetAfterSwipe = false
                             )
                         ) {
                             itemContent(Modifier)
@@ -1012,6 +1077,7 @@ fun LazyListScope.renderListByMode(
                         itemContent(Modifier.animateItem())
                     } else {
                         SwipeActionContainer(
+                            resetKey = swipeResetKey,
                             modifier = Modifier.animateItem(),
                             startAction = SwipeAction(
                                 icon = Icons.Default.Delete,
@@ -1022,6 +1088,7 @@ fun LazyListScope.renderListByMode(
                                     }
                                 },
                                 contentDescription = deleteActionDescription
+                                ,resetAfterSwipe = false
                             )
                         ) {
                             itemContent(Modifier)
@@ -1058,6 +1125,7 @@ fun LazyListScope.renderListByMode(
                     itemContent(Modifier.animateItem())
                 } else {
                     SwipeActionContainer(
+                        resetKey = swipeResetKey,
                         modifier = Modifier.animateItem(),
                         startAction = SwipeAction(
                             icon = Icons.Default.Delete,
@@ -1067,7 +1135,8 @@ fun LazyListScope.renderListByMode(
                                     onIntent(ReadRecordIntent.DeleteRecord(record))
                                 }
                             },
-                            contentDescription = deleteActionDescription
+                                contentDescription = deleteActionDescription
+                                ,resetAfterSwipe = false
                         ),
                         endAction = SwipeAction(
                             icon = Icons.Default.Merge,

+ 65 - 14
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -2,7 +2,9 @@ package io.legado.app.ui.book.readRecord
 
 import androidx.lifecycle.ViewModel
 import androidx.compose.runtime.Stable
+import androidx.annotation.StringRes
 import androidx.lifecycle.viewModelScope
+import io.legado.app.R
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordSession
@@ -47,6 +49,7 @@ data class ReadRecordUiState(
     val dailyReadTimes: ImmutableMap<LocalDate, Long> = persistentMapOf(),
     val displayMode: DisplayMode = DisplayMode.AGGREGATE,
     val readRecordEnabled: Boolean = true,
+    val skipDeleteConfirm: Boolean = false,
     val repairReport: ReadRecordRepairReport? = null,
 )
 
@@ -87,6 +90,8 @@ class ReadRecordViewModel(
             started = SharingStarted.WhileSubscribed(5000),
             initialValue = true
         )
+    val skipDeleteConfirm: StateFlow<Boolean> = repository.skipDeleteConfirm
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
 
     @OptIn(ExperimentalCoroutinesApi::class)
     private val loadedDataFlow = _searchKey
@@ -162,7 +167,8 @@ class ReadRecordViewModel(
         scope = viewModelScope,
         started = SharingStarted.WhileSubscribed(5000),
         initialValue = ReadRecordUiState(isLoading = true)
-    ).combine(_repairReport) { state, report -> state.copy(repairReport = report) }
+    ).combine(skipDeleteConfirm) { state, skip -> state.copy(skipDeleteConfirm = skip) }
+        .combine(_repairReport) { state, report -> state.copy(repairReport = report) }
         .stateIn(
             scope = viewModelScope,
             started = SharingStarted.WhileSubscribed(5000),
@@ -179,6 +185,8 @@ class ReadRecordViewModel(
             is ReadRecordIntent.DeleteRecord -> deleteReadRecord(intent.record)
             ReadRecordIntent.ClearRecords -> clearReadRecords()
             is ReadRecordIntent.SetEnabled -> setReadRecordEnabled(intent.enabled)
+            is ReadRecordIntent.SetSkipDeleteConfirm -> setSkipDeleteConfirm(intent.enabled)
+            ReadRecordIntent.RestoreDeleteConfirmation -> restoreDeleteConfirmation()
             is ReadRecordIntent.MergeRecords -> mergeReadRecords(intent.target, intent.sources)
             ReadRecordIntent.ScanRepair -> scanRepair()
             ReadRecordIntent.RepairDatabase -> repairDatabase()
@@ -195,7 +203,7 @@ class ReadRecordViewModel(
         viewModelScope.launch {
             runCatching { repository.scanReadRecordIssues() }
                 .onSuccess { _repairReport.value = it }
-                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowError(it.localizedMessage.orEmpty())) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
         }
     }
 
@@ -207,7 +215,7 @@ class ReadRecordViewModel(
                 val sessions = repository.repairDuplicateSessions()
                 identity.copy(duplicateSessionCount = sessions)
             }.onSuccess { _repairReport.value = it }
-                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowError(it.localizedMessage.orEmpty())) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
         }
     }
 
@@ -225,23 +233,65 @@ class ReadRecordViewModel(
     }
 
     fun deleteDetail(detail: ReadRecordDetail) {
-        viewModelScope.launch { repository.deleteDetail(detail) }
+        viewModelScope.launch {
+            runCatching { repository.deleteDetail(detail) }
+                .onSuccess { deleted -> emitMutationResult(deleted, R.string.read_record_deleted) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
+        }
     }
 
     fun deleteSession(session: ReadRecordSession) {
-        viewModelScope.launch { repository.deleteSession(session) }
+        viewModelScope.launch {
+            runCatching { repository.deleteSession(session) }
+                .onSuccess { deleted -> emitMutationResult(deleted, R.string.read_record_deleted) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
+        }
     }
 
     fun deleteReadRecord(record: ReadRecord) {
-        viewModelScope.launch { repository.deleteReadRecord(record) }
+        viewModelScope.launch {
+            runCatching { repository.deleteReadRecord(record) }
+                .onSuccess { deleted -> emitMutationResult(deleted, R.string.read_record_deleted) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
+        }
     }
 
     fun clearReadRecords() {
-        viewModelScope.launch { repository.clearReadRecords() }
+        viewModelScope.launch {
+            runCatching { repository.clearReadRecords() }
+                .onSuccess { cleared -> emitMutationResult(cleared, R.string.read_records_cleared) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
+        }
     }
 
     fun setReadRecordEnabled(enabled: Boolean) {
-        viewModelScope.launch { repository.setReadRecordEnabled(enabled) }
+        viewModelScope.launch {
+            runCatching { repository.setReadRecordEnabled(enabled) }
+                .onSuccess { }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
+        }
+    }
+
+    private fun setSkipDeleteConfirm(enabled: Boolean) {
+        viewModelScope.launch {
+            repository.setSkipDeleteConfirm(enabled)
+        }
+    }
+
+    fun restoreDeleteConfirmation() {
+        viewModelScope.launch {
+            runCatching { repository.setSkipDeleteConfirm(false) }
+                .onSuccess { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.delete_confirmation_restored)) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
+        }
+    }
+
+    private fun emitMutationResult(succeeded: Boolean, successMessage: Int) {
+        _effects.tryEmit(
+            ReadRecordEffect.ShowMessage(
+                if (succeeded) successMessage else R.string.operation_failed
+            )
+        )
     }
 
     private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
@@ -280,14 +330,13 @@ class ReadRecordViewModel(
 
     fun mergeReadRecords(targetRecord: ReadRecord, sourceRecords: List<ReadRecord>) {
         if (sourceRecords.isEmpty()) {
-            _effects.tryEmit(ReadRecordEffect.ShowError(""))
+            _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed))
             return
         }
         viewModelScope.launch {
-            val merged = repository.mergeIndependentReadRecordsInto(targetRecord, sourceRecords)
-            if (!merged) {
-                _effects.tryEmit(ReadRecordEffect.ShowError(""))
-            }
+            runCatching { repository.mergeIndependentReadRecordsInto(targetRecord, sourceRecords) }
+                .onSuccess { merged -> emitMutationResult(merged, R.string.read_records_merged) }
+                .onFailure { _effects.tryEmit(ReadRecordEffect.ShowMessage(R.string.operation_failed)) }
         }
     }
 
@@ -311,6 +360,8 @@ sealed interface ReadRecordIntent {
     data class DeleteRecord(val record: ReadRecord) : ReadRecordIntent
     data object ClearRecords : ReadRecordIntent
     data class SetEnabled(val enabled: Boolean) : ReadRecordIntent
+    data class SetSkipDeleteConfirm(val enabled: Boolean) : ReadRecordIntent
+    data object RestoreDeleteConfirmation : ReadRecordIntent
     data class MergeRecords(val target: ReadRecord, val sources: List<ReadRecord>) : ReadRecordIntent
     data object ScanRepair : ReadRecordIntent
     data object RepairDatabase : ReadRecordIntent
@@ -318,5 +369,5 @@ sealed interface ReadRecordIntent {
 }
 
 sealed interface ReadRecordEffect {
-    data class ShowError(val message: String) : ReadRecordEffect
+    data class ShowMessage(@StringRes val messageRes: Int) : ReadRecordEffect
 }

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

@@ -8,5 +8,7 @@ data class SwipeAction(
     val background: Color,
     val onSwipe: () -> Unit,
     val hapticFeedback: Boolean = true,
-    val contentDescription: String? = null
+    val contentDescription: String? = null,
+    /** 删除类操作由数据刷新移除列表项,不立即回弹;普通操作保持自动复位。 */
+    val resetAfterSwipe: Boolean = true,
 )

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

@@ -16,6 +16,8 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.geometry.CornerRadius
@@ -33,6 +35,7 @@ import androidx.compose.ui.semantics.customActions
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
+import kotlinx.coroutines.flow.collectLatest
 import kotlin.math.absoluteValue
 
 /** 滑动露出底块上下内缩量,让操作提示呈现为圆角矩形而不是整行色块。 */
@@ -51,17 +54,24 @@ private val SwipeActionIconSize = 24.dp
 @Composable
 fun SwipeActionContainer(
     modifier: Modifier = Modifier,
+    resetKey: Any? = null,
     startAction: SwipeAction? = null,
     endAction: SwipeAction? = null,
     content: @Composable () -> Unit
 ) {
     val haptic = LocalHapticFeedback.current
+    val currentStartAction by rememberUpdatedState(startAction)
+    val currentEndAction by rememberUpdatedState(endAction)
     val dismissState = rememberSwipeToDismissBoxState(
         positionalThreshold = { totalDistance ->
             totalDistance * 0.6f
         }
     )
 
+    LaunchedEffect(resetKey) {
+        dismissState.reset()
+    }
+
     LaunchedEffect(dismissState.targetValue) {
         if (dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd) {
             if (startAction?.hapticFeedback == true) {
@@ -70,19 +80,25 @@ fun SwipeActionContainer(
         }
     }
 
-    LaunchedEffect(dismissState.settledValue) {
-        when (dismissState.settledValue) {
-            SwipeToDismissBoxValue.StartToEnd -> {
-                startAction?.onSwipe?.invoke()
-                dismissState.reset()
-            }
+    LaunchedEffect(dismissState) {
+        snapshotFlow { dismissState.settledValue }.collectLatest { settledValue ->
+            when (settledValue) {
+                SwipeToDismissBoxValue.StartToEnd -> {
+                    currentStartAction?.onSwipe?.invoke()
+                    if (currentStartAction?.resetAfterSwipe != false) {
+                        dismissState.reset()
+                    }
+                }
 
-            SwipeToDismissBoxValue.EndToStart -> {
-                endAction?.onSwipe?.invoke()
-                dismissState.reset()
-            }
+                SwipeToDismissBoxValue.EndToStart -> {
+                    currentEndAction?.onSwipe?.invoke()
+                    if (currentEndAction?.resetAfterSwipe != false) {
+                        dismissState.reset()
+                    }
+                }
 
-            else -> {}
+                else -> {}
+            }
         }
     }
 

+ 8 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -2718,6 +2718,14 @@
     <string name="read_record_repair">修复阅读记录</string>
     <string name="read_record_repair_summary">合并可确定归属的记录,并清理字段完全相同的阅读时段记录</string>
     <string name="operation_failed">操作失败</string>
+    <string name="do_not_remind_again_this_page">本次打开不再提示</string>
+    <string name="do_not_remind_again_long_term">今后不再提醒</string>
+    <string name="restore_delete_confirmation">恢复删除确认</string>
+    <string name="restore_delete_confirmation_summary">删除阅读记录前再次进行确认</string>
+    <string name="delete_confirmation_restored">已恢复删除确认</string>
+    <string name="read_record_deleted">已删除阅读记录</string>
+    <string name="read_records_cleared">已清空阅读记录</string>
+    <string name="read_records_merged">已合并阅读记录</string>
     <string name="merge_read_records_message">将选中的阅读记录合并到“%1$s”/%2$s。</string>
     <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
     <string name="no_merge_candidates">没有可合并的其它阅读记录</string>

+ 8 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -2587,6 +2587,14 @@
     <string name="read_record_repair">修復閱讀記錄</string>
     <string name="read_record_repair_summary">合併可確定歸屬的記錄,並清理欄位完全相同的閱讀時段記錄</string>
     <string name="operation_failed">操作失敗</string>
+    <string name="do_not_remind_again_this_page">本次開啟不再提示</string>
+    <string name="do_not_remind_again_long_term">今後不再提醒</string>
+    <string name="restore_delete_confirmation">恢復刪除確認</string>
+    <string name="restore_delete_confirmation_summary">刪除閱讀記錄前再次進行確認</string>
+    <string name="delete_confirmation_restored">已恢復刪除確認</string>
+    <string name="read_record_deleted">已刪除閱讀記錄</string>
+    <string name="read_records_cleared">已清空閱讀記錄</string>
+    <string name="read_records_merged">已合併閱讀記錄</string>
     <string name="merge_read_records_message">將選取的閱讀記錄合併到「%1$s」/%2$s。</string>
     <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
     <string name="no_merge_candidates">沒有可合併的其他閱讀記錄</string>

+ 8 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -2592,6 +2592,14 @@
     <string name="read_record_repair">修復閱讀記錄</string>
     <string name="read_record_repair_summary">合併可確定歸屬的記錄,並清理欄位完全相同的閱讀時段記錄</string>
     <string name="operation_failed">操作失敗</string>
+    <string name="do_not_remind_again_this_page">本次開啟不再提示</string>
+    <string name="do_not_remind_again_long_term">今後不再提醒</string>
+    <string name="restore_delete_confirmation">恢復刪除確認</string>
+    <string name="restore_delete_confirmation_summary">刪除閱讀記錄前再次進行確認</string>
+    <string name="delete_confirmation_restored">已恢復刪除確認</string>
+    <string name="read_record_deleted">已刪除閱讀記錄</string>
+    <string name="read_records_cleared">已清空閱讀記錄</string>
+    <string name="read_records_merged">已合併閱讀記錄</string>
     <string name="merge_read_records_message">將選取的閱讀記錄合併到「%1$s」/%2$s。</string>
     <string name="merge_read_record_candidate">%1$s / %2$s(%3$s)</string>
     <string name="no_merge_candidates">沒有可合併的其他閱讀記錄</string>

+ 8 - 0
app/src/main/res/values/strings.xml

@@ -2724,6 +2724,14 @@
     <string name="read_record_repair">Repair reading records</string>
     <string name="read_record_repair_summary">Merge records with a determined owner and remove reading-period records with identical fields</string>
     <string name="operation_failed">Operation failed</string>
+    <string name="do_not_remind_again_this_page">Don’t ask again until this screen is closed</string>
+    <string name="do_not_remind_again_long_term">Do not remind me again in the future</string>
+    <string name="restore_delete_confirmation">Restore delete confirmation</string>
+    <string name="restore_delete_confirmation_summary">Ask for confirmation before deleting reading records again</string>
+    <string name="delete_confirmation_restored">Delete confirmation restored</string>
+    <string name="read_record_deleted">Reading record deleted</string>
+    <string name="read_records_cleared">Reading records cleared</string>
+    <string name="read_records_merged">Reading records merged</string>
     <string name="merge_read_records_message">Merge selected reading records into “%1$s” by %2$s.</string>
     <string name="merge_read_record_candidate">%1$s / %2$s (%3$s)</string>
     <string name="no_merge_candidates">No other reading records can be merged</string>

+ 33 - 0
app/src/test/java/io/legado/app/data/repository/ReadRecordRepositoryTest.kt

@@ -208,6 +208,39 @@ class ReadRecordRepositoryTest {
         assertEquals(0, database.readRecordDao.getSessionsByBook(deviceId, targetName, author).size)
     }
 
+    @Test
+    fun `deleting one timeline session keeps other sessions for the same book`() = runBlocking {
+        val first = session(bookUrl = "https://book.example", start = 1_000, end = 11_000)
+        val second = session(bookUrl = "https://book.example", start = 1_311_000, end = 1_321_000)
+        val third = session(bookUrl = "https://book.example", start = 2_621_000, end = 2_631_000)
+        repository.saveReadSession(first)
+        repository.saveReadSession(second)
+        repository.saveReadSession(third)
+
+        assertEquals(true, repository.deleteSession(first))
+
+        val remaining = database.readRecordDao.getSessionsByBook(deviceId, targetName, author)
+        assertEquals(2, remaining.size)
+        assertEquals(setOf(1_311_000L, 2_621_000L), remaining.map { it.startTime }.toSet())
+    }
+
+    @Test
+    fun `deleting a merged timeline item removes only its continuous session group`() = runBlocking {
+        val first = session(bookUrl = "https://book.example", start = 1_000, end = 11_000)
+        val second = session(bookUrl = "https://book.example", start = 21_000, end = 31_000)
+        val separate = session(bookUrl = "https://book.example", start = 1_431_000, end = 1_441_000)
+        repository.saveReadSession(first)
+        repository.saveReadSession(second)
+        repository.saveReadSession(separate)
+
+        val mergedDisplayItem = database.readRecordDao.allSession.first { it.startTime == first.startTime }
+            .copy(endTime = second.endTime)
+        assertEquals(true, repository.deleteSession(mergedDisplayItem))
+
+        val remaining = database.readRecordDao.getSessionsByBook(deviceId, targetName, author)
+        assertEquals(listOf(1_431_000L), remaining.map { it.startTime })
+    }
+
     @Test
     fun `coexisting copies are timed independently while the work total is their sum`() =
         runBlocking {