Explorar el Código

Merge pull request #2314 from coastline-echo/fix/issue-2291-read-record-swipe

fix(read-record): 修复阅读记录删除与跨午夜时长统计
Kudomaga hace 1 mes
padre
commit
2ddf69e647

+ 2 - 0
app/src/main/java/io/legado/app/data/entities/readRecord/ReadRecordSession.kt

@@ -4,6 +4,8 @@ import androidx.room.ColumnInfo
 import androidx.room.Entity
 import androidx.room.PrimaryKey
 
+internal const val CONTINUOUS_READ_SESSION_GAP_MILLIS = 20 * 60 * 1000L
+
 @Entity(tableName = "readRecordSession")
 data class ReadRecordSession(
     @PrimaryKey(autoGenerate = true)

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

@@ -55,6 +55,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")

+ 188 - 112
app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt

@@ -13,6 +13,7 @@ import io.legado.app.data.entities.readRecord.ReadRecordRepairReport
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordTimeTotals
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.data.entities.readRecord.CONTINUOUS_READ_SESSION_GAP_MILLIS
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.domain.model.BookMatchKey
 import kotlinx.coroutines.Dispatchers
@@ -25,6 +26,8 @@ import kotlinx.coroutines.withContext
 import kotlinx.datetime.Instant
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toLocalDateTime
+import java.time.ZoneId
+import java.time.LocalDate
 import kotlin.math.max
 import kotlin.math.min
 
@@ -38,13 +41,54 @@ class ReadRecordRepository(
     private fun Long.toDateString(): String =
         Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
 
+    /** 将跨自然日的阅读会话按本地时区午夜拆分,时间线仍保留原始整段会话。 */
+    private fun ReadRecordSession.durationByDate(): Map<String, Long> {
+        if (endTime <= startTime) return emptyMap()
+        val zone = ZoneId.systemDefault()
+        var cursor = startTime
+        val result = linkedMapOf<String, Long>()
+        while (cursor < endTime) {
+            val date = java.time.Instant.ofEpochMilli(cursor).atZone(zone).toLocalDate()
+            val nextMidnight = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
+            val segmentEnd = minOf(endTime, nextMidnight)
+            result[date.toString()] = (result[date.toString()] ?: 0L) + (segmentEnd - cursor)
+            cursor = segmentEnd
+        }
+        return result
+    }
+
+    private fun List<ReadRecordSession>.durationByDate(): Map<String, Long> =
+        flatMap { session -> session.durationByDate().entries }
+            .groupingBy { it.key }
+            .fold(0L) { total, entry -> total + entry.value }
+
+    private fun List<ReadRecordSession>.wordsByStartDate(): Map<String, Long> =
+        groupBy { it.startTime.toDateString() }
+            .mapValues { (_, sessions) -> sessions.sumOf { it.words } }
+
+    private fun ReadRecordSession.boundsOnDate(date: String): Pair<Long, Long>? {
+        val zone = ZoneId.systemDefault()
+        val dayStart = LocalDate.parse(date).atStartOfDay(zone).toInstant().toEpochMilli()
+        val dayEnd = LocalDate.parse(date).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
+        val segmentStart = maxOf(startTime, dayStart)
+        val segmentEnd = minOf(endTime, dayEnd)
+        return if (segmentStart < segmentEnd) segmentStart to segmentEnd else null
+    }
+
     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)
+    }
+
     /**
      * 获取总阅读时长流
      */
@@ -355,8 +399,14 @@ class ReadRecordRepository(
 
             val segmentDuration = normalizedSession.endTime - normalizedSession.startTime
             dao.insertSession(normalizedSession)
-            val dateString = normalizedSession.startTime.toDateString()
-            updateReadRecordDetail(normalizedSession, segmentDuration, normalizedSession.words, dateString)
+            normalizedSession.durationByDate().forEach { (date, duration) ->
+                updateReadRecordDetail(
+                    normalizedSession,
+                    duration,
+                    if (date == normalizedSession.startTime.toDateString()) normalizedSession.words else 0L,
+                    date,
+                )
+            }
             updateReadRecord(normalizedSession, segmentDuration)
         }
     }
@@ -402,6 +452,7 @@ class ReadRecordRepository(
         dateString: String
     ) {
         if (durationDelta <= 0 && wordsDelta <= 0) return
+        val (segmentStart, segmentEnd) = session.boundsOnDate(dateString) ?: return
         val existingDetail = dao.getDetail(
             session.deviceId,
             session.bookName,
@@ -411,8 +462,8 @@ class ReadRecordRepository(
         if (existingDetail != null) {
             existingDetail.readTime += durationDelta
             existingDetail.readWords += wordsDelta
-            existingDetail.firstReadTime = minPositive(existingDetail.firstReadTime, session.startTime)
-            existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
+            existingDetail.firstReadTime = minPositive(existingDetail.firstReadTime, segmentStart)
+            existingDetail.lastReadTime = max(existingDetail.lastReadTime, segmentEnd)
             dao.insertDetail(existingDetail)
         } else {
             dao.insertDetail(
@@ -423,15 +474,18 @@ class ReadRecordRepository(
                     date = dateString,
                     readTime = durationDelta,
                     readWords = wordsDelta,
-                    firstReadTime = session.startTime,
-                    lastReadTime = session.endTime
+                    firstReadTime = segmentStart,
+                    lastReadTime = segmentEnd
                 )
             )
         }
     }
 
-    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,106 +513,118 @@ class ReadRecordRepository(
                     legacyReadTimes[deviceId] ?: 0L,
                 )
             }
+            existed
         }
     }
 
