|
@@ -2,20 +2,24 @@ package io.legado.app.ui.book.readRecord
|
|
|
|
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.ViewModel
|
|
|
import androidx.lifecycle.viewModelScope
|
|
import androidx.lifecycle.viewModelScope
|
|
|
-import io.legado.app.data.dao.BookChapterDao
|
|
|
|
|
-import io.legado.app.data.dao.BookDao
|
|
|
|
|
|
|
+import cn.hutool.core.date.DateUtil
|
|
|
import io.legado.app.data.entities.readRecord.ReadRecord
|
|
import io.legado.app.data.entities.readRecord.ReadRecord
|
|
|
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
|
import io.legado.app.data.entities.readRecord.ReadRecordDetail
|
|
|
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
|
import io.legado.app.data.entities.readRecord.ReadRecordSession
|
|
|
|
|
+import io.legado.app.data.repository.BookRepository
|
|
|
import io.legado.app.data.repository.ReadRecordRepository
|
|
import io.legado.app.data.repository.ReadRecordRepository
|
|
|
-import kotlinx.coroutines.Dispatchers
|
|
|
|
|
|
|
+import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
|
|
|
+import kotlinx.coroutines.flow.SharingStarted
|
|
|
|
|
+import kotlinx.coroutines.flow.StateFlow
|
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
|
-import kotlinx.coroutines.flow.update
|
|
|
|
|
|
|
+import kotlinx.coroutines.flow.combine
|
|
|
|
|
+import kotlinx.coroutines.flow.flatMapLatest
|
|
|
|
|
+import kotlinx.coroutines.flow.stateIn
|
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.launch
|
|
|
-import kotlinx.coroutines.withContext
|
|
|
|
|
import java.time.LocalDate
|
|
import java.time.LocalDate
|
|
|
import java.time.format.DateTimeFormatter
|
|
import java.time.format.DateTimeFormatter
|
|
|
|
|
+import java.util.Date
|
|
|
|
|
|
|
|
data class ReadRecordUiState(
|
|
data class ReadRecordUiState(
|
|
|
val isLoading: Boolean = true,
|
|
val isLoading: Boolean = true,
|
|
@@ -26,7 +30,8 @@ data class ReadRecordUiState(
|
|
|
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
|
|
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
|
|
|
//最后阅读列表
|
|
//最后阅读列表
|
|
|
val latestRecords: List<ReadRecord> = emptyList(),
|
|
val latestRecords: List<ReadRecord> = emptyList(),
|
|
|
- val selectedDate: LocalDate? = null
|
|
|
|
|
|
|
+ val selectedDate: LocalDate? = null,
|
|
|
|
|
+ val searchKey: String? = null,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
enum class DisplayMode {
|
|
enum class DisplayMode {
|
|
@@ -35,18 +40,75 @@ enum class DisplayMode {
|
|
|
LATEST
|
|
LATEST
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+@OptIn(ExperimentalCoroutinesApi::class)
|
|
|
class ReadRecordViewModel(
|
|
class ReadRecordViewModel(
|
|
|
private val repository: ReadRecordRepository,
|
|
private val repository: ReadRecordRepository,
|
|
|
- private val bookDao: BookDao,
|
|
|
|
|
- private val bookChapterDao: BookChapterDao
|
|
|
|
|
|
|
+ private val bookRepository: BookRepository
|
|
|
) : ViewModel() {
|
|
) : ViewModel() {
|
|
|
|
|
+
|
|
|
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
|
|
private val _displayMode = MutableStateFlow(DisplayMode.AGGREGATE)
|
|
|
val displayMode = _displayMode.asStateFlow()
|
|
val displayMode = _displayMode.asStateFlow()
|
|
|
- private val _uiState = MutableStateFlow(ReadRecordUiState())
|
|
|
|
|
- val uiState = _uiState.asStateFlow()
|
|
|
|
|
|
|
|
|
|
- init {
|
|
|
|
|
- loadData()
|
|
|
|
|
|
|
+ private val _searchKey = MutableStateFlow("")
|
|
|
|
|
+ private val _selectedDate = MutableStateFlow<LocalDate?>(null)
|
|
|
|
|
+
|
|
|
|
|
+ // 数据源:直接从 Repository 获取 Flow
|
|
|
|
|
+ @OptIn(ExperimentalCoroutinesApi::class)
|
|
|
|
|
+ private val loadedDataFlow = _searchKey
|
|
|
|
|
+ .flatMapLatest { query ->
|
|
|
|
|
+ combine(
|
|
|
|
|
+ repository.getAllRecordDetails(query),
|
|
|
|
|
+ repository.getLatestReadRecords(query),
|
|
|
|
|
+ repository.getAllSessions(),
|
|
|
|
|
+ repository.getTotalReadTime()
|
|
|
|
|
+ ) { details, latest, sessions, totalTime ->
|
|
|
|
|
+ LoadedData(totalTime, details, latest, sessions)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ val uiState: StateFlow<ReadRecordUiState> = combine(
|
|
|
|
|
+ loadedDataFlow,
|
|
|
|
|
+ _selectedDate,
|
|
|
|
|
+ _searchKey
|
|
|
|
|
+ ) { data, selectedDate, searchKey ->
|
|
|
|
|
+
|
|
|
|
|
+ val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
|
|
|
|
+ val filteredDetails = if (dateStr != null) {
|
|
|
|
|
+ data.details.filter { it.date == dateStr }
|
|
|
|
|
+ } else data.details
|
|
|
|
|
+
|
|
|
|
|
+ val timelineMap = data.sessions
|
|
|
|
|
+ .asSequence()
|
|
|
|
|
+ .filter { session ->
|
|
|
|
|
+ val sDate = DateUtil.format(Date(session.startTime), "yyyy-MM-dd")
|
|
|
|
|
+ (dateStr == null || sDate == dateStr) &&
|
|
|
|
|
+ (searchKey.isEmpty() || session.bookName.contains(
|
|
|
|
|
+ searchKey,
|
|
|
|
|
+ ignoreCase = true
|
|
|
|
|
+ ))
|
|
|
|
|
+ }
|
|
|
|
|
+ .groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
|
|
|
|
|
+ .mapValues { (_, sessions) ->
|
|
|
|
|
+ mergeContinuousSessions(sessions).reversed()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ReadRecordUiState(
|
|
|
|
|
+ isLoading = false,
|
|
|
|
|
+ totalReadTime = data.totalReadTime,
|
|
|
|
|
+ groupedRecords = filteredDetails.groupBy { it.date },
|
|
|
|
|
+ timelineRecords = timelineMap,
|
|
|
|
|
+ latestRecords = data.latestRecords,
|
|
|
|
|
+ selectedDate = selectedDate,
|
|
|
|
|
+ searchKey = searchKey
|
|
|
|
|
+ )
|
|
|
|
|
+ }.stateIn(
|
|
|
|
|
+ scope = viewModelScope,
|
|
|
|
|
+ started = SharingStarted.WhileSubscribed(5000),
|
|
|
|
|
+ initialValue = ReadRecordUiState(isLoading = true)
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ fun setSearchKey(query: String) {
|
|
|
|
|
+ _searchKey.value = query
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fun setDisplayMode(mode: DisplayMode) {
|
|
fun setDisplayMode(mode: DisplayMode) {
|
|
@@ -54,132 +116,44 @@ class ReadRecordViewModel(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fun setSelectedDate(date: LocalDate?) {
|
|
fun setSelectedDate(date: LocalDate?) {
|
|
|
- _uiState.update { it.copy(selectedDate = date) }
|
|
|
|
|
- loadData()
|
|
|
|
|
|
|
+ _selectedDate.value = date
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- fun loadData(query: String = "") {
|
|
|
|
|
- viewModelScope.launch {
|
|
|
|
|
- _uiState.update { it.copy(isLoading = true) }
|
|
|
|
|
-
|
|
|
|
|
- val selectedDate = _uiState.value.selectedDate
|
|
|
|
|
-
|
|
|
|
|
- if (selectedDate != null) {
|
|
|
|
|
- //筛选特定日期
|
|
|
|
|
- val dateString = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)
|
|
|
|
|
- //某一天所有书籍的明细
|
|
|
|
|
- val dailyDetails = repository.getAllRecordDetailsByDate(dateString, query)
|
|
|
|
|
- val grouped = dailyDetails.groupBy { it.date }
|
|
|
|
|
- //某一天所有会话
|
|
|
|
|
- val allSessions = repository.getAllSessionsByDate(dateString)
|
|
|
|
|
- val mergedSessions = mergeContinuousSessions(allSessions).reversed()
|
|
|
|
|
- val timelineMap = mapOf(dateString to mergedSessions)
|
|
|
|
|
- //LATEST
|
|
|
|
|
- val latest = repository.getLatestReadRecords(query)
|
|
|
|
|
- val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
|
|
|
|
|
- _uiState.update {
|
|
|
|
|
- it.copy(
|
|
|
|
|
- isLoading = false,
|
|
|
|
|
- totalReadTime = totalTime,
|
|
|
|
|
- groupedRecords = grouped,
|
|
|
|
|
- timelineRecords = timelineMap,
|
|
|
|
|
- latestRecords = latest
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- val details = repository.getAllRecordDetails(query)
|
|
|
|
|
- val grouped = details.groupBy { it.date }
|
|
|
|
|
- val uniqueDates = grouped.keys.toList()
|
|
|
|
|
- val timelineMap = LinkedHashMap<String, List<ReadRecordSession>>()
|
|
|
|
|
-
|
|
|
|
|
- for (date in uniqueDates) {
|
|
|
|
|
- val rawSessions = repository.getAllSessionsByDate(date)
|
|
|
|
|
- val mergedSessions = mergeContinuousSessions(rawSessions).reversed()
|
|
|
|
|
- timelineMap[date] = mergedSessions
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- val latest = repository.getLatestReadRecords(query)
|
|
|
|
|
- val totalTime = withContext(Dispatchers.IO) { repository.getTotalReadTime() }
|
|
|
|
|
-
|
|
|
|
|
- _uiState.update {
|
|
|
|
|
- it.copy(
|
|
|
|
|
- isLoading = false,
|
|
|
|
|
- totalReadTime = totalTime,
|
|
|
|
|
- groupedRecords = grouped,
|
|
|
|
|
- timelineRecords = timelineMap,
|
|
|
|
|
- latestRecords = latest
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ fun deleteDetail(detail: ReadRecordDetail) {
|
|
|
|
|
+ viewModelScope.launch { repository.deleteDetail(detail) }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * 遍历会话列表,合并同一本书连续阅读且间隔不超过20分钟的会话。
|
|
|
|
|
- * 只保留最晚结束的那条会话(更新其结束时间)。
|
|
|
|
|
- * @param sessions 原始 ReadRecordSession 列表,按开始时间排序。
|
|
|
|
|
- * @return 合并后的 ReadRecordSession 列表。
|
|
|
|
|
- */
|
|
|
|
|
- fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
|
|
|
|
|
|
|
+ private fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
|
|
|
if (sessions.isEmpty()) return emptyList()
|
|
if (sessions.isEmpty()) return emptyList()
|
|
|
-
|
|
|
|
|
val mergedList = mutableListOf<ReadRecordSession>()
|
|
val mergedList = mutableListOf<ReadRecordSession>()
|
|
|
mergedList.add(sessions.first().copy())
|
|
mergedList.add(sessions.first().copy())
|
|
|
|
|
|
|
|
- val twentyMinutesInMillis = 2 * 60 * 1000L
|
|
|
|
|
|
|
+ val gapLimit = 20 * 60 * 1000L
|
|
|
|
|
|
|
|
for (i in 1 until sessions.size) {
|
|
for (i in 1 until sessions.size) {
|
|
|
- val currentSession = sessions[i]
|
|
|
|
|
- val lastMergedSession = mergedList.last()
|
|
|
|
|
- val isSameBook = currentSession.bookName == lastMergedSession.bookName
|
|
|
|
|
- val timeGap = currentSession.startTime - lastMergedSession.endTime
|
|
|
|
|
- val isContinuous = timeGap <= twentyMinutesInMillis && timeGap >= 0
|
|
|
|
|
-
|
|
|
|
|
- if (isSameBook && isContinuous) {
|
|
|
|
|
- mergedList.removeAt(mergedList.lastIndex)
|
|
|
|
|
-
|
|
|
|
|
- val updatedSession = lastMergedSession.copy(
|
|
|
|
|
- endTime = currentSession.endTime
|
|
|
|
|
- )
|
|
|
|
|
- mergedList.add(updatedSession)
|
|
|
|
|
-
|
|
|
|
|
|
|
+ val current = sessions[i]
|
|
|
|
|
+ val last = mergedList.last()
|
|
|
|
|
+ if (current.bookName == last.bookName && (current.startTime - last.endTime) <= gapLimit) {
|
|
|
|
|
+ mergedList[mergedList.lastIndex] = last.copy(endTime = current.endTime)
|
|
|
} else {
|
|
} else {
|
|
|
- mergedList.add(currentSession.copy())
|
|
|
|
|
|
|
+ mergedList.add(current.copy())
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
return mergedList
|
|
return mergedList
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- suspend fun getChapterTitle(
|
|
|
|
|
- bookName: String,
|
|
|
|
|
- chapterIndexLong: Long
|
|
|
|
|
- ): String? {
|
|
|
|
|
- val chapterIndex = chapterIndexLong.toInt()
|
|
|
|
|
- val book = withContext(Dispatchers.IO) {
|
|
|
|
|
- bookDao.findByName(bookName).firstOrNull()
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- val bookUrl = book?.bookUrl
|
|
|
|
|
- if (bookUrl.isNullOrEmpty()) {
|
|
|
|
|
- return null
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return withContext(Dispatchers.IO) {
|
|
|
|
|
- bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- fun deleteDetail(detail: ReadRecordDetail) {
|
|
|
|
|
- viewModelScope.launch {
|
|
|
|
|
- repository.deleteDetail(detail)
|
|
|
|
|
- loadData()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ suspend fun getChapterTitle(bookName: String, chapterIndexLong: Long): String? {
|
|
|
|
|
+ return bookRepository.getChapterTitle(bookName, chapterIndexLong.toInt())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
suspend fun getBookCover(bookName: String): String? {
|
|
suspend fun getBookCover(bookName: String): String? {
|
|
|
- return withContext(Dispatchers.IO) {
|
|
|
|
|
- bookDao.findByName(bookName).firstOrNull()?.getDisplayCover()
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ return bookRepository.getBookCoverByName(bookName)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private data class LoadedData(
|
|
|
|
|
+ val totalReadTime: Long,
|
|
|
|
|
+ val details: List<ReadRecordDetail>,
|
|
|
|
|
+ val latestRecords: List<ReadRecord>,
|
|
|
|
|
+ val sessions: List<ReadRecordSession>
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|