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

[优化] 新的阅读记录日期选择,现在可以方便的查看每天的阅读情况

HapeLee 8 месяцев назад
Родитель
Сommit
47a8e12f78

+ 0 - 17
app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt

@@ -55,23 +55,6 @@ data class BookmarkUiState(
     val collapsedGroups: Set<String> = emptySet()
     val collapsedGroups: Set<String> = emptySet()
 )
 )
 
 
-sealed interface BookmarkListItem {
-    val key: String
-
-    data class Header(
-        val header: BookmarkGroupHeader,
-        val collapsed: Boolean
-    ) : BookmarkListItem {
-        override val key = "header:${header.bookName}|${header.bookAuthor}"
-    }
-
-    data class Item(
-        val data: BookmarkItemUi
-    ) : BookmarkListItem {
-        override val key = "item:${data.id}"
-    }
-}
-
 
 
 class AllBookmarkViewModel(
 class AllBookmarkViewModel(
     application: Application,
     application: Application,

+ 176 - 30
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -2,6 +2,8 @@ package io.legado.app.ui.book.readRecord
 
 
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
 import androidx.compose.foundation.background
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
@@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyListScope
 import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyRow
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.shape.RoundedCornerShape
@@ -43,6 +46,7 @@ import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.produceState
 import androidx.compose.runtime.produceState
@@ -52,9 +56,14 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.drawBehind
 import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.draw.drawWithContent
 import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.draw.rotate
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
@@ -64,7 +73,6 @@ 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.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.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
@@ -72,6 +80,16 @@ import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.ui.widget.components.SectionHeader
 import io.legado.app.ui.widget.components.SectionHeader
 import io.legado.app.ui.widget.components.button.AlertButton
 import io.legado.app.ui.widget.components.button.AlertButton
 import io.legado.app.ui.widget.components.button.SmallTopBarButton
 import io.legado.app.ui.widget.components.button.SmallTopBarButton
+import io.legado.app.ui.widget.components.heatmap.HeatmapCalendarTopBar
+import io.legado.app.ui.widget.components.heatmap.HeatmapConfig
+import io.legado.app.ui.widget.components.heatmap.HeatmapLegend
+import io.legado.app.ui.widget.components.heatmap.HeatmapMode
+import io.legado.app.ui.widget.components.heatmap.HeatmapWeekColumn
+import io.legado.app.ui.widget.components.heatmap.NoEarlierDataIndicator
+import io.legado.app.ui.widget.components.heatmap.WeekdayLabelsColumn
+import io.legado.app.ui.widget.components.heatmap.rememberDateRange
+import io.legado.app.ui.widget.components.heatmap.rememberDaysInRange
+import io.legado.app.ui.widget.components.heatmap.rememberWeeks
 import io.legado.app.ui.widget.components.swipe.SwipeAction
 import io.legado.app.ui.widget.components.swipe.SwipeAction
 import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
 import io.legado.app.ui.widget.components.swipe.SwipeActionContainer
 import io.legado.app.utils.StringUtils.formatFriendlyDate
 import io.legado.app.utils.StringUtils.formatFriendlyDate
@@ -166,19 +184,6 @@ fun ReadRecordScreen(
                         onQueryChange = { viewModel.setSearchKey(it) }
                         onQueryChange = { viewModel.setSearchKey(it) }
                     )
                     )
                 }
                 }
-                AnimatedVisibility(visible = showCalendar) {
-                    CalendarSection(
-                        selectedDate = state.selectedDate,
-                        onDateSelected = { date ->
-                            viewModel.setSelectedDate(date)
-                            showCalendar = false
-                        },
-                        onClearDate = {
-                            viewModel.setSelectedDate(null)
-                            showCalendar = false
-                        }
-                    )
-                }
             }
             }
         }
         }
     ) { padding ->
     ) { padding ->
@@ -286,6 +291,30 @@ fun ReadRecordScreen(
             }
             }
         )
         )
     }
     }