-    suspend fun deleteSession(session: ReadRecordSession) {
-        database.withTransaction {
-            val affectedDevices = dao.allSession.asSequence()
+    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 == session.startTime &&
-                        it.endTime == session.endTime &&
-                        it.words == session.words
+                        it.startTime.toDateString() == session.startTime.toDateString()
                 }
-                .mapTo(linkedSetOf()) { it.deviceId }
-                .apply { addAll(dao.getReadRecordsByName(session.bookName, session.bookAuthor).map { it.deviceId }) }
+                .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 <= CONTINUOUS_READ_SESSION_GAP_MILLIS) {
+                            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 = sessionGroup.mapTo(linkedSetOf()) { it.deviceId }
             val legacyReadTimes = affectedDevices.associateWith { deviceId ->
                 val record = dao.getReadRecord(deviceId, session.bookName, session.bookAuthor)
                 val sessionTime = dao.getSessionsByBook(deviceId, session.bookName, session.bookAuthor)
                     .sumOf { it.endTime - it.startTime }
                 ((record?.readTime ?: 0L) - sessionTime).coerceAtLeast(0L)
             }
-            val daySessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
-                dao.getSessionsByBookAndDate(
-                    deviceId,
-                    session.bookName,
-                    session.bookAuthor,
-                    session.startTime.toDateString(),
-                )
+            val detailsBeforeDelete = affectedDevices.associateWith { deviceId ->
+                dao.getDetailsByBook(deviceId, session.bookName, session.bookAuthor)
             }
-            dao.deleteSessionByIdentity(
-                session.bookName,
-                session.bookAuthor,
-                session.startTime,
-                session.endTime,
-                session.words,
-            )
-            val dateString = session.startTime.toDateString()
+            val sessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
+                dao.getSessionsByBook(deviceId, session.bookName, session.bookAuthor)
+            }
+            sessionGroup.forEach { dao.deleteSession(it) }
             affectedDevices.forEach { deviceId ->
-                    val record = ReadRecord(
-                        deviceId = deviceId,
-                        bookName = session.bookName,
-                        bookAuthor = session.bookAuthor,
-                    )
-                    val remainingSessions = dao.getSessionsByBookAndDate(
+                    rebuildDetailsAfterSessionMutation(
                         deviceId,
-                        record.bookName,
-                        record.bookAuthor,
-                        dateString,
+                        session.bookName,
+                        session.bookAuthor,
+                        sessionsBeforeDelete[deviceId].orEmpty(),
+                        detailsBeforeDelete[deviceId].orEmpty(),
                     )
-                    val detail = dao.getDetail(
-                        deviceId,
-                        record.bookName,
-                        record.bookAuthor,
-                        dateString,
-                    )
-                    val legacyDetailTime = ((detail?.readTime ?: 0L) -
-                        daySessionsBeforeDelete[deviceId].orEmpty().sumOf { it.endTime - it.startTime }).coerceAtLeast(0L)
-                    val legacyDetailWords = ((detail?.readWords ?: 0L) -
-                        daySessionsBeforeDelete[deviceId].orEmpty().sumOf { it.words }).coerceAtLeast(0L)
-                    if (remainingSessions.isEmpty()) {
-                        if (legacyDetailTime <= 0L && legacyDetailWords <= 0L) {
-                            detail?.let { dao.deleteDetail(it) }
-                        } else {
-                            dao.insertDetail(
-                                (detail ?: ReadRecordDetail(
-                                    deviceId = record.deviceId,
-                                    bookName = record.bookName,
-                                    bookAuthor = record.bookAuthor,
-                                    date = dateString,
-                                )).copy(
-                                    readTime = legacyDetailTime,
-                                    readWords = legacyDetailWords,
-                                )
-                            )
-                        }
-                    } else {
-                        dao.insertDetail(
-                            (detail ?: ReadRecordDetail(
-                                deviceId = record.deviceId,
-                                bookName = record.bookName,
-                                bookAuthor = record.bookAuthor,
-                                date = dateString,
-                            )).copy(
-                                readTime = legacyDetailTime + remainingSessions.sumOf { it.endTime - it.startTime },
-                                readWords = legacyDetailWords + remainingSessions.sumOf { it.words },
-                                firstReadTime = remainingSessions
-                                    .map { it.startTime }
-                                    .filter { it > 0L }
-                                    .minOrNull() ?: 0L,
-                                lastReadTime = remainingSessions.maxOf { it.endTime },
-                            )
-                        )
-                    }
                     updateReadRecordTotal(
                         deviceId,
-                        record.bookName,
-                        record.bookAuthor,
+                        session.bookName,
+                        session.bookAuthor,
                         legacyReadTimes[deviceId] ?: 0L,
                     )
                 }
+            sessionGroup.isNotEmpty()
+        }
+    }
+
+    private suspend fun rebuildDetailsAfterSessionMutation(
+        deviceId: String,
+        bookName: String,
+        bookAuthor: String,
+        oldSessions: List<ReadRecordSession>,
+        oldDetails: List<ReadRecordDetail>,
+    ) {
+        val newSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
+        val oldDurations = oldSessions.durationByDate()
+        val newDurations = newSessions.durationByDate()
+        val oldWords = oldSessions.wordsByStartDate()
+        val newWords = newSessions.wordsByStartDate()
+        val detailsByDate = oldDetails.associateBy { it.date }
+        val dates = (oldDurations.keys + newDurations.keys + oldDetails.map { it.date }).toSet()
+        dates.forEach { date ->
+            val oldDetail = detailsByDate[date]
+            val legacyTime = ((oldDetail?.readTime ?: 0L) - (oldDurations[date] ?: 0L)).coerceAtLeast(0L)
+            val legacyWords = ((oldDetail?.readWords ?: 0L) - (oldWords[date] ?: 0L)).coerceAtLeast(0L)
+            val readTime = legacyTime + (newDurations[date] ?: 0L)
+            val readWords = legacyWords + (newWords[date] ?: 0L)
+            if (readTime <= 0L && readWords <= 0L) {
+                oldDetail?.let { dao.deleteDetail(it) }
+            } else {
+                val dayBounds = newSessions.mapNotNull { it.boundsOnDate(date) }
+                dao.insertDetail((oldDetail ?: ReadRecordDetail(deviceId, bookName, bookAuthor, date)).copy(
+                    readTime = readTime,
+                    readWords = readWords,
+                    firstReadTime = dayBounds.minOfOrNull { it.first } ?: oldDetail?.firstReadTime ?: 0L,
+                    lastReadTime = dayBounds.maxOfOrNull { it.second } ?: oldDetail?.lastReadTime ?: 0L,
+                ))
+            }
         }
     }
 
