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

fix(read-record): delete merged timeline sessions by identity

HapeLee 1 месяц назад
Родитель
Сommit
328ea7b3e5

+ 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)

+ 21 - 24
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
@@ -26,6 +27,7 @@ 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
 
@@ -64,6 +66,15 @@ class ReadRecordRepository(
         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)
 
@@ -441,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,
@@ -450,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(
@@ -462,8 +474,8 @@ class ReadRecordRepository(
                     date = dateString,
                     readTime = durationDelta,
                     readWords = wordsDelta,
-                    firstReadTime = session.startTime,
-                    lastReadTime = session.endTime
+                    firstReadTime = segmentStart,
+                    lastReadTime = segmentEnd
                 )
             )
         }
@@ -532,7 +544,7 @@ class ReadRecordRepository(
                 .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) {
+                        if (previous != null && current.startTime - previous.endTime <= CONTINUOUS_READ_SESSION_GAP_MILLIS) {
                             groups.last().add(current)
                         } else {
                             groups.add(mutableListOf(current))
@@ -549,14 +561,7 @@ class ReadRecordRepository(
                         }
                         .orEmpty()
                 }
-            val affectedDevices = dao.allSession.asSequence()
-                .filter {
-                    it.bookName == session.bookName &&
-                        it.bookAuthor == session.bookAuthor &&
-                        it.id in sessionGroup.map { grouped -> grouped.id }
-                }
-                .mapTo(linkedSetOf()) { it.deviceId }
-                .apply { addAll(dao.getReadRecordsByName(session.bookName, session.bookAuthor).map { it.deviceId }) }
+            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)
@@ -569,14 +574,6 @@ class ReadRecordRepository(
             val sessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
                 dao.getSessionsByBook(deviceId, session.bookName, session.bookAuthor)
             }
-            val daySessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
-                dao.getSessionsByBookAndDate(
-                    deviceId,
-                    session.bookName,
-                    session.bookAuthor,
-                    session.startTime.toDateString(),
-                )
-            }
             sessionGroup.forEach { dao.deleteSession(it) }
             affectedDevices.forEach { deviceId ->
                     rebuildDetailsAfterSessionMutation(
@@ -620,12 +617,12 @@ class ReadRecordRepository(
             if (readTime <= 0L && readWords <= 0L) {
                 oldDetail?.let { dao.deleteDetail(it) }
             } else {
-                val daySessions = newSessions.filter { it.startTime.toDateString() == date }
+                val dayBounds = newSessions.mapNotNull { it.boundsOnDate(date) }
                 dao.insertDetail((oldDetail ?: ReadRecordDetail(deviceId, bookName, bookAuthor, date)).copy(
                     readTime = readTime,
                     readWords = readWords,
-                    firstReadTime = daySessions.map { it.startTime }.filter { it > 0L }.minOrNull() ?: oldDetail?.firstReadTime ?: 0L,
-                    lastReadTime = daySessions.maxOfOrNull { it.endTime } ?: oldDetail?.lastReadTime ?: 0L,
+                    firstReadTime = dayBounds.minOfOrNull { it.first } ?: oldDetail?.firstReadTime ?: 0L,
+                    lastReadTime = dayBounds.maxOfOrNull { it.second } ?: oldDetail?.lastReadTime ?: 0L,
                 ))
             }
         }

+ 4 - 14
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -41,7 +41,6 @@ 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
@@ -199,7 +198,6 @@ fun ReadRecordScreen(
     }
 
     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) }
