feat(heatmap): add popup tooltip, crossfade animation, accessibility, i18n

- Click cell shows popup with date and token count in Chinese format
- Weekly popup shows date range (start-end)
- Crossfade 150ms between view mode switches
- Canvas i18n: noDataText/lessText/moreText parameters
- Accessibility: semantics contentDescription on Canvas
- formatTokenChinese: 亿/万 Chinese units with decimal precision
- Popup: focusable, auto-dismiss, toggle on re-click
- heatmap_accessibility_desc string in EN/zh-Hans/zh-Hant
This commit is contained in:
WaterRain 2026-08-06 07:45:15 +00:00
parent dd91e94805
commit 77cb664590
No known key found for this signature in database
5 changed files with 142 additions and 15 deletions

View File

@ -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<MonthLabel>, 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)
}
}
}

View File

@ -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}日"
}

View File

@ -503,4 +503,5 @@
<string name="heatmap_no_data">暂无数据</string>
<string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string>
<string name="heatmap_accessibility_desc">Token 活动热力图,%1$d 天数据</string>
</resources>

View File

@ -503,4 +503,5 @@
<string name="heatmap_no_data">暫無資料</string>
<string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string>
<string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string>
</resources>

View File

@ -506,4 +506,5 @@
<string name="heatmap_no_data">No data yet</string>
<string name="heatmap_legend_less">Less</string>
<string name="heatmap_legend_more">More</string>
<string name="heatmap_accessibility_desc">Token activity heatmap, %1$d days</string>
</resources>