|
|
@@ -25,6 +25,7 @@ import kotlinx.coroutines.withContext
|
|
|
import kotlinx.datetime.Instant
|
|
|
import kotlinx.datetime.TimeZone
|
|
|
import kotlinx.datetime.toLocalDateTime
|
|
|
+import java.time.ZoneId
|
|
|
import kotlin.math.max
|
|
|
import kotlin.math.min
|
|
|
|
|
|
@@ -38,6 +39,31 @@ 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 } }
|
|
|
+
|
|
|
val readRecordEnabled: Flow<Boolean> =
|
|
|
localPreferencesRepository.getPreference(LocalPreferencesKeys.ENABLE_READ_RECORD, true)
|
|
|
|
|
|
@@ -362,8 +388,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)
|
|
|
}
|
|
|
}
|
|
|
@@ -531,6 +563,12 @@ class ReadRecordRepository(
|
|
|
.sumOf { it.endTime - it.startTime }
|
|
|
((record?.readTime ?: 0L) - sessionTime).coerceAtLeast(0L)
|
|
|
}
|
|
|
+ val detailsBeforeDelete = affectedDevices.associateWith { deviceId ->
|
|
|
+ dao.getDetailsByBook(deviceId, session.bookName, session.bookAuthor)
|
|
|
+ }
|
|
|
+ val sessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
|
|
|
+ dao.getSessionsByBook(deviceId, session.bookName, session.bookAuthor)
|
|
|
+ }
|
|
|
val daySessionsBeforeDelete = affectedDevices.associateWith { deviceId ->
|
|
|
dao.getSessionsByBookAndDate(
|
|
|
deviceId,
|
|
|
@@ -540,67 +578,18 @@ class ReadRecordRepository(
|
|
|
)
|
|
|
}
|
|
|
sessionGroup.forEach { dao.deleteSession(it) }
|
|
|
- val dateString = session.startTime.toDateString()
|
|
|
affectedDevices.forEach { deviceId ->
|
|
|
- val record = ReadRecord(
|
|
|
- deviceId = deviceId,
|
|
|
- bookName = session.bookName,
|
|
|
- bookAuthor = session.bookAuthor,
|
|
|
- )
|
|
|
- val remainingSessions = dao.getSessionsByBookAndDate(
|
|
|
- deviceId,
|
|
|
- record.bookName,
|
|
|
- record.bookAuthor,
|
|
|
- dateString,
|
|
|
- )
|
|
|
- val detail = dao.getDetail(
|
|
|
+ rebuildDetailsAfterSessionMutation(
|
|
|
deviceId,
|
|
|
- record.bookName,
|
|
|
- record.bookAuthor,
|
|
|
- dateString,
|
|
|
+ session.bookName,
|
|
|
+ session.bookAuthor,
|
|
|
+ sessionsBeforeDelete[deviceId].orEmpty(),
|
|
|
+ detailsBeforeDelete[deviceId].orEmpty(),
|
|
|
)
|
|
|
- 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,
|
|
|
)
|
|
|
}
|
|
|
@@ -608,6 +597,40 @@ class ReadRecordRepository(
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ 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 daySessions = newSessions.filter { it.startTime.toDateString() == 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,
|
|
|
+ ))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private fun minPositive(left: Long, right: Long): Long = when {
|
|
|
left <= 0L -> right
|
|
|
right <= 0L -> left
|
|
|
@@ -729,10 +752,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) }
|
|
|
@@ -740,15 +767,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,
|
|
|
@@ -798,19 +823,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 {
|
|
|
@@ -940,16 +967,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 }),
|
|
|
))
|