@@ -605,19 +671,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
         }
     }
 
@@ -677,10 +749,14 @@ class ReadRecordRepository(
         val allDetails = targetDetails + sourceDetails.values.flatten()
         val originalSessions = targetSessions + sourceSessions.values.flatten()
         val sessionsByRecord = originalSessions.groupBy { Triple(it.deviceId, it.bookName, it.bookAuthor) }
-        val sessionsByDate = originalSessions
+        val durationsByRecord = sessionsByRecord.mapValues { it.value.durationByDate() }
+        val wordsByRecord = sessionsByRecord.mapValues { it.value.wordsByStartDate() }
+        val normalizedSessions = originalSessions
             .map { it.copy(deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor) }
             .distinctBy { listOf(it.bookName, it.bookAuthor, it.startTime, it.endTime, it.words) }
-            .groupBy { it.startTime.toDateString() }
+        val sessionsByDate = normalizedSessions
+            .flatMap { session -> session.durationByDate().keys.map { it to session } }
+            .groupBy({ it.first }, { it.second })
         val detailsByDate = allDetails.groupBy { it.date }
         dao.deleteDetailsByBook(target.deviceId, target.bookName, target.bookAuthor)
         sourceDetails.keys.forEach { dao.deleteDetailsByBook(it.deviceId, it.bookName, it.bookAuthor) }
@@ -688,15 +764,13 @@ class ReadRecordRepository(
             val sessions = sessionsByDate[date].orEmpty()
             val details = detailsByDate[date].orEmpty()
             val legacyTime = details.sumOf { detail ->
-                ReadRecordTimeTotals.legacy(detail.readTime, sessionsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]
-                    .orEmpty().filter { it.startTime.toDateString() == date }.sumOf { it.endTime - it.startTime })
+                ReadRecordTimeTotals.legacy(detail.readTime, durationsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]?.get(date) ?: 0L)
             }
             val legacyWords = details.sumOf { detail ->
-                (detail.readWords - sessionsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]
-                    .orEmpty().filter { it.startTime.toDateString() == date }.sumOf { it.words }).coerceAtLeast(0L)
+                (detail.readWords - (wordsByRecord[Triple(detail.deviceId, detail.bookName, detail.bookAuthor)]?.get(date) ?: 0L)).coerceAtLeast(0L)
             }