+
+    if (showCalendar) {
+        val sheetState =
+            androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true)
+        androidx.compose.material3.ModalBottomSheet(
+            onDismissRequest = { showCalendar = false },
+            sheetState = sheetState
+        ) {
+            HeatmapCalendarSection(
+                dailyReadCounts = state.dailyReadCounts,
+                dailyReadTimes = state.dailyReadTimes,
+                selectedDate = state.selectedDate,
+                onDateSelected = { date ->
+                    viewModel.setSelectedDate(date)
+                    showCalendar = false
+                },
+                onClearDate = {
+                    viewModel.setSelectedDate(null)
+                    showCalendar = false
+                }
+            )
+        }
+    }
+
 }
 }
 
 
 @Composable
 @Composable
@@ -329,6 +358,139 @@ fun SummarySection(
     }
     }
 }
 }
 
 
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun HeatmapCalendarSection(
+    modifier: Modifier = Modifier,
+    dailyReadCounts: Map<LocalDate, Int>,
+    dailyReadTimes: Map<LocalDate, Long>,
+    selectedDate: LocalDate?,
+    onDateSelected: (LocalDate) -> Unit,
+    onClearDate: () -> Unit,
+    config: HeatmapConfig = HeatmapConfig()
+) {
+    var currentMode by remember { mutableStateOf(HeatmapMode.COUNT) }
+
+    val (startDate, endDate) = rememberDateRange(dailyReadCounts, dailyReadTimes)
+    val days = rememberDaysInRange(startDate, endDate)
+    val weeks = rememberWeeks(days, startDate)
+
+    val listState = rememberLazyListState()
+
+    LaunchedEffect(weeks) {
+        if (weeks.isNotEmpty()) {
+            listState.scrollToItem(weeks.size - 1)
+        }
+    }
+
+    val showLeftGradient by remember {
+        derivedStateOf {
+            listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
+        }
+    }
+    val showRightGradient by remember {
+        derivedStateOf {
+            listState.canScrollForward
+        }
+    }
+
+    val leftAlpha by animateFloatAsState(
+        targetValue = if (showLeftGradient) 1f else 0f,
+        animationSpec = tween(durationMillis = 200),
+        label = "LeftGradientAlpha"
+    )
+
+    val rightAlpha by animateFloatAsState(
+        targetValue = if (showRightGradient) 1f else 0f,
+        animationSpec = tween(durationMillis = 200),
+        label = "RightGradientAlpha"
+    )
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 8.dp)
+            .padding(bottom = 32.dp)
+    ) {
+        HeatmapCalendarTopBar(
+            currentMode = currentMode,
+            onModeChanged = { currentMode = it },
+            onClearDate = onClearDate
+        )
+
+        Spacer(modifier = Modifier.height(8.dp))
+
+        Row(modifier = Modifier.fillMaxWidth()) {
+            WeekdayLabelsColumn(
+                cellSize = config.cellSize,
+                cellSpacing = config.cellSpacing
+            )
+
+            LazyRow(
+                state = listState,
+                horizontalArrangement = Arrangement.spacedBy(config.cellSpacing),
+                modifier = Modifier
+                    .weight(1f)
+                    .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
+                    .drawWithContent {
+                        drawContent()
+
+                        val width = size.width
+                        val gradientWidthPx = config.gradientWidth.toPx()
+                        val leftStop = gradientWidthPx / width
+                        val rightStop = 1f - (gradientWidthPx / width)
+
+                        val colorStops = arrayOf(
+                            0f to Color.Black.copy(alpha = 1f - leftAlpha),
+                            leftStop to Color.Black,
+                            rightStop to Color.Black,
+                            1f to Color.Black.copy(alpha = 1f - rightAlpha)
+                        )
+
+                        drawRect(
+                            brush = Brush.horizontalGradient(
+                                colorStops = colorStops,
+                                startX = 0f,
+                                endX = width
+                            ),
+                            blendMode = BlendMode.DstIn
+                        )
+                    }
+            ) {
+                val firstReadDate = listOfNotNull(
+                    dailyReadCounts.filterValues { it > 0 }.keys.minOrNull(),
+                    dailyReadTimes.filterValues { it > 0L }.keys.minOrNull()
+                ).minOrNull()
+
+                if (firstReadDate != null) {
+                    item {
+                        NoEarlierDataIndicator(cellSize = config.cellSize)
+                    }
+                }
+
+                items(weeks.size) { weekIndex ->
+                    HeatmapWeekColumn(
+                        week = weeks[weekIndex],
+                        mode = currentMode,
+                        dailyReadCounts = dailyReadCounts,
+                        dailyReadTimes = dailyReadTimes,
+                        selectedDate = selectedDate,
+                        config = config,
+                        onDateSelected = onDateSelected
+                    )
+                }
+            }
+        }
+
+        Spacer(modifier = Modifier.height(16.dp))
+
+        HeatmapLegend(
+            mode = currentMode,
+            config = config
+        )
+    }
+}
+
 fun LazyListScope.renderListByMode(
 fun LazyListScope.renderListByMode(
     displayMode: DisplayMode,
     displayMode: DisplayMode,
     state: ReadRecordUiState,
     state: ReadRecordUiState,
@@ -615,22 +777,6 @@ fun DateHeader(
     )
     )
 }
 }
 
 
