diff --git a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt index de98c77f8..f300793ff 100644 --- a/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkViewModel.kt @@ -55,23 +55,6 @@ data class BookmarkUiState( val collapsedGroups: Set = 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, diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt index 533101251..5ef204f9e 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt +++ b/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.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, + dailyReadTimes: Map, + 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, diff --git a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt index d24528709..669b3b74d 100644 --- a/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordViewModel.kt @@ -24,14 +24,13 @@ import java.util.Date data class ReadRecordUiState( val isLoading: Boolean = true, val totalReadTime: Long = 0, - //每日聚合明细 val groupedRecords: Map> = emptyMap(), - //每日所有阅读会话 val timelineRecords: Map> = emptyMap(), - //最后阅读列表 val latestRecords: List = emptyList(), val selectedDate: LocalDate? = null, - val searchKey: String? = null + val searchKey: String? = null, + val dailyReadCounts: Map = emptyMap(), + val dailyReadTimes: Map = 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, diff --git a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt b/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt index 62b691064..4860b4d8f 100644 --- a/app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt +++ b/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 = "替换开启", inactiveText = "替换关闭" ) @@ -177,7 +178,8 @@ fun SearchContentScreen( ) } }, - icon = Icons.Default.Code, + iconChecked = Icons.Default.Code, + iconUnchecked = Icons.Default.Code, activeText = "正则开启", inactiveText = "正则关闭" ) diff --git a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButton.kt b/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButton.kt index 32b4f0aea..261984e98 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/button/AnimatedActionButton.kt +++ b/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 +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, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt b/app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt new file mode 100644 index 000000000..20815a1e3 --- /dev/null +++ b/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, + dailyReadTimes: Map, + 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, + mode: HeatmapMode, + dailyReadCounts: Map, + dailyReadTimes: Map, + 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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapMode.kt b/app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapMode.kt new file mode 100644 index 000000000..f6e525c6a --- /dev/null +++ b/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 +) \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/heatmap/RememberDateUtil.kt b/app/src/main/java/io/legado/app/ui/widget/components/heatmap/RememberDateUtil.kt new file mode 100644 index 000000000..74b5359de --- /dev/null +++ b/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, + dailyReadTimes: Map +): Pair { + 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 { + return remember(startDate, endDate) { + val list = mutableListOf() + var current = startDate + while (!current.isAfter(endDate)) { + list.add(current) + current = current.plusDays(1) + } + list + } +} + +/** + * 将日期列表按周分组(补全第一周的空白天数) + */ +@Composable +fun rememberWeeks(days: List, startDate: LocalDate): List> { + 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, + dailyReadTimes: Map +): 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt b/app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt new file mode 100644 index 000000000..b4a4b478f --- /dev/null +++ b/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) + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt b/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt index 6ea9aba7e..30d527f03 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt +++ b/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.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 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) } } }