Преглед изворни кода

优化阅读记录界面,添加按日期查看记录的功能

HapeLee пре 11 месеци
родитељ
комит
101a9fa878

+ 6 - 3
app/src/main/java/io/legado/app/data/dao/ReadRecordDao.kt

@@ -26,8 +26,8 @@ interface ReadRecordDao {
     )
     )
     val allShow: List<ReadRecordShow>
     val allShow: List<ReadRecordShow>
 
 
-    @get:Query("select sum(readTime) from readRecord")
-    val allTime: Long
+    @Query("select sum(readTime) from readRecord")
+    suspend fun getTotalReadTime(): Long
 
 
     @Query(
     @Query(
         """
         """
@@ -138,10 +138,13 @@ interface ReadRecordDao {
     SELECT * FROM readRecordSession 
     SELECT * FROM readRecordSession 
     WHERE deviceId = :deviceId 
     WHERE deviceId = :deviceId 
     AND STRFTIME('%Y-%m-%d', datetime(startTime/1000, 'unixepoch', 'localtime')) = :date 
     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<ReadRecordSession>
     suspend fun getSessionsByDate(deviceId: String, date: String): List<ReadRecordSession>
 
 
+    @Query("SELECT * FROM readRecordDetail WHERE deviceId = :deviceId AND date = :date AND bookName LIKE '%' || :query || '%'")
+    suspend fun searchDetailsByDate(deviceId: String, date: String, query: String): List<ReadRecordDetail>
+
     // 清除会话记录
     // 清除会话记录
     @Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
     @Query("DELETE FROM readRecordSession WHERE bookName = :bookName")
     fun deleteSessionByName(bookName: String)
     fun deleteSessionByName(bookName: String)

+ 18 - 15
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<ReadRecordDetail> {
-        return dao.getDetailsByDate(deviceId, date)
-    }
-
-    suspend fun getDailySessions(deviceId: String, bookName: String, date: String): List<ReadRecordSession> {
-        return dao.getSessionsByBookAndDate(deviceId, bookName, date)
-    }
-
     suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
     suspend fun getLatestReadRecords(query: String = ""): List<ReadRecord> {
         return if (query.isBlank()) {
         return if (query.isBlank()) {
             dao.getAllReadRecordsSortedByLastRead()
             dao.getAllReadRecordsSortedByLastRead()
@@ -106,11 +98,6 @@ class ReadRecordRepository(
         }
         }
     }
     }
 
 
-    suspend fun getAllSessionsForDate(date: String): List<ReadRecordSession> {
-        val deviceId = getCurrentDeviceId()
-        return dao.getSessionsByDate(deviceId, date)
-    }
-
     suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
     suspend fun getAllRecordDetails(query: String = ""): List<ReadRecordDetail> {
         return if (query.isBlank()) {
         return if (query.isBlank()) {
             dao.getAllDetails()
             dao.getAllDetails()
@@ -119,6 +106,20 @@ class ReadRecordRepository(
         }
         }
     }
     }
 
 
+    suspend fun getAllRecordDetailsByDate(dateString: String, query: String = ""): List<ReadRecordDetail> {
+        val deviceId = getCurrentDeviceId()
+        return if (query.isBlank()) {
+            dao.getDetailsByDate(deviceId, dateString)
+        } else {
+            dao.searchDetailsByDate(deviceId, dateString, query)
+        }
+    }
+
+    suspend fun getAllSessionsByDate(dateString: String): List<ReadRecordSession> {
+        val deviceId = getCurrentDeviceId()
+        return dao.getSessionsByDate(deviceId, dateString)
+    }
+
     suspend fun deleteDetail(detail: ReadRecordDetail) {
     suspend fun deleteDetail(detail: ReadRecordDetail) {
         dao.deleteDetail(detail)
         dao.deleteDetail(detail)
     }
     }
@@ -129,6 +130,8 @@ class ReadRecordRepository(
     }
     }
 
 
     // 暴露总时长
     // 暴露总时长
-    val allTime: Long
-        get() = dao.allTime
+    suspend fun getTotalReadTime(): Long {
+        return dao.getTotalReadTime()
+    }
+
 }
 }

+ 1 - 1
app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt

