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

This commit is contained in:
HapeLee committed 2026-02-21 00:47:56 +08:00
1 parent b31f56b953
commit 47a8e12f78
10 files changed
+698 -74

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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 = "正则关闭"
) )
@@ -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, AnimatedContent(
contentDescription = null 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,
@@ -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
)
}
}
@@ -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
)
@@ -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
}
}
@@ -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)
}
}
}
@@ -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 }
) { ) { dismiss ->
Column( content(dismiss)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
content { showMenu = false }
}
} }
} }
} }