-@Composable
-fun CalendarSection(
-    selectedDate: LocalDate?,
-    onDateSelected: (LocalDate) -> Unit,
-    onClearDate: () -> Unit
-) {
-    val effectiveInitialDate = selectedDate ?: LocalDate.now()
-    Calendar(
-        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
-        initialDate = effectiveInitialDate,
-        selectedDate = selectedDate,
-        onDateSelected = onDateSelected,
-        onClearDate = onClearDate
-    )
-}
-
 @Composable
 @Composable
 fun ReadingSummaryCard(
 fun ReadingSummaryCard(
     title: String,
     title: String,

+ 18 - 5
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt

@@ -24,14 +24,13 @@ import java.util.Date
 data class ReadRecordUiState(
 data class ReadRecordUiState(
     val isLoading: Boolean = true,
     val isLoading: Boolean = true,
     val totalReadTime: Long = 0,
     val totalReadTime: Long = 0,
-    //每日聚合明细
     val groupedRecords: Map<String, List<ReadRecordDetail>> = emptyMap(),
     val groupedRecords: Map<String, List<ReadRecordDetail>> = emptyMap(),
-    //每日所有阅读会话
     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
+    val searchKey: String? = null,
+    val dailyReadCounts: Map<LocalDate, Int> = emptyMap(),
+    val dailyReadTimes: Map<LocalDate, Long> = emptyMap()
 )
 )
 
 
 enum class DisplayMode {
 enum class DisplayMode {
@@ -71,6 +70,18 @@ class ReadRecordViewModel(
     ) { data, selectedDate, searchKey ->
     ) { data, selectedDate, searchKey ->
         val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
         val dateStr = selectedDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
 
 
+        val dailyCounts = data.details
+            .groupBy { it.date }
+            .mapKeys { LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE) }
+            .mapValues { it.value.size }
+
+        val dailyTimes = data.sessions
+            .groupBy { DateUtil.format(Date(it.startTime), "yyyy-MM-dd") }
+            .mapKeys { LocalDate.parse(it.key, DateTimeFormatter.ISO_LOCAL_DATE) }
+            .mapValues { (_, sessions) ->
+                sessions.sumOf { (it.endTime - it.startTime).coerceAtLeast(0L) }
+            }
+
         val filteredDetails = data.details.filter { detail ->
         val filteredDetails = data.details.filter { detail ->
             dateStr == null || detail.date == dateStr
             dateStr == null || detail.date == dateStr
         }
         }
@@ -97,7 +108,9 @@ class ReadRecordViewModel(
             timelineRecords = timelineMap,
             timelineRecords = timelineMap,
             latestRecords = data.latestRecords,
             latestRecords = data.latestRecords,
             selectedDate = selectedDate,
             selectedDate = selectedDate,
-            searchKey = searchKey
+            searchKey = searchKey,
+            dailyReadCounts = dailyCounts,
+            dailyReadTimes = dailyTimes
         )
         )
     }.stateIn(
     }.stateIn(
         scope = viewModelScope,
         scope = viewModelScope,

+ 4 - 2
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -160,7 +160,8 @@ fun SearchContentScreen(
                                         )
                                         )
                                     }
                                     }
                                 },
                                 },