@@ -409,7 +407,6 @@ fun ReadRecordScreen(
                                 loadChapterTitle = loadChapterTitle,
                                 onBookClick = onBookClick,
                                 onConfirmDelete = onConfirmDelete,
-                                swipeResetKey = swipeResetKey,
                                 selectedItemKeys = selectedItemKeys,
                                 inSelectionMode = inSelectionMode,
                                 onToggleSelection = { key ->
@@ -520,7 +517,6 @@ fun ReadRecordScreen(
         data = pendingDeleteAction,
         onDismissRequest = {
             pendingDeleteAction = null
-            swipeResetKey++
         },
         title = stringResource(R.string.confirm_delete_read_record),
         content = { _ ->
@@ -528,6 +524,8 @@ fun ReadRecordScreen(
                 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 {
@@ -569,7 +567,6 @@ fun ReadRecordScreen(
         dismissText = stringResource(R.string.cancel),
         onDismiss = {
             pendingDeleteAction = null
-            swipeResetKey++
         }
     )
 
@@ -985,7 +982,6 @@ 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,
@@ -1027,7 +1023,6 @@ fun LazyListScope.renderListByMode(
                         itemContent(Modifier.animateItem())
                     } else {
                         SwipeActionContainer(
-                            resetKey = swipeResetKey,
                             modifier = Modifier.animateItem(),
                             startAction = SwipeAction(
                                 icon = Icons.Default.Delete,
@@ -1038,7 +1033,6 @@ fun LazyListScope.renderListByMode(
                                     }
                                 },
                                 contentDescription = deleteActionDescription
-                                ,resetAfterSwipe = false
                             )
                         ) {
                             itemContent(Modifier)
@@ -1077,18 +1071,16 @@ fun LazyListScope.renderListByMode(
                         itemContent(Modifier.animateItem())
                     } else {
                         SwipeActionContainer(
-                            resetKey = swipeResetKey,
                             modifier = Modifier.animateItem(),
                             startAction = SwipeAction(
                                 icon = Icons.Default.Delete,
                                 background = LegadoTheme.colorScheme.error,
                                 onSwipe = {
-                                    onConfirmDelete(1) {
+                                    onConfirmDelete(0) {
                                         onIntent(ReadRecordIntent.DeleteSession(session))
                                     }
                                 },
                                 contentDescription = deleteActionDescription
-                                ,resetAfterSwipe = false
                             )
                         ) {
                             itemContent(Modifier)
@@ -1125,7 +1117,6 @@ fun LazyListScope.renderListByMode(
                     itemContent(Modifier.animateItem())
                 } else {
                     SwipeActionContainer(
-                        resetKey = swipeResetKey,
                         modifier = Modifier.animateItem(),
                         startAction = SwipeAction(
                             icon = Icons.Default.Delete,
@@ -1135,8 +1126,7 @@ fun LazyListScope.renderListByMode(
                                     onIntent(ReadRecordIntent.DeleteRecord(record))
                                 }
                             },
-                                contentDescription = deleteActionDescription
-                                ,resetAfterSwipe = false
+                            contentDescription = deleteActionDescription
                         ),
                         endAction = SwipeAction(
                             icon = Icons.Default.Merge,

+ 4 - 6
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -8,6 +8,7 @@ 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
@@ -286,11 +287,8 @@ class ReadRecordViewModel(
     }
 
     private fun emitMutationResult(succeeded: Boolean, successMessage: Int) {
-        _effects.tryEmit(
-            ReadRecordEffect.ShowMessage(
-                if (succeeded) successMessage else R.string.operation_failed
-            )
-        )
+        // 未找到记录是过期列表项或空列表的正常竞态;异常由调用方报告。
+        if (succeeded) _effects.tryEmit(ReadRecordEffect.ShowMessage(successMessage))
     }
 
     private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
@@ -298,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]

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

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

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

@@ -16,8 +16,6 @@ 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
@@ -35,7 +33,6 @@ 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
 
 /** 滑动露出底块上下内缩量,让操作提示呈现为圆角矩形而不是整行色块。 */
@@ -54,24 +51,17 @@ 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) {
@@ -80,25 +70,19 @@ fun SwipeActionContainer(
         }
     }
 
-    LaunchedEffect(dismissState) {
-        snapshotFlow { dismissState.settledValue }.collectLatest { settledValue ->
-            when (settledValue) {
-                SwipeToDismissBoxValue.StartToEnd -> {
-                    currentStartAction?.onSwipe?.invoke()
-                    if (currentStartAction?.resetAfterSwipe != false) {
-                        dismissState.reset()
-                    }
-                }
-
-                SwipeToDismissBoxValue.EndToStart -> {
-                    currentEndAction?.onSwipe?.invoke()
-                    if (currentEndAction?.resetAfterSwipe != false) {
-                        dismissState.reset()
-                    }
-                }
+    LaunchedEffect(dismissState.settledValue) {
+        when (dismissState.settledValue) {
+            SwipeToDismissBoxValue.StartToEnd -> {
+                startAction?.onSwipe?.invoke()
+                dismissState.reset()
+            }
 
-                else -> {}
+            SwipeToDismissBoxValue.EndToStart -> {
+                endAction?.onSwipe?.invoke()
+                dismissState.reset()
             }
+
+            else -> {}
         }
     }
 

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

@@ -2696,6 +2696,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>

+ 1 - 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>

+ 1 - 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>

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

@@ -2702,6 +2702,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>

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

@@ -237,6 +237,9 @@ class ReadRecordRepositoryTest {
 
         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)
@@ -255,6 +258,8 @@ class ReadRecordRepositoryTest {
         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)
     }