feat(heatmap): 年度统计卡片(累计/峰值/当前连续/最长连续)

- computeStats 单次循环计算四项指标,存于 HeatmapUiState.stats
- 视图切换器下方一行 4 卡片,按所选年份跟随变化
- i18n 三语言(含 plurals 单复数);loading 占位显示 -
- 对抗审查修复:surfaceVariant 品牌色、en plurals、loading 占位
- 新增 HeatmapStatsTest 单测(4 用例全绿)
This commit is contained in:
WaterRain 2026-08-07 01:53:24 +00:00
parent bb1dd97874
commit 03424e6184
No known key found for this signature in database
7 changed files with 209 additions and 1 deletions

View File

@ -2,12 +2,14 @@ package com.rainy.token.ui.heatmap
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@ -40,10 +42,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@ -177,6 +181,45 @@ fun HeatmapScreen(
}
}
// ── 年度统计(按所选年份,切换年份跟随变化)──
// loading 期间显示占位,避免全 0 造成"无数据"假象
val dash = "–"
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StatItem(
label = stringResource(R.string.heatmap_stats_total),
value = if (state.loading) dash else formatTokenChinese(state.stats.totalTokens),
modifier = Modifier.weight(1f),
)
StatItem(
label = stringResource(R.string.heatmap_stats_peak),
value = if (state.loading) dash else formatTokenChinese(state.stats.peakTokens),
modifier = Modifier.weight(1f),
)
StatItem(
label = stringResource(R.string.heatmap_stats_current_streak),
value = if (state.loading) {
dash
} else {
pluralStringResource(R.plurals.heatmap_stats_days, state.stats.currentStreak, state.stats.currentStreak)
},
modifier = Modifier.weight(1f),
)
StatItem(
label = stringResource(R.string.heatmap_stats_max_streak),
value = if (state.loading) {
dash
} else {
pluralStringResource(R.plurals.heatmap_stats_days, state.stats.maxStreak, state.stats.maxStreak)
},
modifier = Modifier.weight(1f),
)
}
// ── 热力图 Canvas(带 Crossfade 淡入淡出动画)──
Crossfade(
targetState = state.viewMode,
@ -288,3 +331,35 @@ fun HeatmapScreen(
}
}
}
// ── 年度统计卡片(累计/峰值/当前连续/最长连续)──
@Composable
private fun StatItem(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@ -43,6 +43,14 @@ data class HeatmapWeekData(
val barHeight: Int, // 柱状图高度(格数):0 用量周=1(Level 0 浅色格,可见可点),非零周=2~7(按 token 排名比例)
)
/** 年度统计指标(按所选年份的每日数据计算,切换年份跟随变化) */
data class HeatmapStats(
val totalTokens: Long = 0L, // 累计 token 数(当年全部天之和)
val peakTokens: Long = 0L, // 峰值 token 数(单日最大;当年无数据=0)
val currentStreak: Int = 0, // 当前连续天数(从数据最后一天往前数连续 >0 的天数;最后一天为 0 则=0)
val maxStreak: Int = 0, // 最长连续天数(当年内连续 >0 的最大天数)
)
/** UI 状态 */
data class HeatmapUiState(
val loading: Boolean = true,
@ -54,6 +62,7 @@ data class HeatmapUiState(
val weeklyData: List<HeatmapWeekData> = emptyList(),
val cumulativeData: List<HeatmapDayData> = emptyList(),
val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max]
val stats: HeatmapStats = HeatmapStats(), // 年度统计(累计/峰值/当前连续/最长连续)
val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC
) {
override fun equals(other: Any?): Boolean {
@ -68,6 +77,7 @@ data class HeatmapUiState(
weeklyData == other.weeklyData &&
cumulativeData == other.cumulativeData &&
colorLevels.contentEquals(other.colorLevels) &&
stats == other.stats &&
useUtc8 == other.useUtc8
}
@ -81,6 +91,7 @@ data class HeatmapUiState(
result = 31 * result + weeklyData.hashCode()
result = 31 * result + cumulativeData.hashCode()
result = 31 * result + colorLevels.contentHashCode()
result = 31 * result + stats.hashCode()
result = 31 * result + useUtc8.hashCode()
return result
}
@ -189,6 +200,7 @@ class HeatmapViewModel @Inject constructor(
weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(),
stats = computeStats(result.dailyData),
)
}
}
@ -244,6 +256,7 @@ class HeatmapViewModel @Inject constructor(
weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(),
stats = computeStats(result.dailyData),
)
}
} catch (e: Exception) {
@ -398,6 +411,29 @@ class HeatmapViewModel @Inject constructor(
}
}
/**
* 计算年度统计指标(基于所选年份的完整日序列,dailyRaw 含 0 token 的天)。
*
* - totalTokens:全部天之和
* - peakTokens:单日最大 token(当年无数据=0)
* - currentStreak:从最后一天往前数连续 >0 的天数(最后一天为 0 则=0)
* - maxStreak:当年内连续 >0 的最大天数
*/
internal fun computeStats(dailyData: List<HeatmapDayData>): HeatmapStats {
var total = 0L
var peak = 0L
var currentStreak = 0
var maxStreak = 0
for (d in dailyData) {
total += d.tokens
if (d.tokens > peak) peak = d.tokens
if (d.tokens > 0L) currentStreak++ else currentStreak = 0
if (currentStreak > maxStreak) maxStreak = currentStreak
}
// 循环结束后 currentStreak = 以最后一天结尾的连续天数(最后一天为 0 时为 0)= 当前连续天数
return HeatmapStats(total, peak, currentStreak, maxStreak)
}
/** 返回时间戳在指定时区下的自然日零点(epoch 毫秒) */
private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long {
return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()

View File

@ -506,4 +506,11 @@
<string name="heatmap_accessibility_desc">Token 活动热力图,%1$d 天数据</string>
<string name="heatmap_accessibility_desc_weekly">Token 活动热力图,%1$d 周数据</string>
<string name="heatmap_select_year">选择年份</string>
<string name="heatmap_stats_total">累计 token</string>
<string name="heatmap_stats_peak">峰值 token</string>
<string name="heatmap_stats_current_streak">当前连续</string>
<string name="heatmap_stats_max_streak">最长连续</string>
<plurals name="heatmap_stats_days">
<item quantity="other">%1$d 天</item>
</plurals>
</resources>

View File

@ -506,4 +506,11 @@
<string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string>
<string name="heatmap_accessibility_desc_weekly">Token 活動熱力圖,%1$d 週資料</string>
<string name="heatmap_select_year">選擇年份</string>
<string name="heatmap_stats_total">累計 Token</string>
<string name="heatmap_stats_peak">峰值 Token</string>
<string name="heatmap_stats_current_streak">當前連續</string>
<string name="heatmap_stats_max_streak">最長連續</string>
<plurals name="heatmap_stats_days">
<item quantity="other">%1$d 天</item>
</plurals>
</resources>

View File

@ -509,4 +509,12 @@
<string name="heatmap_accessibility_desc">Token activity heatmap, %1$d days</string>
<string name="heatmap_accessibility_desc_weekly">Token activity heatmap, %1$d weeks</string>
<string name="heatmap_select_year">Select year</string>
<string name="heatmap_stats_total">Total tokens</string>
<string name="heatmap_stats_peak">Peak tokens</string>
<string name="heatmap_stats_current_streak">Current streak</string>
<string name="heatmap_stats_max_streak">Longest streak</string>
<plurals name="heatmap_stats_days">
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
</resources>

View File

@ -0,0 +1,64 @@
package com.rainy.token.ui.heatmap
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for [HeatmapViewModel.Companion.computeStats].
*
* All tests are pure JVM — no Android framework, no network.
*/
class HeatmapStatsTest {
private fun days(vararg tokens: Long): List<HeatmapDayData> =
tokens.mapIndexed { i, t -> HeatmapDayData(dayTs = i.toLong(), tokens = t, level = 0) }
// ── 累计 token ──
@Test
fun `total sums all days`() {
assertEquals(17L, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).totalTokens)
assertEquals(0L, HeatmapViewModel.computeStats(days(0, 0, 0)).totalTokens)
assertEquals(0L, HeatmapViewModel.computeStats(emptyList()).totalTokens)
}
// ── 峰值 token ──
@Test
fun `peak is max single day`() {
assertEquals(7L, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).peakTokens)
assertEquals(0L, HeatmapViewModel.computeStats(days(0, 0, 0)).peakTokens)
assertEquals(100L, HeatmapViewModel.computeStats(days(100)).peakTokens)
}
// ── 当前连续天数 ──
@Test
fun `current streak counts from last day backward`() {
// 末尾是 2(>0),往前数只有它自己(前一个 0 断档)→ 1
assertEquals(1, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).currentStreak)
// 末尾断档 → 0
assertEquals(0, HeatmapViewModel.computeStats(days(5, 3, 0)).currentStreak)
// 全零 → 0
assertEquals(0, HeatmapViewModel.computeStats(days(0, 0, 0)).currentStreak)
// 全连续 → 全部天数
assertEquals(3, HeatmapViewModel.computeStats(days(2, 3, 4)).currentStreak)
// 单天 → 1
assertEquals(1, HeatmapViewModel.computeStats(days(100)).currentStreak)
// 空列表 → 0
assertEquals(0, HeatmapViewModel.computeStats(emptyList()).currentStreak)
}
// ── 最长连续天数 ──
@Test
fun `max streak is longest run`() {
// 最长连续段是 (3,7) 共 2 天
assertEquals(2, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).maxStreak)
assertEquals(2, HeatmapViewModel.computeStats(days(5, 3, 0)).maxStreak)
assertEquals(0, HeatmapViewModel.computeStats(days(0, 0, 0)).maxStreak)
assertEquals(3, HeatmapViewModel.computeStats(days(2, 3, 4)).maxStreak)
assertEquals(1, HeatmapViewModel.computeStats(days(100)).maxStreak)
assertEquals(0, HeatmapViewModel.computeStats(emptyList()).maxStreak)
}
}