-                                icon = Icons.Default.FindReplace,
+                                iconChecked = Icons.Default.FindReplace,
+                                iconUnchecked = Icons.Default.FindReplace,
                                 activeText = "替换开启",
                                 activeText = "替换开启",
                                 inactiveText = "替换关闭"
                                 inactiveText = "替换关闭"
                             )
                             )
@@ -177,7 +178,8 @@ fun SearchContentScreen(
                                         )
                                         )
                                     }
                                     }
                                 },
                                 },
-                                icon = Icons.Default.Code,
+                                iconChecked = Icons.Default.Code,
+                                iconUnchecked = Icons.Default.Code,
                                 activeText = "正则开启",
                                 activeText = "正则开启",
                                 inactiveText = "正则关闭"
                                 inactiveText = "正则关闭"
                             )
                             )

+ 19 - 7
app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButton.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.widget.components.button
 package io.legado.app.ui.widget.components.button
 
 
+import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.PaddingValues
@@ -27,12 +28,15 @@ import kotlinx.coroutines.delay
 fun AnimatedActionButton(
 fun AnimatedActionButton(
     checked: Boolean,
     checked: Boolean,
     onCheckedChange: (Boolean) -> Unit,
     onCheckedChange: (Boolean) -> Unit,
-    icon: ImageVector,
+    iconChecked: ImageVector,
+    iconUnchecked: ImageVector,
     activeText: String,
     activeText: String,
     inactiveText: String
     inactiveText: String
 ) {
 ) {
 
 
     var showText by remember { mutableStateOf(false) }
     var showText by remember { mutableStateOf(false) }
+    var lastCheckedState by remember { mutableStateOf(checked) }
+
     LaunchedEffect(showText) {
     LaunchedEffect(showText) {
         if (showText) {
         if (showText) {
             delay(1000)
             delay(1000)
@@ -44,6 +48,7 @@ fun AnimatedActionButton(
         contentPadding = PaddingValues(4.dp),
         contentPadding = PaddingValues(4.dp),
         checked = checked,
         checked = checked,
         onCheckedChange = {
         onCheckedChange = {
+            lastCheckedState = it
             onCheckedChange(it)
             onCheckedChange(it)
             showText = true
             showText = true
         }
         }
@@ -51,18 +56,25 @@ fun AnimatedActionButton(
         Row(
         Row(
             verticalAlignment = Alignment.CenterVertically,
             verticalAlignment = Alignment.CenterVertically,
             horizontalArrangement = Arrangement.Center,
             horizontalArrangement = Arrangement.Center,
-            modifier = Modifier.padding(horizontal = 4.dp)
+            modifier = Modifier
+                .padding(horizontal = 4.dp)
         ) {
         ) {
-            Icon(
-                imageVector = icon,
-                contentDescription = null
-            )
+
+            AnimatedContent(
+                targetState = checked,
+                label = "IconAnimation"
+            ) { targetChecked ->
+                Icon(
+                    imageVector = if (targetChecked) iconChecked else iconUnchecked,
+                    contentDescription = null
+                )
+            }
 
 
             AnimatedVisibility(
             AnimatedVisibility(
                 visible = showText
                 visible = showText
             ) {
             ) {
                 Text(
                 Text(
-                    text = if (checked) activeText else inactiveText,
+                    text = if (lastCheckedState) activeText else inactiveText,
                     style = MaterialTheme.typography.labelMedium,
                     style = MaterialTheme.typography.labelMedium,
                     modifier = Modifier.padding(start = 8.dp),
                     modifier = Modifier.padding(start = 8.dp),
                     maxLines = 1,
                     maxLines = 1,

+ 307 - 0
app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt

@@ -0,0 +1,307 @@
+package io.legado.app.ui.widget.components.heatmap
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.FormatListNumbered
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedIconButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.legado.app.ui.widget.components.button.AnimatedActionButton
+import java.time.LocalDate
+
+/**
+ * 热力图日历顶部操作栏
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun HeatmapCalendarTopBar(
+    currentMode: HeatmapMode,
+    onModeChanged: (HeatmapMode) -> Unit,
+    onClearDate: () -> Unit,
+    modifier: Modifier = Modifier
+) {
+    Row(
+        modifier = modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.SpaceBetween,
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        Text("时间线", style = MaterialTheme.typography.titleMedium)
+
+        Row(verticalAlignment = Alignment.CenterVertically) {
+            AnimatedActionButton(
+                checked = currentMode == HeatmapMode.TIME,
+                onCheckedChange = {
+                    onModeChanged(if (it) HeatmapMode.TIME else HeatmapMode.COUNT)
+                },
+                iconChecked = Icons.Default.AccessTime,
+                iconUnchecked = Icons.Default.FormatListNumbered,
+                activeText = "按时长",
+                inactiveText = "按次数"
+            )
+
+            Spacer(modifier = Modifier.width(8.dp))
+
+            OutlinedIconButton(
+                onClick = onClearDate,
+                shapes = IconButtonDefaults.shapes(),
+                border = ButtonDefaults.outlinedButtonBorder()
+            ) {
+                Icon(
+                    imageVector = Icons.Outlined.Delete,
+                    contentDescription = "清除选择"
+                )
+            }
+        }
+    }
+}
+
+/**
+ * 星期标签列(周一到周日)
+ */
+@Composable
+fun WeekdayLabelsColumn(
+    cellSize: Dp,
+    cellSpacing: Dp,
+    modifier: Modifier = Modifier
+) {
+    Column(
+        modifier = modifier
+            .padding(top = 20.dp, end = 8.dp)
+    ) {
+        val labels = listOf("一", "二", "三", "四", "五", "六", "日")
+
+        labels.forEachIndexed { index, label ->
+            if (index % 2 == 0) {
+                Text(
+                    text = label,
+                    fontSize = 10.sp,
+                    color = MaterialTheme.colorScheme.onSurfaceVariant,
+                    modifier = Modifier.height(cellSize)
+                )
+            } else {
+                Spacer(modifier = Modifier.height(cellSize))
+            }
+
+            if (index < 6) Spacer(modifier = Modifier.height(cellSpacing))
+        }
+    }
+}
+
+/**
+ * 没有更早数据的提示组件
+ */
+@Composable
+fun NoEarlierDataIndicator(
+    cellSize: Dp,
+    modifier: Modifier = Modifier
+) {
+    val outlineColor = MaterialTheme.colorScheme.outlineVariant
+
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        modifier = modifier
+            .padding(top = 24.dp, start = 8.dp, end = 16.dp)
+    ) {
+        Column(
+            verticalArrangement = Arrangement.spacedBy(4.dp)
+        ) {
+            repeat(7) {
+                Box(
+                    modifier = Modifier
+                        .size(cellSize)
+                        .drawBehind {
+                            val strokeWidth = 1.dp.toPx()
+                            val stroke = Stroke(
+                                width = strokeWidth,
+                                pathEffect = PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f)
+                            )
+                            val inset = strokeWidth / 2
+                            drawRoundRect(
+                                color = outlineColor,
+                                style = stroke,
+                                topLeft = Offset(inset, inset),
+                                size = Size(size.width - strokeWidth, size.height - strokeWidth),
+                                cornerRadius = CornerRadius(4.dp.toPx())
+                            )
+                        }
+                )
+            }
+        }
+
+        Spacer(modifier = Modifier.width(16.dp))
+
+        Column(
+            horizontalAlignment = Alignment.CenterHorizontally,
+            verticalArrangement = Arrangement.Center
+        ) {
+            "没有更早数据".forEach { char ->
+                Text(
+                    text = char.toString(),
+                    fontSize = 9.sp,
+                    lineHeight = 12.sp,
+                    fontWeight = FontWeight.Light
+                )
+            }
+        }
+    }
+}
+
+/**
+ * 单个日历单元格
+ */
+@Composable
+fun HeatmapCalendarCell(
+    day: LocalDate,
+    mode: HeatmapMode,
+    dailyReadCounts: Map<LocalDate, Int>,
+    dailyReadTimes: Map<LocalDate, Long>,
+    isSelected: Boolean,
+    config: HeatmapConfig,
+    onDateSelected: (LocalDate) -> Unit,
+    modifier: Modifier = Modifier
+) {
+    val level = rememberHeatmapLevel(day, mode, dailyReadCounts, dailyReadTimes)
+    val cellColor = heatmapColorForLevel(level)
+
+    Box(
+        modifier = modifier
+            .size(config.cellSize)
+            .clip(RoundedCornerShape(config.cornerRadius))
+            .background(cellColor)
+            .border(
+                width = if (isSelected) 2.dp else 0.dp,
+                color = if (isSelected) MaterialTheme.colorScheme.onSurface else Color.Transparent,
+                shape = RoundedCornerShape(config.cornerRadius)
+            )
+            .clickable { onDateSelected(day) }
+    )
+}
+
+/**
+ * 一周的日历列(包含月份标签)
+ */
+@Composable
+fun HeatmapWeekColumn(
+    week: List<LocalDate?>,
+    mode: HeatmapMode,
+    dailyReadCounts: Map<LocalDate, Int>,
+    dailyReadTimes: Map<LocalDate, Long>,
+    selectedDate: LocalDate?,
+    config: HeatmapConfig,
+    onDateSelected: (LocalDate) -> Unit,
+    modifier: Modifier = Modifier
+) {
+    val firstDayOfMonth = week.firstOrNull { it?.dayOfMonth == 1 }
+
+    Box(modifier = modifier.width(config.cellSize)) {
+        Column(
+            modifier = Modifier.padding(top = 24.dp),
+            verticalArrangement = Arrangement.spacedBy(config.cellSpacing)
+        ) {
+            week.forEach { day ->
+                if (day == null) {
+                    Spacer(modifier = Modifier.size(config.cellSize))
+                } else {
+                    HeatmapCalendarCell(
+                        day = day,
+                        mode = mode,
+                        dailyReadCounts = dailyReadCounts,
+                        dailyReadTimes = dailyReadTimes,
+                        isSelected = day == selectedDate,
+                        config = config,
+                        onDateSelected = onDateSelected
+                    )
+                }
+            }
+        }
+
+        // 月份标签
+        if (firstDayOfMonth != null) {
+            Text(
+                text = "${firstDayOfMonth.monthValue}月",
+                fontSize = 10.sp,
+                color = MaterialTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier
+                    .align(Alignment.TopStart)
+                    .wrapContentWidth(unbounded = true)
+            )
+        }
+    }
+}
+
+/**
+ * 热力图图例
+ */
+@Composable
+fun HeatmapLegend(
+    mode: HeatmapMode,
+    config: HeatmapConfig,
+    modifier: Modifier = Modifier
+) {
+    Row(
+        modifier = modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.End,
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        val legendUnit = if (mode == HeatmapMode.COUNT) "次" else "长"
+
+        Text(
+            "少($legendUnit)",
+            style = MaterialTheme.typography.bodySmall,
+            color = Color.Gray
+        )
+
+        Spacer(modifier = Modifier.width(4.dp))
+
+        for (level in 0..4) {
+            Box(
+                modifier = Modifier
+                    .size(config.legendSize)
+                    .clip(RoundedCornerShape(2.dp))
+                    .background(heatmapColorForLevel(level))
+            )
+            Spacer(modifier = Modifier.width(4.dp))
+        }
+
+        Text(
+            "多($legendUnit)",
+            style = MaterialTheme.typography.bodySmall,
+            color = Color.Gray
+        )
+    }
+}

+ 16 - 0
app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapMode.kt

@@ -0,0 +1,16 @@
+package io.legado.app.ui.widget.components.heatmap
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+enum class HeatmapMode {
+    COUNT, TIME
+}
+
+data class HeatmapConfig(
+    val cellSize: Dp = 16.dp,
+    val cellSpacing: Dp = 4.dp,
+    val cornerRadius: Dp = 4.dp,
+    val gradientWidth: Dp = 16.dp,
+    val legendSize: Dp = 12.dp
+)

+ 117 - 0
app/src/main/java/io/legado/app/ui/widget/components/heatmap/RememberDateUtil.kt

@@ -0,0 +1,117 @@
+package io.legado.app.ui.widget.components.heatmap
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.Color
+import java.time.LocalDate
+
+/**
+ * 计算日期范围:从最早有数据的日期前1个月到最晚有数据的日期后1个月
+ */
+@Composable
+fun rememberDateRange(
+    dailyReadCounts: Map<LocalDate, Int>,
+    dailyReadTimes: Map<LocalDate, Long>
+): Pair<LocalDate, LocalDate> {
+    return remember(dailyReadCounts, dailyReadTimes) {
+        val firstReadDate = listOfNotNull(
+            dailyReadCounts.filterValues { it > 0 }.keys.minOrNull(),
+            dailyReadTimes.filterValues { it > 0L }.keys.minOrNull()
+        ).minOrNull()
+
+        val lastReadDate = listOfNotNull(
+            dailyReadCounts.filterValues { it > 0 }.keys.maxOrNull(),
+            dailyReadTimes.filterValues { it > 0L }.keys.maxOrNull()
+        ).maxOrNull()
+
+        val startDate = firstReadDate?.minusMonths(1) ?: LocalDate.now()
+        val endDate = lastReadDate ?: startDate
+
+        startDate to endDate
+    }
+}
+
+/**
+ * 生成日期范围内的所有日期列表
+ */
+@Composable
+fun rememberDaysInRange(startDate: LocalDate, endDate: LocalDate): List<LocalDate> {
+    return remember(startDate, endDate) {
+        val list = mutableListOf<LocalDate>()
+        var current = startDate
+        while (!current.isAfter(endDate)) {
+            list.add(current)
+            current = current.plusDays(1)
+        }
+        list
+    }
+}
+
+/**
+ * 将日期列表按周分组(补全第一周的空白天数)
+ */
+@Composable
+fun rememberWeeks(days: List<LocalDate>, startDate: LocalDate): List<List<LocalDate?>> {
+    return remember(days, startDate) {
+        val firstDayOfWeekOffset = startDate.dayOfWeek.value - 1 // 周一为1
+        val padded = List(firstDayOfWeekOffset) { null } + days
+        padded.chunked(7)
+    }
+}
+
+/**
+ * 计算日期对应的热力图等级(0-4)
+ */
+@Composable
+fun rememberHeatmapLevel(
+    day: LocalDate,
+    mode: HeatmapMode,
+    dailyReadCounts: Map<LocalDate, Int>,
+    dailyReadTimes: Map<LocalDate, Long>
+): Int {
+    val maxCount by remember(dailyReadCounts) {
+        derivedStateOf { dailyReadCounts.values.maxOrNull()?.coerceAtLeast(1) ?: 1 }
+    }
+    val maxTime by remember(dailyReadTimes) {
+        derivedStateOf { dailyReadTimes.values.maxOrNull()?.coerceAtLeast(1L) ?: 1L }
+    }
+
+    return remember(day, mode, maxCount, maxTime) {
+        val fraction = if (mode == HeatmapMode.COUNT) {
+            val value = dailyReadCounts[day] ?: 0
+            value.toFloat() / maxCount
+        } else {
+            val value = dailyReadTimes[day] ?: 0L
+            value.toFloat() / maxTime
+        }
+
+        when {
+            fraction == 0f -> 0
+            fraction < 0.25f -> 1
+            fraction < 0.5f -> 2
+            fraction < 0.75f -> 3
+            else -> 4
+        }
+    }
+}
+
+/**
+ * 根据等级获取对应的颜色
+ */
+@Composable
+fun heatmapColorForLevel(
+    level: Int,
+    primary: Color = MaterialTheme.colorScheme.primary,
+    emptyColor: Color = MaterialTheme.colorScheme.surfaceVariant
+): Color {
+    return when (level) {
+        0 -> emptyColor
+        1 -> primary.copy(alpha = 0.35f)
+        2 -> primary.copy(alpha = 0.55f)
+        3 -> primary.copy(alpha = 0.75f)
+        else -> primary
+    }
+}

+ 37 - 0
app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt

@@ -0,0 +1,37 @@
+package io.legado.app.ui.widget.components.menuItem
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun RoundDropdownMenu(
+    expanded: Boolean,
+    onDismissRequest: () -> Unit,
+    modifier: Modifier = Modifier,
+    shape: Shape = MaterialTheme.shapes.medium,
+    shadowElevation: Dp = 4.dp,
+    verticalSpacing: Dp = 8.dp,
+    content: @Composable ColumnScope.(dismiss: () -> Unit) -> Unit
+) {
+    DropdownMenu(
+        expanded = expanded,
+        onDismissRequest = onDismissRequest,
+        modifier = modifier,
+        shape = shape,
+        shadowElevation = shadowElevation
+    ) {
+        Column(
+            verticalArrangement = Arrangement.spacedBy(verticalSpacing)
+        ) {
+            content(onDismissRequest)
+        }
+    }
+}

+ 4 - 13
app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt

@@ -5,7 +5,6 @@ import androidx.compose.animation.expandVertically
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.shrinkVertically
 import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.ColumnScope
@@ -15,11 +14,9 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Search
-import androidx.compose.material3.DropdownMenu
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.TopAppBarScrollBehavior
 import androidx.compose.material3.TopAppBarScrollBehavior
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
@@ -28,12 +25,12 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
 import io.legado.app.ui.widget.components.AdaptiveAnimatedText
 import io.legado.app.ui.widget.components.AdaptiveAnimatedText
 import io.legado.app.ui.widget.components.AnimatedTextLine
 import io.legado.app.ui.widget.components.AnimatedTextLine
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.ui.widget.components.SearchBarSection
 import io.legado.app.ui.widget.components.button.SmallTopBarButton
 import io.legado.app.ui.widget.components.button.SmallTopBarButton
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.rules.RuleActionState
 import io.legado.app.ui.widget.components.rules.RuleActionState
 
 
 
 
@@ -97,17 +94,11 @@ fun <T> DynamicTopAppBar(
                             IconButton(onClick = { showMenu = true }) {
                             IconButton(onClick = { showMenu = true }) {
                                 Icon(Icons.Default.MoreVert, null)
                                 Icon(Icons.Default.MoreVert, null)
                             }
                             }
-                            DropdownMenu(
+                            RoundDropdownMenu(
                                 expanded = showMenu,
                                 expanded = showMenu,
-                                shape = MaterialTheme.shapes.medium,
-                                shadowElevation = 4.dp,
                                 onDismissRequest = { showMenu = false }
                                 onDismissRequest = { showMenu = false }
-                            ) {
-                                Column(
-                                    verticalArrangement = Arrangement.spacedBy(8.dp)
-                                ) {
-                                    content { showMenu = false }
-                                }
+                            ) { dismiss ->
+                                content(dismiss)
                             }
                             }
                         }
                         }
                     }
                     }