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.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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}日"
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user