diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt index 4a20985..189ea8a 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapCanvas.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rainy.token.ui.theme.inkMuted @@ -191,6 +193,10 @@ private fun filterMonthLabels(rawLabels: List, totalWeeks: Int): Lis fun HeatmapCanvas( state: HeatmapUiState, modifier: Modifier = Modifier, + noDataText: String = "", + lessText: String = "", + moreText: String = "", + accessibilityDesc: String = "", onDayClick: (HeatmapDayData) -> Unit = {}, onWeekClick: (HeatmapWeekData) -> Unit = {}, ) { @@ -240,7 +246,7 @@ fun HeatmapCanvas( if (state.loading) { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } else { - Text("暂无数据", color = mutedColor, fontSize = 14.sp) + Text(noDataText.ifEmpty { "No data" }, color = mutedColor, fontSize = 14.sp) } } return @@ -265,7 +271,9 @@ fun HeatmapCanvas( } Column( - modifier = modifier.horizontalScroll(rememberScrollState()), + modifier = modifier + .horizontalScroll(rememberScrollState()) + .semantics { contentDescription = accessibilityDesc.ifEmpty { "Token activity heatmap, ${if (isWeekly) state.weeklyData.size else state.dailyData.size} days" } }, ) { // ── 热力图网格 + 月份标签 ── Canvas( @@ -344,7 +352,7 @@ fun HeatmapCanvas( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { - Text("少", color = mutedColor, fontSize = 12.sp) + Text(lessText.ifEmpty { "Less" }, color = mutedColor, fontSize = 12.sp) Spacer(Modifier.width(4.dp)) for (level in 0..5) { Box( @@ -357,7 +365,7 @@ fun HeatmapCanvas( } } Spacer(Modifier.width(4.dp)) - Text("多", color = mutedColor, fontSize = 12.sp) + Text(moreText.ifEmpty { "More" }, color = mutedColor, fontSize = 12.sp) } } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt index a63a23e..1cbdccf 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt @@ -1,5 +1,7 @@ package com.rainy.token.ui.heatmap +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,22 +18,31 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Popup +import androidx.compose.material3.PopupProperties import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.ui.theme.inkMuted +import java.time.Instant +import java.time.ZoneOffset /** * Token 活动热力图页面。 @@ -46,6 +57,16 @@ fun HeatmapScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + // ── 浮层状态 ── + var selectedDay: HeatmapDayData? by remember { mutableStateOf(null) } + var selectedWeek: HeatmapWeekData? by remember { mutableStateOf(null) } + + // ── i18n 字符串 ── + val noDataText = stringResource(R.string.heatmap_no_data) + val lessText = stringResource(R.string.heatmap_legend_less) + val moreText = stringResource(R.string.heatmap_legend_more) + val accessibilityDesc = stringResource(R.string.heatmap_accessibility_desc, if (state.viewMode == HeatmapViewMode.WEEKLY) state.weeklyData.size else state.dailyData.size) + LaunchedEffect(Unit) { viewModel.load() } @@ -88,23 +109,118 @@ fun HeatmapScreen( style = MaterialTheme.typography.bodyMedium, fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, color = if (isSelected) MaterialTheme.colorScheme.onSurface else inkMuted(), - modifier = Modifier.clickable { viewModel.setViewMode(mode) } + modifier = Modifier.clickable { + viewModel.setViewMode(mode) + // 切换视图时关闭浮层 + selectedDay = null + selectedWeek = null + } ) } } - // ── 热力图 Canvas ── - HeatmapCanvas( - state = state, - onDayClick = { dayData -> - // Phase 4: 显示浮层 - }, - onWeekClick = { weekData -> - // Phase 4: 显示浮层 - } - ) + // ── 热力图 Canvas(带 Crossfade 淡入淡出动画)── + Crossfade( + targetState = state.viewMode, + animationSpec = tween(150), + label = "heatmap_view" + ) { mode -> + HeatmapCanvas( + state = state, + noDataText = noDataText, + lessText = lessText, + moreText = moreText, + accessibilityDesc = accessibilityDesc, + onDayClick = { dayData -> + // 再次点击同一格子则关闭浮层 + if (selectedDay == dayData) { + selectedDay = null + } else { + selectedDay = dayData + selectedWeek = null + } + }, + onWeekClick = { weekData -> + if (selectedWeek == weekData) { + selectedWeek = null + } else { + selectedWeek = weekData + selectedDay = null + } + } + ) + } Spacer(modifier = Modifier.height(32.dp)) } } + + // ── 浮层 Popup ── + val day = selectedDay + val week = selectedWeek + if (day != null || week != null) { + val popupText = when { + day != null -> "${formatDateChinese(day.dayTs)} 使用了${formatTokenChinese(day.tokens)}token" + week != null -> { + val start = formatDateChinese(week.weekStartTs) + val endTs = week.weekStartTs + 6L * 86_400_000L + val end = formatDateChinese(endTs) + "$start-$end 使用了${formatTokenChinese(week.tokens)}token" + } + else -> "" + } + Popup( + alignment = androidx.compose.ui.Alignment.TopCenter, + onDismissRequest = { + selectedDay = null + selectedWeek = null + }, + properties = PopupProperties(focusable = true) + ) { + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + tonalElevation = 3.dp, + shadowElevation = 6.dp, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + text = popupText, + fontSize = 14.sp, + modifier = Modifier.padding(12.dp) + ) + } + } + } +} + +// ── 格式化辅助函数 ────────────────────────────────────────── + +/** + * 将 Token 数按中文数量级格式化: + * - 亿级(≥1亿):1.9亿 + * - 万级(≥1万):11.2万 / 1124.5万 + * - 万以下:原始数字 + */ +private fun formatTokenChinese(tokens: Long): String { + return when { + tokens >= 1_0000_0000 -> { + val v = tokens / 1_0000_0000.0 + if (v >= 100) "${v.toInt()}亿" + else "${"%.1f".format(v)}亿" + } + tokens >= 1_0000 -> { + val v = tokens / 1_0000.0 + "${"%.1f".format(v)}万" // 始终保留一位小数 + } + else -> "$tokens" + } +} + +/** + * 将 UTC 时间戳格式化为 "7月9日" 格式(月+日,不加年)。 + */ +private fun formatDateChinese(ts: Long): String { + val instant = Instant.ofEpochMilli(ts) + val date = instant.atOffset(ZoneOffset.UTC).toLocalDate() + return "${date.monthValue}月${date.dayOfMonth}日" } \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index e19d5ef..5066ba1 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -503,4 +503,5 @@ 暂无数据 少 多 + Token 活动热力图,%1$d 天数据 \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index d4fb0f2..87ab95b 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -503,4 +503,5 @@ 暫無資料 少 多 + Token 活動熱力圖,%1$d 天資料 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5ddda63..697e447 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -506,4 +506,5 @@ No data yet Less More + Token activity heatmap, %1$d days