View File

@ -128,3 +128,14 @@
- 底部「少 → 多」颜色图例固定显示,不随热力图横向滚动
- 页面打开 / 切换视图 / 切换年份时,热力图横向滚动自动定位到最右(最新数据)
- 0 token 的天可以点击查看浮层(显示「7月9日 使用了0token」);每周视图 0 用量周显示 1 格 Level 0 浅色格、同样可点击(柱状图语义,见「五、每周视图」)
## 十三、年度统计(2026-08 新增)
- 视图切换器下方一行 4 个统计卡片,**按所选年份**计算(切换年份跟随变化),与图表口径一致
- **累计 token**:当年全部天之和(= 累计视图最后一天的累计值)
- **峰值 token**:当年单日最大 token(无数据=0)
- **当前连续**:从数据最后一天往前数连续 >0 的天数(最后一天为 0 则=0)
- **最长连续**:当年内连续 >0 的最大天数
- 实现:HeatmapViewModel 的 `computeStats(dailyData)`(companion 内,internal),结果存于 `HeatmapUiState.stats`;加载与切换年份时随 dailyData 一起更新
- 连续天数的"有 token"判定:当日 token > 0(0 token 的天视为断档);数据为 1月1日~结束日的完整日序列,含 0 token 的天
- Token 数值显示沿用「十、Token 数显示格式」(亿/万中文读法);天数显示为「N 天」