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.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
@ -191,6 +193,10 @@ private fun filterMonthLabels(rawLabels: List<MonthLabel>, totalWeeks: Int): Lis
fun HeatmapCanvas( fun HeatmapCanvas(
state: HeatmapUiState, state: HeatmapUiState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
noDataText: String = "",
lessText: String = "",
moreText: String = "",
accessibilityDesc: String = "",
onDayClick: (HeatmapDayData) -> Unit = {}, onDayClick: (HeatmapDayData) -> Unit = {},
onWeekClick: (HeatmapWeekData) -> Unit = {}, onWeekClick: (HeatmapWeekData) -> Unit = {},
) { ) {
@ -240,7 +246,7 @@ fun HeatmapCanvas(
if (state.loading) { if (state.loading) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} else { } else {
Text("暂无数据", color = mutedColor, fontSize = 14.sp) Text(noDataText.ifEmpty { "No data" }, color = mutedColor, fontSize = 14.sp)
} }
} }
return return
@ -265,7 +271,9 @@ fun HeatmapCanvas(
} }
Column( 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( Canvas(
@ -344,7 +352,7 @@ fun HeatmapCanvas(
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text("少", color = mutedColor, fontSize = 12.sp) Text(lessText.ifEmpty { "Less" }, color = mutedColor, fontSize = 12.sp)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
for (level in 0..5) { for (level in 0..5) {
Box( Box(
@ -357,7 +365,7 @@ fun HeatmapCanvas(
} }
} }
Spacer(Modifier.width(4.dp)) 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 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -16,22 +18,31 @@ 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.MaterialTheme
import androidx.compose.material3.Popup
import androidx.compose.material3.PopupProperties
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults 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.getValue 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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 androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
import java.time.Instant
import java.time.ZoneOffset
/** /**
* Token 活动热力图页面。 * Token 活动热力图页面。
@ -46,6 +57,16 @@ fun HeatmapScreen(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() 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) { LaunchedEffect(Unit) {
viewModel.load() viewModel.load()
} }
@ -88,23 +109,118 @@ fun HeatmapScreen(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.onSurface else inkMuted(), 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 ── // ── 热力图 Canvas(带 Crossfade 淡入淡出动画)──
HeatmapCanvas( Crossfade(
state = state, targetState = state.viewMode,
onDayClick = { dayData -> animationSpec = tween(150),
// Phase 4: 显示浮层 label = "heatmap_view"
}, ) { mode ->
onWeekClick = { weekData -> HeatmapCanvas(
// Phase 4: 显示浮层 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)) 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_no_data">暂无数据</string>
<string name="heatmap_legend_less">少</string> <string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string> <string name="heatmap_legend_more">多</string>
<string name="heatmap_accessibility_desc">Token 活动热力图,%1$d 天数据</string>
</resources> </resources>

View File

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

View File

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