@@ -132,7 +132,7 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
     private fun initAllTime() {
     private fun initAllTime() {
         lifecycleScope.launch {
         lifecycleScope.launch {
             val allTime = withContext(IO) {
             val allTime = withContext(IO) {
-                appDb.readRecordDao.allTime
+                appDb.readRecordDao.getTotalReadTime()
             }
             }
             binding.tvReadingTime.text = formatDuring(allTime)
             binding.tvReadingTime.text = formatDuring(allTime)
         }
         }

+ 212 - 56
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.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 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.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.lifecycleScope
-import coil.compose.AsyncImage
-import coil.request.ImageRequest
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.data.entities.readRecord.ReadRecordDetail
 import io.legado.app.utils.startActivityForBook
 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.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.List
 import androidx.compose.material.icons.automirrored.filled.List
 import androidx.compose.material.icons.filled.*
 import androidx.compose.material.icons.filled.*
-import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.zIndex
 import cn.hutool.core.date.DateUtil
 import cn.hutool.core.date.DateUtil
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.ui.widget.components.AnimatedTextLine
 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.Cover
 import io.legado.app.ui.widget.components.EmptyMessageView
 import io.legado.app.ui.widget.components.EmptyMessageView
 import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.utils.StringUtils.formatFriendlyDate
 import io.legado.app.utils.StringUtils.formatFriendlyDate
+import kotlinx.coroutines.delay
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Date
 
 
 // 包含绘制时间线所需的上下文信息
 // 包含绘制时间线所需的上下文信息
 data class TimelineItem(
 data class TimelineItem(
@@ -93,6 +95,7 @@ fun ReadRecordScreen(
     val state by viewModel.uiState.collectAsState()
     val state by viewModel.uiState.collectAsState()
     val displayMode by viewModel.displayMode.collectAsState()
     val displayMode by viewModel.displayMode.collectAsState()
     var showSearch by remember { mutableStateOf(false) }
     var showSearch by remember { mutableStateOf(false) }
+    var showCalendar by remember { mutableStateOf(false) }
     var searchText by remember { mutableStateOf("") }
     var searchText by remember { mutableStateOf("") }
 
 
     val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
     val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
@@ -105,7 +108,7 @@ fun ReadRecordScreen(
 
 
     LaunchedEffect(searchText) {
     LaunchedEffect(searchText) {
         if (showSearch) {
         if (showSearch) {
-            kotlinx.coroutines.delay(100L)
+            delay(100L)
             viewModel.loadData(searchText)
             viewModel.loadData(searchText)
         }
         }
     }
     }
@@ -155,6 +158,9 @@ fun ReadRecordScreen(
                             val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
                             val description = if (displayMode == DisplayMode.AGGREGATE) "Switch to Timeline" else "Switch to Aggregate"
                             Icon(icon, description)
                             Icon(icon, description)
                         }
                         }
+                        IconButton(onClick = { showCalendar = !showCalendar }) {
+                            Icon(Icons.Default.CalendarMonth, contentDescription = "Toggle Calendar")
+                        }
                         IconButton(onClick = { showSearch = !showSearch }) {
                         IconButton(onClick = { showSearch = !showSearch }) {
                             Icon(Icons.Default.Search, contentDescription = null)
                             Icon(Icons.Default.Search, contentDescription = null)
                         }
                         }
@@ -172,8 +178,6 @@ fun ReadRecordScreen(
         }
         }
     ) { padding ->
     ) { padding ->
         Column(modifier = Modifier.padding(padding).fillMaxSize()) {
         Column(modifier = Modifier.padding(padding).fillMaxSize()) {
-
-            TotalTimeHeader(state.totalReadTime)
             val isEmpty = when (displayMode) {
             val isEmpty = when (displayMode) {
                 DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
                 DisplayMode.AGGREGATE -> state.groupedRecords.isEmpty()
                 DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
                 DisplayMode.TIMELINE -> state.timelineRecords.isEmpty()
@@ -184,18 +188,76 @@ fun ReadRecordScreen(
                 animationSpec = tween(durationMillis = 500),
                 animationSpec = tween(durationMillis = 500),
                 label = "ContentCrossfade"
                 label = "ContentCrossfade"
             ) { isListEmpty ->
             ) { isListEmpty ->
-                if (isListEmpty){
-                    Box(
+
+                    LazyColumn(
                         modifier = Modifier
                         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){
                         when(displayMode){
                             DisplayMode.AGGREGATE -> {
                             DisplayMode.AGGREGATE -> {
                                 state.groupedRecords.forEach { (date, details) ->
                                 state.groupedRecords.forEach { (date, details) ->
@@ -226,9 +288,7 @@ fun ReadRecordScreen(
                                     stickyHeader { DateHeader(date, dailyTotalTime) }
                                     stickyHeader { DateHeader(date, dailyTotalTime) }
 
 
                                     val timelineItems = sessions.mapIndexed { index, session ->
                                     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)
                                         TimelineItem(session, showHeader)
                                     }
                                     }
 
 
@@ -253,7 +313,7 @@ fun ReadRecordScreen(
                             }
                             }
                         }
                         }
                     }
                     }
-                }
+
             }
             }
         }
         }
     }
     }
@@ -296,7 +356,7 @@ fun LatestReadItem(
                 color = Color.Gray
                 color = Color.Gray
             )
             )
             Text(
             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,
                 style = MaterialTheme.typography.bodySmall,
                 color = MaterialTheme.colorScheme.primary
                 color = MaterialTheme.colorScheme.primary
             )
             )
@@ -317,8 +377,8 @@ fun TimelineSessionItem(
         coverPath = viewModel.getBookCover(session.bookName)
         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 duration = session.endTime - session.startTime
 
 
     val nodeRadius = 4.dp
     val nodeRadius = 4.dp
@@ -378,22 +438,21 @@ fun TimelineSessionItem(
                         Text(
                         Text(
                             text = session.bookName,
                             text = session.bookName,
                             style = MaterialTheme.typography.titleMedium,
                             style = MaterialTheme.typography.titleMedium,
-                            maxLines = 1
+                            maxLines = 2
                         )
                         )
                     }
                     }
                     Spacer(modifier = Modifier.height(6.dp))
                     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
 @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<String>,
+    viewModel: ReadRecordViewModel,
+    onClick: () -> Unit
+) {
+
+    val coverPaths by produceState(initialValue = emptyList(), key1 = bookNamesForCover) {
+        value = bookNamesForCover.map { name ->
+            viewModel.getBookCover(name)
+        }
+    }
+
+    val totalDurationMinutes = totalTimeMillis / 60000
+
     Card(
     Card(
+        onClick = onClick,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 8.dp),
         colors = CardDefaults.cardColors(
         colors = CardDefaults.cardColors(
             containerColor = MaterialTheme.colorScheme.surfaceContainer
             containerColor = MaterialTheme.colorScheme.surfaceContainer
-        ),
-        shape = MaterialTheme.shapes.medium,
-        modifier = Modifier
-            .padding(horizontal = 16.dp, vertical = 8.dp)
+        )
     ) {
     ) {
-        Column(
+        Row(
             modifier = Modifier
             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<String?>) {
+    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)
+                }
+            }
         }
         }
     }
     }
 }
 }

