[优化] 新的阅读记录日期选择,现在可以方便的查看每天的阅读情况
This commit is contained in:
@@ -55,23 +55,6 @@ data class BookmarkUiState(
|
||||
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(
|
||||
application: Application,
|
||||
|
||||
@@ -2,6 +2,8 @@ package io.legado.app.ui.book.readRecord
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
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.clickable
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -43,6 +46,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@@ -52,9 +56,14 @@ 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.draw.drawWithContent
|
||||
import androidx.compose.ui.draw.rotate
|
||||
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.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.ReadRecordDetail
|
||||
import io.legado.app.ui.widget.components.AnimatedTextLine
|
||||
import io.legado.app.ui.widget.components.Calendar
|
||||
import io.legado.app.ui.widget.components.Cover
|
||||
import io.legado.app.ui.widget.components.EmptyMessageView
|
||||
import io.legado.app.ui.widget.components.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.button.AlertButton
|
||||
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.SwipeActionContainer
|
||||
import io.legado.app.utils.StringUtils.formatFriendlyDate
|
||||
@@ -166,19 +184,6 @@ fun ReadRecordScreen(
|
||||
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 ->
|
||||
@@ -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
|
||||
@@ -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(
|
||||
displayMode: DisplayMode,
|
||||
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
|
||||
fun ReadingSummaryCard(
|
||||
title: String,
|
||||
|
||||
@@ -24,14 +24,13 @@ import java.util.Date
|
||||
data class ReadRecordUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val totalReadTime: Long = 0,
|
||||
//每日聚合明细
|
||||
val groupedRecords: Map<String, List<ReadRecordDetail>> = emptyMap(),
|
||||
//每日所有阅读会话
|
||||
val timelineRecords: Map<String, List<ReadRecordSession>> = emptyMap(),
|
||||
//最后阅读列表
|
||||
val latestRecords: List<ReadRecord> = emptyList(),
|
||||
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 {
|
||||
@@ -71,6 +70,18 @@ class ReadRecordViewModel(
|
||||
) { data, selectedDate, searchKey ->
|
||||
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 ->
|
||||
dateStr == null || detail.date == dateStr
|
||||
}
|
||||
@@ -97,7 +108,9 @@ class ReadRecordViewModel(
|
||||
timelineRecords = timelineMap,
|
||||
latestRecords = data.latestRecords,
|
||||
selectedDate = selectedDate,
|
||||
searchKey = searchKey
|
||||
searchKey = searchKey,
|
||||
dailyReadCounts = dailyCounts,
|
||||
dailyReadTimes = dailyTimes
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
|
||||
@@ -160,7 +160,8 @@ fun SearchContentScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = Icons.Default.FindReplace,
|
||||
iconChecked = Icons.Default.FindReplace,
|
||||
iconUnchecked = Icons.Default.FindReplace,
|
||||
activeText = "替换开启",
|
||||
inactiveText = "替换关闭"
|
||||
)
|
||||
@@ -177,7 +178,8 @@ fun SearchContentScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
icon = Icons.Default.Code,
|
||||
iconChecked = Icons.Default.Code,
|
||||
iconUnchecked = Icons.Default.Code,
|
||||
activeText = "正则开启",
|
||||
inactiveText = "正则关闭"
|
||||
)
|
||||
|
||||
+19
-7
@@ -1,5 +1,6 @@
|
||||
package io.legado.app.ui.widget.components.button
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -27,12 +28,15 @@ import kotlinx.coroutines.delay
|
||||
fun AnimatedActionButton(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
icon: ImageVector,
|
||||
iconChecked: ImageVector,
|
||||
iconUnchecked: ImageVector,
|
||||
activeText: String,
|
||||
inactiveText: String
|
||||
) {
|
||||
|
||||
var showText by remember { mutableStateOf(false) }
|
||||
var lastCheckedState by remember { mutableStateOf(checked) }
|
||||
|
||||
LaunchedEffect(showText) {
|
||||
if (showText) {
|
||||
delay(1000)
|
||||
@@ -44,6 +48,7 @@ fun AnimatedActionButton(
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
lastCheckedState = it
|
||||
onCheckedChange(it)
|
||||
showText = true
|
||||
}
|
||||
@@ -51,18 +56,25 @@ fun AnimatedActionButton(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
visible = showText
|
||||
) {
|
||||
Text(
|
||||
text = if (checked) activeText else inactiveText,
|
||||
text = if (lastCheckedState) activeText else inactiveText,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
maxLines = 1,
|
||||
|
||||
+307
@@ -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.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -28,12 +25,12 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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.AnimatedTextLine
|
||||
import io.legado.app.ui.widget.components.GlassMediumFlexibleTopAppBar
|
||||
import io.legado.app.ui.widget.components.SearchBarSection
|
||||
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
|
||||
|
||||
|
||||
@@ -97,17 +94,11 @@ fun <T> DynamicTopAppBar(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, null)
|
||||
}
|
||||
DropdownMenu(
|
||||
RoundDropdownMenu(
|
||||
expanded = showMenu,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
shadowElevation = 4.dp,
|
||||
onDismissRequest = { showMenu = false }
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
content { showMenu = false }
|
||||
}
|
||||
) { dismiss ->
|
||||
content(dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user