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:
parent
dd91e94805
commit
77cb664590
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 ──
|
||||
// ── 热力图 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 ->
|
||||
// Phase 4: 显示浮层
|
||||
// 再次点击同一格子则关闭浮层
|
||||
if (selectedDay == dayData) {
|
||||
selectedDay = null
|
||||
} else {
|
||||
selectedDay = dayData
|
||||
selectedWeek = null
|
||||
}
|
||||
},
|
||||
onWeekClick = { weekData ->
|
||||
// Phase 4: 显示浮层
|
||||
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}日"
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user