+ 91 - 17
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.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
 
 
 data class ReadRecordUiState(
 data class ReadRecordUiState(
     val isLoading: Boolean = true,
     val isLoading: Boolean = true,
@@ -22,7 +24,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
 )
 )
 
 
 enum class DisplayMode {
 enum class DisplayMode {
@@ -48,31 +51,102 @@ class ReadRecordViewModel(
         _displayMode.value = mode
         _displayMode.value = mode
     }
     }
 
 
+    fun setSelectedDate(date: LocalDate?) {
+        _uiState.update { it.copy(selectedDate = date) }
+        loadData()
+    }
+
     fun loadData(query: String = "") {
     fun loadData(query: String = "") {
         viewModelScope.launch {
         viewModelScope.launch {
             _uiState.update { it.copy(isLoading = true) }
             _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>>()
 
 
-            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 sessions = repository.getAllSessionsForDate(date)
-                timelineMap[date] = sessions
+                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
+                    )
+                }
             }
             }
-            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
+        }
+    }
+
+    /**
+     * 遍历会话列表,合并同一本书连续阅读且间隔不超过20分钟的会话。
+     * 只保留最晚结束的那条会话(更新其结束时间)。
+     * @param sessions 原始 ReadRecordSession 列表,按开始时间排序。
+     * @return 合并后的 ReadRecordSession 列表。
+     */
+    fun mergeContinuousSessions(sessions: List<ReadRecordSession>): List<ReadRecordSession> {
+        if (sessions.isEmpty()) return emptyList()
+
+        val mergedList = mutableListOf<ReadRecordSession>()
+        mergedList.add(sessions.first().copy())
+
+        val twentyMinutesInMillis = 2 * 60 * 1000L
+
+        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)
+
+            } else {
+                mergedList.add(currentSession.copy())
             }
             }
         }
         }
