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.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight 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.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp 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 淡入淡出动画)── // ── 热力图 Canvas(带 Crossfade 淡入淡出动画)──
Crossfade( Crossfade(
targetState = state.viewMode, 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 排名比例) 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 状态 */ /** UI 状态 */
data class HeatmapUiState( data class HeatmapUiState(
val loading: Boolean = true, val loading: Boolean = true,
@ -54,6 +62,7 @@ data class HeatmapUiState(
val weeklyData: List<HeatmapWeekData> = emptyList(), val weeklyData: List<HeatmapWeekData> = emptyList(),
val cumulativeData: List<HeatmapDayData> = emptyList(), val cumulativeData: List<HeatmapDayData> = emptyList(),
val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max] val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max]
val stats: HeatmapStats = HeatmapStats(), // 年度统计(累计/峰值/当前连续/最长连续)
val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@ -68,6 +77,7 @@ data class HeatmapUiState(
weeklyData == other.weeklyData && weeklyData == other.weeklyData &&
cumulativeData == other.cumulativeData && cumulativeData == other.cumulativeData &&
colorLevels.contentEquals(other.colorLevels) && colorLevels.contentEquals(other.colorLevels) &&
stats == other.stats &&
useUtc8 == other.useUtc8 useUtc8 == other.useUtc8
} }
@ -81,6 +91,7 @@ data class HeatmapUiState(
result = 31 * result + weeklyData.hashCode() result = 31 * result + weeklyData.hashCode()
result = 31 * result + cumulativeData.hashCode() result = 31 * result + cumulativeData.hashCode()
result = 31 * result + colorLevels.contentHashCode() result = 31 * result + colorLevels.contentHashCode()
result = 31 * result + stats.hashCode()
result = 31 * result + useUtc8.hashCode() result = 31 * result + useUtc8.hashCode()
return result return result
} }
@ -189,6 +200,7 @@ class HeatmapViewModel @Inject constructor(
weeklyData = result.weeklyData, weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData, cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(), colorLevels = levels.toIntArray(),
stats = computeStats(result.dailyData),
) )
} }
} }
@ -244,6 +256,7 @@ class HeatmapViewModel @Inject constructor(
weeklyData = result.weeklyData, weeklyData = result.weeklyData,
cumulativeData = result.cumulativeData, cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(), colorLevels = levels.toIntArray(),
stats = computeStats(result.dailyData),
) )
} }
} catch (e: Exception) { } 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 毫秒) */ /** 返回时间戳在指定时区下的自然日零点(epoch 毫秒) */
private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long { private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long {
return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli() 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">Token 活动热力图,%1$d 天数据</string>
<string name="heatmap_accessibility_desc_weekly">Token 活动热力图,%1$d 周数据</string> <string name="heatmap_accessibility_desc_weekly">Token 活动热力图,%1$d 周数据</string>
<string name="heatmap_select_year">选择年份</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> </resources>

View File

@ -506,4 +506,11 @@
<string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string> <string name="heatmap_accessibility_desc">Token 活動熱力圖,%1$d 天資料</string>
<string name="heatmap_accessibility_desc_weekly">Token 活動熱力圖,%1$d 週資料</string> <string name="heatmap_accessibility_desc_weekly">Token 活動熱力圖,%1$d 週資料</string>
<string name="heatmap_select_year">選擇年份</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> </resources>

View File

@ -509,4 +509,12 @@
<string name="heatmap_accessibility_desc">Token activity heatmap, %1$d days</string> <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_accessibility_desc_weekly">Token activity heatmap, %1$d weeks</string>
<string name="heatmap_select_year">Select year</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> </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

@ -127,4 +127,15 @@
- 底部「少 → 多」颜色图例固定显示,不随热力图横向滚动 - 底部「少 → 多」颜色图例固定显示,不随热力图横向滚动
- 页面打开 / 切换视图 / 切换年份时,热力图横向滚动自动定位到最右(最新数据) - 页面打开 / 切换视图 / 切换年份时,热力图横向滚动自动定位到最右(最新数据)
- 0 token 的天可以点击查看浮层(显示「7月9日 使用了0token」);每周视图 0 用量周显示 1 格 Level 0 浅色格、同样可点击(柱状图语义,见「五、每周视图」) - 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 天」