diff --git a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt index 0bb4470b8..e60548246 100644 --- a/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt @@ -26,8 +26,8 @@ interface ReadRecordDao { ) val allShow: List - @get:Query("select sum(readTime) from readRecord") - val allTime: Long + @Query("select sum(readTime) from readRecord") + suspend fun getTotalReadTime(): Long @Query( """ @@ -138,10 +138,13 @@ interface ReadRecordDao { SELECT * FROM readRecordSession WHERE deviceId = :deviceId AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date - ORDER BY startTime DESC + ORDER BY startTime ASC """) suspend fun getSessionsByDate(deviceId: String, date: String): List + @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'") + suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List + // 清除会话记录 @Query("DELETE FROM readRecordSession WHERE bookName = :bookName") fun deleteSessionByName(bookName: String) diff --git a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt index d1cbbd84f..4e5f27639 100644 --- a/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/ReadRecordRepository.kt @@ -90,14 +90,6 @@ class ReadRecordRepository( } } - suspend fun getDailyDetails(deviceId: String, date: String): List { - return dao.getDetailsByDate(deviceId, date) - } - - suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List { - return dao.getSessionsByBookAndDate(deviceId, bookName, date) - } - suspend fun getLatestReadRecords(query: String = ""): List { return if (query.isBlank()) { dao.getAllReadRecordsSortedByLastRead() @@ -106,11 +98,6 @@ class ReadRecordRepository( } } - suspend fun getAllSessionsForDate(date: String): List { - val deviceId = getCurrentDeviceId() - return dao.getSessionsByDate(deviceId, date) - } - suspend fun getAllRecordDetails(query: String = ""): List { return if (query.isBlank()) { dao.getAllDetails() @@ -119,6 +106,20 @@ class ReadRecordRepository( } } + suspend fun getAllRecordDetailsByDate(dateString: String, query: String = ""): List { + val deviceId = getCurrentDeviceId() + return if (query.isBlank()) { + dao.getDetailsByDate(deviceId, dateString) + } else { + dao.searchDetailsByDate(deviceId, dateString, query) + } + } + + suspend fun getAllSessionsByDate(dateString: String): List { + val deviceId = getCurrentDeviceId() + return dao.getSessionsByDate(deviceId, dateString) + } + suspend fun deleteDetail(detail: ReadRecordDetail) { dao.deleteDetail(detail) } @@ -129,6 +130,8 @@ class ReadRecordRepository( } // 暴露总时长 - val allTime: Long - get() = dao.allTime + suspend fun getTotalReadTime(): Long { + return dao.getTotalReadTime() + } + } \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt index 138b44008..3fb938b4f 100644 --- a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt @@ -132,7 +132,7 @@ class ReadRecordActivity : BaseActivity() { private fun initAllTime() { lifecycleScope.launch { val allTime = withContext(IO) { - appDb.readRecordDao.allTime + appDb.readRecordDao.getTotalReadTime() } binding.tvReadingTime.text = formatDuring(allTime) } diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt index 9abb90511..3f1f05570 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordActivity.kt @@ -17,13 +17,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.lifecycleScope -import coil.compose.AsyncImage -import coil.request.ImageRequest import io.legado.app.data.appDb import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.utils.startActivityForBook @@ -34,20 +30,26 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.* -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.zIndex import cn.hutool.core.date.DateUtil import io.legado.app.base.BaseComposeActivity import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.ui.widget.components.AnimatedTextLine +import io.legado.app.ui.widget.components.Calendar import io.legado.app.ui.widget.components.Cover import io.legado.app.ui.widget.components.EmptyMessageView import io.legado.app.ui.widget.components.SearchBarSection import io.legado.app.utils.StringUtils.formatFriendlyDate +import kotlinx.coroutines.delay import org.koin.androidx.compose.koinViewModel +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Date // 包含绘制时间线所需的上下文信息 data class TimelineItem( @@ -93,6 +95,7 @@ fun ReadRecordScreen( val state by viewModel.uiState.collectAsState() val displayMode by viewModel.displayMode.collectAsState() var showSearch by remember { mutableStateOf(false) } + var showCalendar by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -105,7 +108,7 @@ fun ReadRecordScreen( LaunchedEffect(searchText) { if (showSearch) { - kotlinx.coroutines.delay(100L) + delay(100L) viewModel.loadData(searchText) } } @@ -155,6 +158,9 @@ fun ReadRecordScreen( val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate" Icon(icon, description) } + IconButton(onClick = { showCalendar = !showCalendar }) { + Icon(Icons.Default.CalendarMonth, contentDescription = "Toggle Calendar") + } IconButton(onClick = { showSearch = !showSearch }) { Icon(Icons.Default.Search, contentDescription = null) } @@ -172,8 +178,6 @@ fun ReadRecordScreen( } ) { padding -> Column(modifier = Modifier.padding(padding).fillMaxSize()) { - - TotalTimeHeader(state.totalReadTime) val isEmpty = when (displayMode) { DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty() DisplayMode.TIMELINE -> state.timelineRecords.isEmpty() @@ -184,18 +188,76 @@ fun ReadRecordScreen( animationSpec = tween(durationMillis = 500), label = "ContentCrossfade" ) { isListEmpty -> - if (isListEmpty){ - Box( + + LazyColumn( modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center + .nestedScroll(scrollBehavior.nestedScrollConnection) ) { - EmptyMessageView( - message = "没有记录" - ) - } - } else { - LazyColumn { + + item { + val selectedDate = state.selectedDate + + if (selectedDate != null) { + val dateKey = selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + val dailyDetails = state.groupedRecords[dateKey] ?: emptyList() + + if (dailyDetails.isNotEmpty()) { + val distinctBooks = dailyDetails.map { it.bookName }.distinct() + val dailyTime = dailyDetails.sumOf { it.readTime } + + ReadingSummaryCard( + title = selectedDate.format(DateTimeFormatter.ofPattern("M月d日阅读概览")), + bookCount = distinctBooks.size, + totalTimeMillis = dailyTime, + bookNamesForCover = distinctBooks.take(3), + viewModel = viewModel, + onClick = { } + ) + } + } else { + val allBooksCount = state.latestRecords.size + val totalTime = state.totalReadTime + + if (allBooksCount > 0) { + ReadingSummaryCard( + title = "累计阅读成就", + bookCount = allBooksCount, + totalTimeMillis = totalTime, + bookNamesForCover = state.latestRecords.take(5).map { it.bookName }, + viewModel = viewModel, + onClick = { } + ) + } + } + } + + item { + AnimatedVisibility(visible = showCalendar) { + CalendarSection( + selectedDate = state.selectedDate, + onDateSelected = { date -> + viewModel.setSelectedDate(date) + showCalendar = false // 选择后自动收起日历 + }, + onClearDate = { + viewModel.setSelectedDate(null) + showCalendar = false // 清除后自动收起 + } + ) + } + if (isListEmpty){ + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + EmptyMessageView( + message = "没有记录" + ) + } + } + } + when(displayMode){ DisplayMode.AGGREGATE -> { state.groupedRecords.forEach { (date, details) -> @@ -226,9 +288,7 @@ fun ReadRecordScreen( stickyHeader { DateHeader(date, dailyTotalTime) } val timelineItems = sessions.mapIndexed { index, session -> - val previousSession = sessions.getOrNull(index - 1) - - val showHeader = index == 0 || session.bookName != previousSession?.bookName + val showHeader = true TimelineItem(session, showHeader) } @@ -253,7 +313,7 @@ fun ReadRecordScreen( } } } - } + } } } @@ -296,7 +356,7 @@ fun LatestReadItem( color = Color.Gray ) Text( - text = "最后阅读: ${DateUtil.format(java.util.Date(record.lastRead), "yyyy-MM-dd HH:mm")}", + text = "最后阅读: ${DateUtil.format(Date(record.lastRead), "yyyy-MM-dd HH:mm")}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) @@ -317,8 +377,8 @@ fun TimelineSessionItem( coverPath = viewModel.getBookCover(session.bookName) } - val startTimeText = DateUtil.format(java.util.Date(session.startTime), "HH:mm") - val endTimeText = DateUtil.format(java.util.Date(session.endTime), "HH:mm") + val startTimeText = DateUtil.format(Date(session.startTime), "HH:mm") + val endTimeText = DateUtil.format(Date(session.endTime), "HH:mm") val duration = session.endTime - session.startTime val nodeRadius = 4.dp @@ -378,22 +438,21 @@ fun TimelineSessionItem( Text( text = session.bookName, style = MaterialTheme.typography.titleMedium, - maxLines = 1 + maxLines = 2 ) } Spacer(modifier = Modifier.height(6.dp)) + Text( + "时长: ${formatDuring(duration)}", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + // Text( + // "字数: ${session.words}", + // style = MaterialTheme.typography.bodySmall, + // color = MaterialTheme.colorScheme.onSurfaceVariant + // ) } - - Text( - "时长: ${formatDuring(duration)}", - style = MaterialTheme.typography.bodySmall, - color = Color.Gray - ) - // Text( - // "字数: ${session.words}", - // style = MaterialTheme.typography.bodySmall, - // color = MaterialTheme.colorScheme.onSurfaceVariant - // ) } } } @@ -472,32 +531,129 @@ fun DateHeader( } @Composable -fun TotalTimeHeader(time: Long) { +fun CalendarSection( + selectedDate: LocalDate?, + onDateSelected: (LocalDate) -> Unit, + onClearDate: () -> Unit +) { + val effectiveInitialDate = selectedDate ?: LocalDate.now() + Calendar( + modifier = Modifier.padding(horizontal = 16.dp), + initialDate = effectiveInitialDate, + selectedDate = selectedDate, + onDateSelected = onDateSelected, + onClearDate = onClearDate + ) +} + +@Composable +fun ReadingSummaryCard( + title: String, + bookCount: Int, + totalTimeMillis: Long, + bookNamesForCover: List, + viewModel: ReadRecordViewModel, + onClick: () -> Unit +) { + + val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) { + value = bookNamesForCover.map { name -> + viewModel.getBookCover(name) + } + } + + val totalDurationMinutes = totalTimeMillis / 60000 + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) + ) ) { - Column( + Row( modifier = Modifier - .padding(12.dp), - horizontalAlignment = Alignment.Start + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "阅读时长", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = formatDuring(time), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) + Column(modifier = Modifier.weight(1f)) { + + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = "已读 ", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "$bookCount", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Text( + text = " 本书", + style = MaterialTheme.typography.titleMedium + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + val hours = totalDurationMinutes / 60 + val minutes = totalDurationMinutes % 60 + val timeString = if (hours > 0) "${hours}小时${minutes}分钟" else "${minutes}分钟" + + Text( + text = "共阅读 $timeString", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (bookNamesForCover.isNotEmpty()) { + BookStackView(coverPaths = coverPaths) + } + } + } +} + +@Composable +fun BookStackView(coverPaths: List) { + val xOffsetStep = 12.dp + val stackWidth = 48.dp + (xOffsetStep * (coverPaths.size - 1).coerceAtLeast(0)) + + Box( + modifier = Modifier + .width(stackWidth) + .height(72.dp), + contentAlignment = Alignment.CenterStart + ) { + coverPaths.forEachIndexed { index, path -> + Box( + modifier = Modifier + .padding(start = xOffsetStep * index) + .zIndex(index.toFloat()) + .rotate(if (index % 2 == 0) 3f else -3f) + ) { + Surface( + shadowElevation = 4.dp, + shape = RoundedCornerShape(4.dp), + color = Color.Transparent + ) { + Cover(path = path) + } + } } } } diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt index c4938e51e..7c576bb6d 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt @@ -13,6 +13,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.format.DateTimeFormatter data class ReadRecordUiState( val isLoading: Boolean = true, @@ -22,7 +24,8 @@ data class ReadRecordUiState( //每日所有阅读会话 val timelineRecords: Map> = emptyMap(), //最后阅读列表 - val latestRecords: List = emptyList() + val latestRecords: List = emptyList(), + val selectedDate: LocalDate? = null ) enum class DisplayMode { @@ -48,33 +51,104 @@ class ReadRecordViewModel( _displayMode.value = mode } + fun setSelectedDate(date: LocalDate?) { + _uiState.update { it.copy(selectedDate = date) } + loadData() + } + fun loadData(query: String = "") { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } + val selectedDate = _uiState.value.selectedDate - val details = repository.getAllRecordDetails(query) - val grouped = details.groupBy { it.date } - val uniqueDates = grouped.keys.toList() - val timelineMap = LinkedHashMap>() - for (date in uniqueDates) { - val sessions = repository.getAllSessionsForDate(date) - timelineMap[date] = sessions - } - val latest = repository.getLatestReadRecords(query) - val totalTime = withContext(Dispatchers.IO) { repository.allTime } - _uiState.update { - it.copy( - isLoading = false, - totalReadTime = totalTime, - groupedRecords = grouped, - timelineRecords = timelineMap, - latestRecords = latest - ) + 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>() + + 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 + ) + } } } } + /** + * 遍历会话列表,合并同一本书连续阅读且间隔不超过20分钟çš