+
+        return mergedList
     }
     }
 
 
     fun deleteDetail(detail: ReadRecordDetail) {
     fun deleteDetail(detail: ReadRecordDetail) {

+ 267 - 0
app/src/main/java/io/legado/app/ui/widget/components/Calender.kt

@@ -0,0 +1,267 @@
+package io.legado.app.ui.widget.components
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.*
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import java.time.*
+import java.time.format.DateTimeFormatter
+import java.time.format.TextStyle
+import java.util.*
+
+const val START_PAGE_OFFSET = 100
+
+/**
+ * @param modifier 外部修饰符
+ * @param initialDate 初始显示的月份日期
+ * @param onDateSelected 日期被选中时的回调
+ */
+@Composable
+fun Calendar(
+    modifier: Modifier = Modifier,
+    initialDate: LocalDate = LocalDate.now(),
+    onDateSelected: (LocalDate) -> Unit,
+    selectedDate: LocalDate? = null,
+    onClearDate: (() -> Unit)? = null
+) {
+    val baseMonth = remember(selectedDate) {
+        selectedDate?.withDayOfMonth(1) ?: initialDate.withDayOfMonth(1)
+    }
+
+    val pagerState = rememberPagerState(initialPage = START_PAGE_OFFSET) {
+        START_PAGE_OFFSET * 2
+    }
+
+    val coroutineScope = rememberCoroutineScope()
+
+    val currentMonth by remember {
+        derivedStateOf {
+            val pageOffset = pagerState.currentPage - START_PAGE_OFFSET
+            baseMonth.plusMonths(pageOffset.toLong())
+        }
+    }
+
+    Card(
+        modifier = modifier.fillMaxWidth(),
+        shape = MaterialTheme.shapes.medium,
+        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
+    ) {
+        Column(modifier = Modifier.padding(vertical = 12.dp).animateContentSize()) {
+            MonthNavigation(
+                currentMonth = currentMonth,
+                selectedDate = selectedDate,
+                onPreviousClick = {
+                    coroutineScope.launch {
+                        pagerState.animateScrollToPage(pagerState.currentPage - 1)
+                    }
+                },
+                onNextClick = {
+                    coroutineScope.launch {
+                        pagerState.animateScrollToPage(pagerState.currentPage + 1)
+                    }
+                },
+                onClearClick = onClearDate
+            )
+
+            Spacer(modifier = Modifier.height(8.dp))
+
+            DayOfWeekHeader()
+
+            Spacer(modifier = Modifier.height(8.dp))
+
+            HorizontalPager(
+                state = pagerState,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 320.dp),
+                verticalAlignment = Alignment.Top
+            ) { page ->
+                val pageOffset = page - START_PAGE_OFFSET
+                val monthForPage = baseMonth.plusMonths(pageOffset.toLong())
+
+                MonthPageContent(
+                    month = monthForPage,
+                    selectedDate = selectedDate,
+                    onDateClick = onDateSelected
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun MonthPageContent(
+    month: LocalDate,
+    selectedDate: LocalDate?,
+    onDateClick: (LocalDate) -> Unit
+) {
+    val datesInMonth = getDatesInMonth(month)
+    val firstDayOfWeek = month.dayOfWeek.value % 7
+    val offset = firstDayOfWeek
+
+    LazyVerticalGrid(
+        columns = GridCells.Fixed(7),
+        modifier = Modifier
+            .fillMaxWidth(),
+        userScrollEnabled = false,
+        horizontalArrangement = Arrangement.SpaceAround
+    ) {
+        items(offset) {
+            Spacer(modifier = Modifier.size(36.dp))
+        }
+
+        items(datesInMonth) { date ->
+            DateItem(
+                date = date,
+                isSelected = date == selectedDate,
+                isToday = date == LocalDate.now(),
+                isCurrentMonth = date.month == month.month,
+                onClick = { onDateClick(date) }
+            )
+        }
+    }
+}
+
+@Composable
+fun MonthNavigation(
+    currentMonth: LocalDate,
+    selectedDate: LocalDate?,
+    onPreviousClick: () -> Unit,
+    onNextClick: () -> Unit,
+    onClearClick: (() -> Unit)?
+) {
+    Row(
+        modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
+        horizontalArrangement = Arrangement.SpaceBetween,
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Column(
+            modifier = Modifier.weight(1f)
+        ) {
+            Text(
+                text = currentMonth.year.toString(),
+                style = MaterialTheme.typography.labelLarge,
+                color = MaterialTheme.colorScheme.onSurfaceVariant
+            )
+            AnimatedTextLine(
+                text = currentMonth.format(DateTimeFormatter.ofPattern("MMMM", Locale.getDefault())),
+                style = MaterialTheme.typography.headlineSmall,
+                color = MaterialTheme.colorScheme.onSurface
+            )
+        }
+        Row(verticalAlignment = Alignment.CenterVertically) {
+
+            if (selectedDate != null && onClearClick != null) {
+                FilledTonalIconButton(onClick = onClearClick) {
+                    Icon(
+                        imageVector = Icons.Default.Delete,
+                        contentDescription = "Clear Date Selection",
+                    )
+                }
+            }
+
+            IconButton(onClick = onPreviousClick) {
+                Icon(
+                    imageVector = Icons.AutoMirrored.Default.ArrowBack,
+                    contentDescription = "Previous Month",
+                    tint = MaterialTheme.colorScheme.primary
+                )
+            }
+
+            IconButton(onClick = onNextClick) {
+                Icon(
+                    imageVector = Icons.AutoMirrored.Default.ArrowForward,
+                    contentDescription = "Next Month",
+                    tint = MaterialTheme.colorScheme.primary
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun DayOfWeekHeader() {
+    val dayNames = listOf(
+        DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
+        DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY
+    ).map {
+        it.getDisplayName(TextStyle.SHORT, Locale.getDefault())
+    }
+
+    Row(
+        modifier = Modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.SpaceAround
+    ) {
+        dayNames.forEach { dayName ->
+            Text(
+                text = dayName,
+                style = MaterialTheme.typography.labelMedium,
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier.width(36.dp),
+                textAlign = androidx.compose.ui.text.style.TextAlign.Center
+            )
+        }
+    }
+}
+@Composable
+fun DateItem(
+    date: LocalDate,
+    isSelected: Boolean,
+    isToday: Boolean,
+    isCurrentMonth: Boolean,
+    onClick: () -> Unit
+) {
+    val buttonColors = when {
+        isSelected -> ButtonDefaults.buttonColors(
+            containerColor = MaterialTheme.colorScheme.primary,
+            contentColor = MaterialTheme.colorScheme.onPrimary
+        )
+        isCurrentMonth -> ButtonDefaults.textButtonColors(
+            contentColor = if (isToday) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
+        )
+        else -> ButtonDefaults.textButtonColors(
+            contentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+        )
+    }
+
+    val borderStroke = if (isToday && !isSelected) {
+        BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
+    } else {
+        null
+    }
+
+    Button(
+        onClick = onClick,
+        modifier = Modifier.size(36.dp).padding(4.dp),
+        shape = CircleShape,
+        colors = buttonColors,
+        border = borderStroke,
+        contentPadding = PaddingValues(0.dp),
+        elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp)
+    ) {
+        Text(
+            text = date.dayOfMonth.toString(),
+            style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
+        )
+    }
+}
+
+fun getDatesInMonth(month: LocalDate): List<LocalDate> {
+    val firstDayOfMonth = month.withDayOfMonth(1)
+    val lengthOfMonth = month.lengthOfMonth()
+    return (0 until lengthOfMonth).map { firstDayOfMonth.plusDays(it.toLong()) }
+}