-            val sessionTime = sessions.sumOf { it.endTime - it.startTime }
-            val sessionWords = sessions.sumOf { it.words }
+            val sessionTime = sessions.sumOf { it.durationByDate()[date] ?: 0L }
+            val sessionWords = sessions.filter { it.startTime.toDateString() == date }.sumOf { it.words }
             if (sessionTime + legacyTime > 0L || sessionWords + legacyWords > 0L) {
                 dao.insertDetail(ReadRecordDetail(
                     deviceId = target.deviceId, bookName = target.bookName, bookAuthor = target.bookAuthor, date = date,
@@ -746,19 +820,21 @@ class ReadRecordRepository(
         oldDetails: List<ReadRecordDetail>,
     ) {
         val newSessions = dao.getSessionsByBook(deviceId, bookName, bookAuthor)
-        val oldSessionsByDate = oldSessions.groupBy { it.startTime.toDateString() }
-        val newSessionsByDate = newSessions.groupBy { it.startTime.toDateString() }
+        val oldSessionsByDate = oldSessions.durationByDate()
+        val newSessionsByDate = newSessions.durationByDate()
+        val oldWordsByDate = oldSessions.wordsByStartDate()
+        val newWordsByDate = newSessions.wordsByStartDate()
         val detailsByDate = oldDetails.associateBy { it.date }
         val dates = (oldSessionsByDate.keys + newSessionsByDate.keys + detailsByDate.keys)
         dates.forEach { date ->
             val oldDetail = detailsByDate[date]
-            val oldSessionTime = oldSessionsByDate[date].orEmpty().sumOf { it.endTime - it.startTime }
-            val oldSessionWords = oldSessionsByDate[date].orEmpty().sumOf { it.words }
+            val oldSessionTime = oldSessionsByDate[date] ?: 0L
+            val oldSessionWords = oldWordsByDate[date] ?: 0L
             val legacyTime = oldDetail?.let { (it.readTime - oldSessionTime).coerceAtLeast(0L) } ?: 0L
             val legacyWords = oldDetail?.let { (it.readWords - oldSessionWords).coerceAtLeast(0L) } ?: 0L
-            val sessions = newSessionsByDate[date].orEmpty()
-            val readTime = sessions.sumOf { it.endTime - it.startTime } + legacyTime
-            val readWords = sessions.sumOf { it.words } + legacyWords
+            val readTime = (newSessionsByDate[date] ?: 0L) + legacyTime
+            val readWords = (newWordsByDate[date] ?: 0L) + legacyWords
+            val sessions = newSessions.filter { it.startTime.toDateString() == date }
             if (readTime <= 0L && readWords <= 0L) {
                 oldDetail?.let { dao.deleteDetail(it) }
             } else {
@@ -888,16 +964,16 @@ class ReadRecordRepository(
             }
             dao.allDetail.forEach { detail ->
                 val sessions = dao.getSessionsByBook(detail.deviceId, detail.bookName, detail.bookAuthor)
-                    .filter {
-                        it.startTime.toDateString() == detail.date
-                    }
-                if (sessions.isEmpty()) return@forEach
+                val sessionTime = sessions.durationByDate()[detail.date] ?: 0L
+                val sessionWords = sessions.wordsByStartDate()[detail.date] ?: 0L
+                if (sessionTime <= 0L && sessionWords <= 0L) return@forEach
                 dao.insertDetail(detail.copy(
-                    readTime = maxOf(detail.readTime, sessions.sumOf { it.endTime - it.startTime }),
-                    readWords = maxOf(detail.readWords, sessions.sumOf { it.words }),
+                    readTime = maxOf(detail.readTime, sessionTime),
+                    readWords = maxOf(detail.readWords, sessionWords),
                     firstReadTime = minPositive(
                         detail.firstReadTime,
-                        sessions.map { it.startTime }.filter { it > 0L }.minOrNull() ?: 0L,
+                        sessions.filter { it.startTime.toDateString() == detail.date }
+                            .map { it.startTime }.filter { it > 0L }.minOrNull() ?: 0L,
                     ),
                     lastReadTime = maxOf(detail.lastReadTime, sessions.maxOf { it.endTime }),
                 ))

+ 212 - 161
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -33,7 +33,7 @@ import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Timeline
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Surface
@@ -41,6 +41,7 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.produceState
 import androidx.compose.runtime.remember
@@ -54,6 +55,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
@@ -77,13 +79,11 @@ 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.CollapsibleHeader
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
-import io.legado.app.ui.widget.components.checkBox.AppCheckbox
 import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.heatmap.HeatmapCalendarEndAction
 import io.legado.app.ui.widget.components.heatmap.HeatmapCalendarStartAction
@@ -152,6 +152,9 @@ fun ReadRecordRouteScreen(
         onScanRepair = { viewModel.onIntent(ReadRecordIntent.ScanRepair) },
         onRepairDatabase = { viewModel.onIntent(ReadRecordIntent.RepairDatabase) },
         effects = viewModel.effects,
+        onSetSkipDeleteConfirm = { enabled ->
+            viewModel.onIntent(ReadRecordIntent.SetSkipDeleteConfirm(enabled))
+        },
     )
 }
 
@@ -169,11 +172,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
@@ -189,19 +193,19 @@ 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 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
@@ -468,6 +472,7 @@ fun ReadRecordScreen(
         show = showActionsSheet,
         showCalendar = showCalendar,
         readRecordEnabled = readRecordEnabled,
+        skipDeleteConfirm = state.skipDeleteConfirm,
         onDismissRequest = { showActionsSheet = false },
         onToggleCalendar = {
             showCalendar = !showCalendar
@@ -484,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 ->
@@ -504,16 +514,21 @@ 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
+        },
         title = stringResource(R.string.confirm_delete_read_record),
         content = { _ ->
             Column {
                 AppText(
                     if (pendingDeleteCount == -1) {
                         stringResource(R.string.clear_read_records_message)
+                    } else if (pendingDeleteCount == 0) {
+                        stringResource(R.string.delete_reading_period_message)
                     } else if (pendingDeleteCount > 1) {
                         stringResource(R.string.delete_selected_read_records_message, pendingDeleteCount)
                     } else {
@@ -523,9 +538,21 @@ fun ReadRecordScreen(
                 if (pendingDeleteCount != -1) {
                     Spacer(modifier = Modifier.height(8.dp))
                     CheckboxItem(
-                        title = stringResource(R.string.do_not_remind_again),
+                        title = stringResource(R.string.do_not_remind_again_this_page),
                         checked = skipDeleteConfirmTemp,
-                        onCheckedChange = { skipDeleteConfirmTemp = it }
+                        onCheckedChange = { checked ->
+                            skipDeleteConfirmTemp = checked
+                            if (checked) skipDeleteConfirmLongTermTemp = false
+                        },
+                    )
+                    Spacer(modifier = Modifier.height(8.dp))
+                    CheckboxItem(
+                        title = stringResource(R.string.do_not_remind_again_long_term),
+                        checked = skipDeleteConfirmLongTermTemp,
+                        onCheckedChange = { checked ->
+                            skipDeleteConfirmLongTermTemp = checked
+                            if (checked) skipDeleteConfirmTemp = false
+                        },
                     )
                 }
             }
@@ -535,7 +562,10 @@ fun ReadRecordScreen(
             action.invoke()
             pendingDeleteAction = null
             if (pendingDeleteCount != -1) {
-                skipDeleteConfirm = skipDeleteConfirmTemp
+                skipDeleteConfirmForPage = skipDeleteConfirmTemp || skipDeleteConfirmLongTermTemp
+                if (skipDeleteConfirmLongTermTemp) {
+                    onSetSkipDeleteConfirm(true)
+                }
             }
         },
         dismissText = stringResource(R.string.cancel),
@@ -622,42 +652,18 @@ fun ReadRecordScreen(
                         val candidateKey = candidate.mergeKey()
                         val isChecked = selectedMergeKeys.contains(candidateKey)
 
-                        GlassCard(
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(vertical = 4.dp),
-                        ) {
-                            Box(modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(12.dp)) {
-                                Column(modifier = Modifier
-                                    .fillMaxWidth()
-                                    .padding(end = 48.dp)) {
-                                    AppText(text = candidate.bookName)
-                                    AppText(
-                                        text = author,
-                                        style = LegadoTheme.typography.bodySmall,
-                                        color = LegadoTheme.colorScheme.onSurfaceVariant,
-                                    )
-                                    AppText(
-                                        text = formatDuring(candidate.readTime),
-                                        style = LegadoTheme.typography.bodySmall,
-                                        color = LegadoTheme.colorScheme.onSurfaceVariant,
-                                    )
+                        CheckboxItem(
+                            title = candidate.bookName,
+                            description = "$author · ${formatDuring(candidate.readTime)}",
+                            checked = isChecked,
+                            onCheckedChange = { checked ->
+                                selectedMergeKeys = if (checked) {
+                                    selectedMergeKeys + candidateKey
+                                } else {
+                                    selectedMergeKeys - candidateKey
                                 }
-                                AppCheckbox(
-                                    checked = isChecked,
-                                    onCheckedChange = { checked ->
-                                        selectedMergeKeys = if (checked) {
-                                            selectedMergeKeys + candidateKey
-                                        } else {
-                                            selectedMergeKeys - candidateKey
-                                        }
-                                    },
-                                    modifier = Modifier.align(Alignment.CenterEnd),
-                                )
-                            }
-                        }
+                            },
+                        )
                     }
                 }
             }
@@ -682,12 +688,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,
@@ -726,6 +734,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),
@@ -939,49 +955,65 @@ fun LazyListScope.renderListByMode(
         DisplayMode.AGGREGATE -> {
             state.groupedRecords.forEach { (date, details) ->
                 item(key = "header_$date") {
-                    DateHeader(date, details.sumOf { it.readTime })
-                }
-                items(
-                    items = details,
-                    key = { "agg_item_${date}|${it.deviceId}_${it.bookName}_${it.bookAuthor}_${it.date}" }
-                ) { detail ->
-                    val itemKey = detail.selectionKey()
-                    val isSelected = selectedItemKeys.contains(itemKey)
-                    val itemContent: @Composable (Modifier) -> Unit = { modifier ->
-                        ReadRecordItem(
-                            detail,
-                            loadBookCover,
-                            onClick = {
+                    GlassCard(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .animateItem()
+                            .adaptiveHorizontalPadding()
+                            .padding(vertical = 8.dp),
+                        cornerRadius = 12.dp,
+                        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+                    ) {
+                        DateHeader(date, details.sumOf { it.readTime })
+                        HorizontalDivider(
+                            color = LegadoTheme.colorScheme.surface,
+                        )
+                        details.forEachIndexed { index, detail ->
+                            if (index > 0) {
+                                HorizontalDivider(
+                                    color = LegadoTheme.colorScheme.surface,
+                                )
+                            }
+                            key(detail.selectionKey()) {
+                                val itemKey = detail.selectionKey()
+                                val isSelected = selectedItemKeys.contains(itemKey)
+                                val itemContent: @Composable (Modifier) -> Unit = { modifier ->
+                                    ReadRecordItem(
+                                        detail,
+                                        loadBookCover,
+                                        onClick = {
+                                            if (inSelectionMode) {
+                                                onToggleSelection(itemKey)
+                                            } else {
+                                                onBookClick(detail.bookName, detail.bookAuthor)
+                                            }
+                                        },
+                                        onLongClick = { onEnterSelection(itemKey) },
+                                        inSelectionMode = inSelectionMode,
+                                        isSelected = isSelected,
+                                        modifier = modifier,
+                                    )
+                                }
+                                val deleteActionDescription = stringResource(R.string.del_read_record)
                                 if (inSelectionMode) {
-                                    onToggleSelection(itemKey)
+                                    itemContent(Modifier)
                                 } else {
-                                    onBookClick(detail.bookName, detail.bookAuthor)
-                                }
-                            },
-                            onLongClick = { onEnterSelection(itemKey) },
-                            inSelectionMode = inSelectionMode,
-                            isSelected = isSelected,
-                            modifier = modifier
-                        )
-                    }
-                    val deleteActionDescription = stringResource(R.string.del_read_record)
-                    if (inSelectionMode) {
-                        itemContent(Modifier.animateItem())
-                    } else {
-                        SwipeActionContainer(
-                            modifier = Modifier.animateItem(),
-                            startAction = SwipeAction(
-                                icon = Icons.Default.Delete,
-                                background = LegadoTheme.colorScheme.error,
-                                onSwipe = {
-                                    onConfirmDelete(1) {
-                                        onIntent(ReadRecordIntent.DeleteDetail(detail))
+                                    SwipeActionContainer(
+                                        startAction = SwipeAction(
+                                            icon = Icons.Default.Delete,
+                                            background = LegadoTheme.colorScheme.error,
+                                            onSwipe = {
+                                                onConfirmDelete(1) {
+                                                    onIntent(ReadRecordIntent.DeleteDetail(detail))
+                                                }
+                                            },
+                                            contentDescription = deleteActionDescription,
+                                        )
+                                    ) {
+                                        itemContent(Modifier)
                                     }
-                                },
-                                contentDescription = deleteActionDescription
-                            )
-                        ) {
-                            itemContent(Modifier)
+                                }
+                            }
                         }
                     }
                 }
@@ -990,46 +1022,62 @@ fun LazyListScope.renderListByMode(
 
         DisplayMode.TIMELINE -> {
             state.timelineRecords.forEach { (date, sessions) ->
-                item(key = "timeline_header_$date") { DateHeader(date) }
-                items(items = sessions, key = { "timeline_item_${date}|${it.id}" }) { session ->
-                    val itemKey = session.selectionKey()
-                    val isSelected = selectedItemKeys.contains(itemKey)
-                    val itemContent: @Composable (Modifier) -> Unit = { modifier ->
-                        TimelineSessionItem(
-                            item = TimelineItem(session, true),
-                            onBookClick = { bookName, bookAuthor ->
+                item(key = "timeline_header_$date") {
+                    GlassCard(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .animateItem()
+                            .adaptiveHorizontalPadding()
+                            .padding(vertical = 8.dp),
+                        cornerRadius = 12.dp,
+                        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+                    ) {
+                        DateHeader(date)
+                        HorizontalDivider(
+                            color = LegadoTheme.colorScheme.surface,
+                        )
+                        sessions.forEach { session ->
+                            key(session.id) {
+                                val itemKey = session.selectionKey()
+                                val isSelected = selectedItemKeys.contains(itemKey)
+                                val itemContent: @Composable (Modifier) -> Unit = { modifier ->
+                                    TimelineSessionItem(
+                                        item = TimelineItem(session, true),
+                                        onBookClick = { bookName, bookAuthor ->
+                                            if (inSelectionMode) {
+                                                onToggleSelection(itemKey)
+                                            } else {
+                                                onBookClick(bookName, bookAuthor)
+                                            }
+                                        },
+                                        onLongClick = { onEnterSelection(itemKey) },
+                                        inSelectionMode = inSelectionMode,
+                                        isSelected = isSelected,
+                                        loadBookCover = loadBookCover,
+                                        loadChapterTitle = loadChapterTitle,
+                                        modifier = modifier,
+                                    )
+                                }
+                                val deleteActionDescription = stringResource(R.string.del_read_record)
                                 if (inSelectionMode) {
-                                    onToggleSelection(itemKey)
+                                    itemContent(Modifier)
                                 } else {
-                                    onBookClick(bookName, bookAuthor)
-                                }
-                            },
-                            onLongClick = { onEnterSelection(itemKey) },
-                            inSelectionMode = inSelectionMode,
-                            isSelected = isSelected,
-                            loadBookCover = loadBookCover,
-                            loadChapterTitle = loadChapterTitle,
-                            modifier = modifier
-                        )
-                    }
-                    val deleteActionDescription = stringResource(R.string.del_read_record)
-                    if (inSelectionMode) {
-                        itemContent(Modifier.animateItem())
-                    } else {
-                        SwipeActionContainer(
-                            modifier = Modifier.animateItem(),
-                            startAction = SwipeAction(
-                                icon = Icons.Default.Delete,
-                                background = LegadoTheme.colorScheme.error,
-                                onSwipe = {
-                                    onConfirmDelete(1) {
-                                        onIntent(ReadRecordIntent.DeleteSession(session))
+                                    SwipeActionContainer(
+                                        startAction = SwipeAction(
+                                            icon = Icons.Default.Delete,
+                                            background = LegadoTheme.colorScheme.error,
+                                            onSwipe = {
+                                                onConfirmDelete(0) {
+                                                    onIntent(ReadRecordIntent.DeleteSession(session))
+                                                }
+                                            },
+                                            contentDescription = deleteActionDescription,
+                                        )
+                                    ) {
+                                        itemContent(Modifier)
                                     }
-                                },
-                                contentDescription = deleteActionDescription
-                            )
-                        ) {
-                            itemContent(Modifier)
+                                }
+                            }
                         }
                     }
                 }
@@ -1186,11 +1234,11 @@ fun LatestReadItem(
                     ),
                 // 脱敏时整行不给:这里拼的是阅读时长 + 最后读到的章节(内容级信息)
                 text = if (locked) AnnotatedString("") else buildAnnotatedString {
-                    withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.outline)) {
+                    withStyle(style = SpanStyle(color = LegadoTheme.colorScheme.outline)) {
                         append(formatDuring(record.readTime))
                         append(" • ")
                     }
-                    withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
+                    withStyle(style = SpanStyle(color = LegadoTheme.colorScheme.primary)) {
                         append(lastReadText)
                     }
                 },
@@ -1246,11 +1294,11 @@ fun TimelineSessionItem(
 
     val nodeRadius = 4.dp
     val lineWidth = 2.dp
-    val timelineX = 24.dp
+    val timelineX = 16.dp
     val contentPaddingStart = 32.dp
 
-    val lineColor = MaterialTheme.colorScheme.surfaceContainerHigh
-    val nodeColor = MaterialTheme.colorScheme.primary
+    val lineColor = LegadoTheme.colorScheme.surface
+    val nodeColor = LegadoTheme.colorScheme.primary
 
     Box(
         modifier = modifier
@@ -1293,7 +1341,7 @@ fun TimelineSessionItem(
         Row(
             modifier = Modifier
                 .fillMaxWidth()
-                .padding(start = contentPaddingStart, end = 16.dp, top = 8.dp, bottom = 8.dp),
+                .padding(start = contentPaddingStart, end = 16.dp, top = 12.dp, bottom = 12.dp),
             verticalAlignment = Alignment.CenterVertically
         ) {
             Column(
@@ -1302,7 +1350,7 @@ fun TimelineSessionItem(
             ) {
                 AppText(
                     text = endTimeText,
-                    style = LegadoTheme.typography.bodySmall
+                    style = LegadoTheme.typography.labelSmallEmphasized
                 )
             }
 
@@ -1338,7 +1386,7 @@ fun TimelineSessionItem(
                     AppText(
                         text = if (locked) "" else author,
                         style = LegadoTheme.typography.bodySmall,
-                        color = MaterialTheme.colorScheme.outline,
+                        color = LegadoTheme.colorScheme.outline,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis
                     )
@@ -1346,7 +1394,7 @@ fun TimelineSessionItem(
                     AppText(
                         text = if (locked) "" else chapterTitle.orEmpty(),
                         style = LegadoTheme.typography.labelSmall,
-                        color = MaterialTheme.colorScheme.outline,
+                        color = LegadoTheme.colorScheme.outline,
                         maxLines = 1,
                         overflow = TextOverflow.Ellipsis
                     )
@@ -1398,7 +1446,7 @@ fun ReadRecordItem(
                 contentDescription = if (locked) "" else itemDescription
                 role = Role.Button
             }
-            .adaptiveHorizontalPadding(vertical = 8.dp),
+            .padding(all = 12.dp),
         verticalAlignment = Alignment.CenterVertically
     ) {
         if (locked) {
@@ -1427,12 +1475,15 @@ fun ReadRecordItem(
             AppText(
                 text = if (locked) "" else detail.bookName,
                 style = LegadoTheme.typography.titleMedium,
-                maxLines = 1
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
             )
             AppText(
                 text = if (locked) "" else author,
                 style = LegadoTheme.typography.bodySmall,
-                color = MaterialTheme.colorScheme.outline,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
             )
             Spacer(modifier = Modifier.height(8.dp))
             AppText(
@@ -1440,8 +1491,10 @@ fun ReadRecordItem(
                     R.string.reading_time_with_value,
                     formatDuring(detail.readTime)
                 ),
-                color = MaterialTheme.colorScheme.outline,
-                style = LegadoTheme.typography.labelSmall
+                color = LegadoTheme.colorScheme.primary,
+                style = LegadoTheme.typography.labelMedium,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
             )
         }
     }
@@ -1452,29 +1505,27 @@ fun DateHeader(
     date: String,
     dailyTotalTime: Long? = null
 ) {
-    CollapsibleHeader(
-        modifier = Modifier.adaptiveHorizontalPadding(),
-        showIcon = false,
-        isCollapsed = false,
-        onToggle = { },
-        title = "",
-        titleContent = {
-            val dateText = formatFriendlyDate(date)
-            AppText(
-                text = dateText,
-                style = LegadoTheme.typography.bodyMedium,
-                fontWeight = FontWeight.Bold,
-                color = LegadoTheme.colorScheme.primary
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(all = 12.dp),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        AppText(
+            text = formatFriendlyDate(date),
+            modifier = Modifier.weight(1f),
+            style = LegadoTheme.typography.titleSmallEmphasized,
+            color = LegadoTheme.colorScheme.primary,
+            maxLines = 1,
+            overflow = TextOverflow.Ellipsis,
+        )
+        dailyTotalTime?.let { total ->
+            TextCard(
+                text = stringResource(R.string.read_duration_done, formatDuring(total))
             )
-            dailyTotalTime?.let { total ->
-                AppText(
-                    text = stringResource(R.string.read_duration_done, formatDuring(total)),
-                    style = LegadoTheme.typography.bodySmall,
-                    color = LegadoTheme.colorScheme.onSurface
-                )
-            }
         }
-    )
+    }
 }
 
 @Composable
@@ -1510,7 +1561,7 @@ fun ReadingSummaryCard(
                 role = Role.Button
             }
             .adaptiveHorizontalPadding(vertical = 8.dp),
-        containerColor = LegadoTheme.colorScheme.surfaceContainer
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow
     ) {
         Row(
             modifier = Modifier

+ 82 - 19
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -2,10 +2,13 @@ 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
+import io.legado.app.data.entities.readRecord.CONTINUOUS_READ_SESSION_GAP_MILLIS
 import io.legado.app.data.entities.readRecord.ReadRecordRepairReport
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.repository.SettingsRepository
@@ -47,6 +50,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 +91,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
@@ -116,11 +122,10 @@ class ReadRecordViewModel(
             .mapValues { it.value.size }
 
         val dailyTimes = data.sessions
-            .groupBy { it.startTime.toDateString() }
+            .flatMap { session -> session.durationByDate().entries }
+            .groupingBy { it.key }
+            .fold(0L) { total, entry -> total + entry.value }
             .mapKeys { LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE) }
-            .mapValues { (_, sessions) ->
-                sessions.sumOf { (it.endTime - it.startTime).coerceAtLeast(0L) }
-            }
 
         val filteredDetails = data.details.filter { detail ->
             dateStr == null || detail.date == dateStr
@@ -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,62 @@ 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) {
+        // 未找到记录是过期列表项或空列表的正常竞态;异常由调用方报告。
+        if (succeeded) _effects.tryEmit(ReadRecordEffect.ShowMessage(successMessage))
     }
 
     private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
@@ -249,7 +296,7 @@ class ReadRecordViewModel(
         val mergedList = mutableListOf<ReadRecordSession>()
         mergedList.add(sessions.first().copy())
 
-        val gapLimit = 20 * 60 * 1000L
+        val gapLimit = CONTINUOUS_READ_SESSION_GAP_MILLIS
 
         for (i in 1 until sessions.size) {
             val current = sessions[i]
@@ -280,14 +327,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)) }
         }
     }
 
@@ -300,6 +346,21 @@ class ReadRecordViewModel(
 
     private fun Long.toDateString(): String =
         Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()).toLocalDate().toString()
+
+    private fun ReadRecordSession.durationByDate(): Map<String, Long> {
+        if (endTime <= startTime) return emptyMap()
+        val zone = ZoneId.systemDefault()
+        var cursor = startTime
+        val result = linkedMapOf<String, Long>()
+        while (cursor < endTime) {
+            val date = java.time.Instant.ofEpochMilli(cursor).atZone(zone).toLocalDate()
+            val nextMidnight = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
+            val segmentEnd = minOf(endTime, nextMidnight)
+            result[date.toString()] = (result[date.toString()] ?: 0L) + (segmentEnd - cursor)
+            cursor = segmentEnd
+        }
+        return result
+    }
 }
 
 sealed interface ReadRecordIntent {
@@ -311,6 +372,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 +381,5 @@ sealed interface ReadRecordIntent {
 }
 
 sealed interface ReadRecordEffect {
-    data class ShowError(val message: String) : ReadRecordEffect
+    data class ShowMessage(@StringRes val messageRes: Int) : ReadRecordEffect
 }

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

@@ -94,4 +94,3 @@ fun rememberPrivateLockedRecords(records: List<PrivateRecordKey>): Set<PrivateRe
         }
     }
 }
-

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

@@ -317,4 +317,3 @@ fun PrivateLockedPage(
         )
     }
 }
-

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

@@ -2747,6 +2747,7 @@
     <string name="long_press_select_mode">点按项目选择,返回退出</string>
     <string name="confirm_delete_read_record">删除阅读记录?</string>
     <string name="delete_read_record_message">确定要删除这条记录吗?</string>
+    <string name="delete_reading_period_message">确定要删除这个阅读时段吗?该行连续的所有会话(包括其他设备的会话)都会被删除。</string>
     <string name="delete_selected_read_records_message">确定要删除选中的 %1$d 条阅读记录吗?</string>
     <string name="clear_read_records">清空阅读记录</string>
     <string name="clear_read_records_summary">删除全部阅读记录、每日明细和时间线阅读时段记录</string>
@@ -2769,6 +2770,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>

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

@@ -2565,6 +2565,7 @@
     <string name="long_press_select_mode">點按項目選擇,返回退出</string>
     <string name="confirm_delete_read_record">刪除閱讀記錄?</string>
     <string name="delete_read_record_message">確定要刪除這條記錄嗎?</string>
+    <string name="delete_reading_period_message">確定要刪除這個閱讀時段嗎?該行連續的所有會話(包括其他裝置的會話)都會被刪除。</string>
     <string name="delete_selected_read_records_message">確定要刪除所選的 %1$d 條閱讀記錄嗎?</string>
     <string name="clear_read_records">清空閱讀記錄</string>
     <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線閱讀時段記錄</string>
@@ -2587,6 +2588,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>

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

@@ -2570,6 +2570,7 @@
     <string name="long_press_select_mode">點按項目選擇,返回退出</string>
     <string name="confirm_delete_read_record">刪除閱讀記錄?</string>
     <string name="delete_read_record_message">確定要刪除這條記錄嗎?</string>
+    <string name="delete_reading_period_message">確定要刪除這個閱讀時段嗎?該列連續的所有工作階段(包括其他裝置的工作階段)都會被刪除。</string>
     <string name="delete_selected_read_records_message">確定要刪除所選的 %1$d 筆閱讀記錄嗎?</string>
     <string name="clear_read_records">清空閱讀記錄</string>
     <string name="clear_read_records_summary">刪除全部閱讀記錄、每日明細和時間線閱讀時段記錄</string>
@@ -2592,6 +2593,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>

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

@@ -2753,6 +2753,7 @@
     <string name="long_press_select_mode">Tap items to select, back to exit</string>
     <string name="confirm_delete_read_record">Delete reading record?</string>
     <string name="delete_read_record_message">Delete this reading record?</string>
+    <string name="delete_reading_period_message">Delete this reading period? All continuous sessions in this row, including sessions from other devices, will be removed.</string>
     <string name="delete_selected_read_records_message">Delete %1$d selected reading records?</string>
     <string name="clear_read_records">Clear reading records</string>
     <string name="clear_read_records_summary">Delete all reading records, daily details, and timeline reading-period records</string>
@@ -2775,6 +2776,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>

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

@@ -20,6 +20,8 @@ import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
 import org.robolectric.RuntimeEnvironment
 import org.robolectric.annotation.Config
+import java.time.ZoneId
+import java.time.ZonedDateTime
 
 @RunWith(RobolectricTestRunner::class)
 @Config(application = Application::class, sdk = [35])
@@ -208,6 +210,72 @@ 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)
+        // 时间线保留首段的行 ID,但展示用 endTime 与数据库首段不同。
+        assertEquals(true, mergedDisplayItem.id > 0L)
+        assertEquals(true, mergedDisplayItem.endTime != first.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 `cross midnight session splits daily read time without splitting the session`() = runBlocking {
+        val zone = ZoneId.systemDefault()
+        val midnight = ZonedDateTime.of(2026, 9, 23, 0, 0, 0, 0, zone).toInstant().toEpochMilli()
+        val record = session(bookUrl = "https://book.example", start = midnight - 120_000, end = midnight + 480_000)
+
+        repository.saveReadSession(record)
+
+        val previousDay = java.time.Instant.ofEpochMilli(record.startTime).atZone(zone).toLocalDate().toString()
+        val nextDay = java.time.Instant.ofEpochMilli(record.endTime).atZone(zone).toLocalDate().toString()
+        assertEquals(120_000L, database.readRecordDao.getDetail(deviceId, targetName, author, previousDay)?.readTime)
+        assertEquals(480_000L, database.readRecordDao.getDetail(deviceId, targetName, author, nextDay)?.readTime)
+        assertEquals(midnight, database.readRecordDao.getDetail(deviceId, targetName, author, nextDay)?.firstReadTime)
+        assertEquals(record.endTime, database.readRecordDao.getDetail(deviceId, targetName, author, nextDay)?.lastReadTime)
+        assertEquals(1, database.readRecordDao.getSessionsByBook(deviceId, targetName, author).size)
+    }
+
+    @Test
+    fun `deleting cross midnight session clears both daily details`() = runBlocking {
+        val zone = ZoneId.systemDefault()
+        val midnight = ZonedDateTime.of(2026, 9, 24, 0, 0, 0, 0, zone).toInstant().toEpochMilli()
+        val record = session(bookUrl = "https://book.example", start = midnight - 120_000, end = midnight + 480_000)
+        repository.saveReadSession(record)
+
+        assertEquals(true, repository.deleteSession(database.readRecordDao.allSession.single()))
+
+        assertEquals(0, database.readRecordDao.allDetail.size)
+        assertEquals(0, database.readRecordDao.allSession.size)
+    }
+
     @Test
     fun `coexisting copies are timed independently while the work total is their sum`() =
         runBlocking {