feat(heatmap): 个人资料与活动洞察 + 当前连续天数逻辑修正

- 个人资料卡片(页面顶部):自定义头像(相册选图→私有目录持久化→降采样解码)+ 昵称/邮箱自由文本编辑,SharedPreferences 持久化
- 活动洞察卡片:总请求次数 + 最多请求时段 Top3(按小时,UTC+8/UTC 口径,全量历史)
- 当前连续天数:结尾 0 token 天跳过,从最后一个非零天往前数(最后一天 0 也计入连续)
- 三语言资源(en/Hans/Hant),HeatmapInsightsTest 7 用例,全量 126 用例通过
This commit is contained in:
WaterRain 2026-08-07 23:01:37 +00:00
parent 266d33d131
commit 99050554e2
No known key found for this signature in database
7 changed files with 619 additions and 8 deletions

View File

@ -1,10 +1,16 @@
package com.rainy.token.ui.heatmap package com.rainy.token.ui.heatmap
import android.content.Context
import android.graphics.BitmapFactory
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.Image
import androidx.compose.foundation.background 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.Box
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
@ -12,35 +18,48 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api 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.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
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.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -58,7 +77,11 @@ 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.io.File
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** /**
* Token 活动热力图页面。 * Token 活动热力图页面。
@ -93,6 +116,51 @@ fun HeatmapScreen(
var yearMenuExpanded by remember { mutableStateOf(false) } var yearMenuExpanded by remember { mutableStateOf(false) }
val selectYearDesc = stringResource(R.string.heatmap_select_year) val selectYearDesc = stringResource(R.string.heatmap_select_year)
// ── 个人资料状态(昵称/邮箱/头像路径,SharedPreferences 持久化)──
val context = LocalContext.current
val profilePrefs = remember { context.getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE) }
var nickname by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var avatarPath by remember { mutableStateOf<String?>(null) }
// 换头像后路径不变(固定文件名),用版本号强制触发头像重载
var avatarVersion by remember { mutableStateOf(0) }
var showProfileEdit by remember { mutableStateOf(false) }
var avatarMenuVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
nickname = profilePrefs.getString(KEY_NICKNAME, "").orEmpty()
email = profilePrefs.getString(KEY_EMAIL, "").orEmpty()
avatarPath = profilePrefs.getString(KEY_AVATAR_PATH, null)
}
// 系统相册选图 → 复制到应用私有目录(content URI 授权是临时的,必须保存持久化副本)
// 复制在 IO 线程执行(相册原图可达数 MB,避免阻塞主线程);
// 先写临时文件再 rename 覆盖,失败时不破坏已有头像
val scope = rememberCoroutineScope()
val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
val target = File(context.filesDir, AVATAR_FILE_NAME)
val tmp = File(context.filesDir, AVATAR_FILE_NAME + ".tmp")
val input = context.contentResolver.openInputStream(uri)
?: error("cannot open picked image")
input.use { i -> tmp.outputStream().use { o -> i.copyTo(o) } }
if (!tmp.renameTo(target)) {
tmp.delete()
error("cannot persist avatar")
}
target.absolutePath
}
}.onSuccess { path ->
avatarPath = path
avatarVersion++ // 路径相同(固定文件名),必须递增版本号触发头像重载
profilePrefs.edit().putString(KEY_AVATAR_PATH, path).apply()
}.onFailure { tmp ->
File(context.filesDir, AVATAR_FILE_NAME + ".tmp").delete()
}
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.load() viewModel.load()
} }
@ -155,6 +223,29 @@ fun HeatmapScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
// ── 个人资料卡片(页面最顶部:自定义头像 + 昵称/邮箱)──
ProfileCard(
nickname = nickname,
email = email,
avatarPath = avatarPath,
avatarVersion = avatarVersion,
onEdit = { showProfileEdit = true },
onAvatarClick = { avatarMenuVisible = true },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
// ── 活动洞察卡片(个人资料下方:总请求次数 + 最多请求时段 Top3)──
InsightsCard(
totalRequests = state.insights.totalRequests,
topHours = state.insights.topHours,
loading = state.loading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
// ── 视图切换器 ── // ── 视图切换器 ──
Row( Row(
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -275,6 +366,62 @@ fun HeatmapScreen(
} }
} }
// ── 个人资料编辑对话框(昵称/邮箱均可完全自定义,留空=清除)──
if (showProfileEdit) {
var editNickname by remember { mutableStateOf(nickname) }
var editEmail by remember { mutableStateOf(email) }
ProfileEditDialog(
nickname = editNickname,
email = editEmail,
onNicknameChange = { editNickname = it },
onEmailChange = { editEmail = it },
onSave = {
nickname = editNickname
email = editEmail
profilePrefs.edit()
.putString(KEY_NICKNAME, editNickname)
.putString(KEY_EMAIL, editEmail)
.apply()
showProfileEdit = false
},
onDismiss = { showProfileEdit = false },
)
}
// ── 头像操作对话框(更换/移除)──
if (avatarMenuVisible) {
AlertDialog(
onDismissRequest = { avatarMenuVisible = false },
title = { Text(stringResource(R.string.heatmap_profile_avatar_change)) },
text = {
Column {
TextButton(onClick = {
avatarMenuVisible = false
avatarPicker.launch("image/*")
}) {
Text(stringResource(R.string.heatmap_profile_avatar_pick))
}
if (avatarPath != null) {
TextButton(onClick = {
avatarMenuVisible = false
avatarPath?.let { runCatching { File(it).delete() } }
avatarPath = null
profilePrefs.edit().remove(KEY_AVATAR_PATH).apply()
}) {
Text(stringResource(R.string.heatmap_profile_avatar_remove))
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { avatarMenuVisible = false }) {
Text(stringResource(R.string.heatmap_profile_cancel))
}
},
)
}
// ── 浮层 Popup(锚定到被点击格子的中心位置)── // ── 浮层 Popup(锚定到被点击格子的中心位置)──
val day = selectedDay val day = selectedDay
val week = selectedWeek val week = selectedWeek
@ -377,3 +524,244 @@ private fun StatItem(
) )
} }
} }
// ── 个人资料卡片(页面最顶部:自定义头像 + 昵称/邮箱)──
@Composable
private fun ProfileCard(
nickname: String,
email: String,
avatarPath: String?,
avatarVersion: Int,
onEdit: () -> Unit,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val emptyText = stringResource(R.string.heatmap_profile_empty)
Surface(
modifier = modifier,
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(14.dp)) {
Text(
text = stringResource(R.string.heatmap_profile_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(10.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
ProfileAvatar(
avatarPath = avatarPath,
avatarVersion = avatarVersion,
nickname = nickname,
onClick = onAvatarClick,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
text = nickname.ifBlank { emptyText },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = email.ifBlank { emptyText },
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.heatmap_profile_edit))
}
}
}
}
}
// ── 自定义头像(圆形裁剪;未设置时显示昵称首字符占位)──
@Composable
private fun ProfileAvatar(
avatarPath: String?,
avatarVersion: Int,
nickname: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// 本地文件解码在 IO 线程;key=(路径,版本号):换头像后路径不变,靠版本号触发重载
// 先读尺寸再降采样解码(头像仅 56dp 显示,全尺寸解码相册大图会 OOM)
val avatar by produceState<ImageBitmap?>(initialValue = null, avatarPath, avatarVersion) {
value = avatarPath?.let { path ->
withContext(Dispatchers.IO) {
runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
var sample = 1
while (bounds.outWidth / sample > 512 || bounds.outHeight / sample > 512) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
}.getOrNull()
}
}
}
val avatarDesc = stringResource(R.string.heatmap_profile_avatar_change)
Box(
modifier = modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.semantics { contentDescription = avatarDesc },
contentAlignment = Alignment.Center,
) {
val bmp = avatar
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
// 按 code point 取首字符,避免 emoji(代理对)被截成半个字符显示乱码
val trimmed = nickname.trim()
val initial = if (trimmed.isEmpty()) null else String(Character.toChars(trimmed.codePointAt(0)))
if (initial != null) {
Text(
text = initial,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = inkMuted(),
)
} else {
Icon(Icons.Filled.Person, contentDescription = null, tint = inkMuted())
}
}
}
}
// ── 个人资料编辑对话框(昵称/邮箱均可完全自定义)──
@Composable
private fun ProfileEditDialog(
nickname: String,
email: String,
onNicknameChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onSave: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.heatmap_profile_dialog_title)) },
text = {
Column {
OutlinedTextField(
value = nickname,
onValueChange = onNicknameChange,
label = { Text(stringResource(R.string.heatmap_profile_nickname)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text(stringResource(R.string.heatmap_profile_email)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(onClick = onSave) { Text(stringResource(R.string.heatmap_profile_save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.heatmap_profile_cancel)) }
},
)
}
// ── 活动洞察卡片(总请求次数 + 最多请求时段 Top3,全量历史口径)──
@Composable
private fun InsightsCard(
totalRequests: Int,
topHours: List<Int>,
loading: Boolean,
modifier: Modifier = Modifier,
) {
val dash = "–"
Surface(
modifier = modifier,
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(Modifier.padding(14.dp)) {
Text(
text = stringResource(R.string.heatmap_insights_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.heatmap_insights_requests),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
modifier = Modifier.weight(1f),
)
Text(
text = if (loading) {
dash
} else {
pluralStringResource(R.plurals.heatmap_insights_request_count, totalRequests, totalRequests)
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(6.dp))
Text(
text = stringResource(R.string.heatmap_insights_peak_hours),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
)
Spacer(Modifier.height(6.dp))
// 固定 3 个时段槽位,不足的显示占位,避免布局跳动
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(3) { i ->
val hour = topHours.getOrNull(i)
Text(
text = if (loading || hour == null) {
dash
} else {
stringResource(R.string.heatmap_insight_hour_range, hour, hour + 1)
},
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
}
}
// ── 个人资料持久化 ──
private const val PROFILE_PREFS = "heatmap_profile"
private const val KEY_NICKNAME = "nickname"
private const val KEY_EMAIL = "email"
private const val KEY_AVATAR_PATH = "avatar_path"
private const val AVATAR_FILE_NAME = "heatmap_avatar.jpg"

View File

@ -47,10 +47,22 @@ data class HeatmapWeekData(
data class HeatmapStats( data class HeatmapStats(
val totalTokens: Long = 0L, // 累计 token 数(当年全部天之和) val totalTokens: Long = 0L, // 累计 token 数(当年全部天之和)
val peakTokens: Long = 0L, // 峰值 token 数(单日最大;当年无数据=0) val peakTokens: Long = 0L, // 峰值 token 数(单日最大;当年无数据=0)
val currentStreak: Int = 0, // 当前连续天数(从数据最后一天往前数连续 >0 的天数;最后一天为 0 则=0) val currentStreak: Int = 0, // 当前连续天数(从最后一天往前数连续 >0 的天数;末尾为 0 的天跳过,从最后一个非零天往前数)
val maxStreak: Int = 0, // 最长连续天数(当年内连续 >0 的最大天数) val maxStreak: Int = 0, // 最长连续天数(当年内连续 >0 的最大天数)
) )
/**
* 活动洞察(基于全部历史记录,不随年份切换变化)。
*
* - totalRequests:总请求次数(= 记录条数,一条 UsageRecord 即一次请求)
* - topHours:请求最多的时段(按小时 0-23),降序取前 3;
* 并列时小时数小的优先;不足 3 个时列表较短
*/
data class HeatmapInsights(
val totalRequests: Int = 0,
val topHours: List<Int> = emptyList(),
)
/** UI 状态 */ /** UI 状态 */
data class HeatmapUiState( data class HeatmapUiState(
val loading: Boolean = true, val loading: Boolean = true,
@ -63,6 +75,7 @@ data class HeatmapUiState(
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 stats: HeatmapStats = HeatmapStats(), // 年度统计(累计/峰值/当前连续/最长连续)
val insights: HeatmapInsights = HeatmapInsights(), // 活动洞察(全量记录:总请求次数 + 最多请求时段 Top3)
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 {
@ -78,6 +91,7 @@ data class HeatmapUiState(
cumulativeData == other.cumulativeData && cumulativeData == other.cumulativeData &&
colorLevels.contentEquals(other.colorLevels) && colorLevels.contentEquals(other.colorLevels) &&
stats == other.stats && stats == other.stats &&
insights == other.insights &&
useUtc8 == other.useUtc8 useUtc8 == other.useUtc8
} }
@ -92,6 +106,7 @@ data class HeatmapUiState(
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 + stats.hashCode()
result = 31 * result + insights.hashCode()
result = 31 * result + useUtc8.hashCode() result = 31 * result + useUtc8.hashCode()
return result return result
} }
@ -233,6 +248,9 @@ class HeatmapViewModel @Inject constructor(
records = fetched records = fetched
latestRequestedYear = currentYear latestRequestedYear = currentYear
// 活动洞察基于全量历史记录(不随年份变化),在后台线程计算(万级记录仅数 ms)
val insights = withContext(Dispatchers.Default) { computeInsights(fetched, useUtc8) }
// 所有重操作放在 Dispatchers.Default 上 // 所有重操作放在 Dispatchers.Default 上
val result = withContext(Dispatchers.Default) { val result = withContext(Dispatchers.Default) {
computeAllViews(fetched, useUtc8, currentYear).also { yearResults[currentYear] = it } computeAllViews(fetched, useUtc8, currentYear).also { yearResults[currentYear] = it }
@ -257,6 +275,7 @@ class HeatmapViewModel @Inject constructor(
cumulativeData = result.cumulativeData, cumulativeData = result.cumulativeData,
colorLevels = levels.toIntArray(), colorLevels = levels.toIntArray(),
stats = computeStats(result.dailyData), stats = computeStats(result.dailyData),
insights = insights,
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -416,24 +435,59 @@ class HeatmapViewModel @Inject constructor(
* *
* - totalTokens:全部天之和 * - totalTokens:全部天之和
* - peakTokens:单日最大 token(当年无数据=0) * - peakTokens:单日最大 token(当年无数据=0)
* - currentStreak:从最后一天往前数连续 >0 的天数(最后一天为 0 则=0) * - currentStreak:从最后一天往前数连续 >0 的天数;结尾为 0 的天(如当天尚未产生 token)跳过,
* 从最后一个非零天往前数(全部为 0 则=0)
* - maxStreak:当年内连续 >0 的最大天数 * - maxStreak:当年内连续 >0 的最大天数
*/ */
internal fun computeStats(dailyData: List<HeatmapDayData>): HeatmapStats { internal fun computeStats(dailyData: List<HeatmapDayData>): HeatmapStats {
var total = 0L var total = 0L
var peak = 0L var peak = 0L
var currentStreak = 0
var maxStreak = 0 var maxStreak = 0
var run = 0
for (d in dailyData) { for (d in dailyData) {
total += d.tokens total += d.tokens
if (d.tokens > peak) peak = d.tokens if (d.tokens > peak) peak = d.tokens
if (d.tokens > 0L) currentStreak++ else currentStreak = 0 if (d.tokens > 0L) run++ else run = 0
if (currentStreak > maxStreak) maxStreak = currentStreak if (run > maxStreak) maxStreak = run
}
// 当前连续天数:最后一天即使为 0(当天尚未产生 token)也计入连续——跳过结尾的 0,
// 从最后一个非零天往前数连续 >0 的天数;全部为 0 时=0
var currentStreak = 0
var i = dailyData.size - 1
while (i >= 0 && dailyData[i].tokens == 0L) i--
while (i >= 0 && dailyData[i].tokens > 0L) {
currentStreak++
i--
} }
// 循环结束后 currentStreak = 以最后一天结尾的连续天数(最后一天为 0 时为 0)= 当前连续天数
return HeatmapStats(total, peak, currentStreak, maxStreak) return HeatmapStats(total, peak, currentStreak, maxStreak)
} }
/**
* 计算活动洞察(基于全部历史记录,不随年份变化)。
*
* - totalRequests:总请求次数 = 记录条数(一条 UsageRecord 即一次请求)
* - topHours:请求最多的时段(按小时 0-23),降序取前 3;
* 并列时小时数小的优先;不足 3 个时列表较短;无数据时为空列表
*
* 小时按 useUtc8 对应的时区(UTC+8 / UTC)计算,与热力图分桶口径一致。
*/
internal fun computeInsights(records: List<UsageRecord>, useUtc8: Boolean = false): HeatmapInsights {
if (records.isEmpty()) return HeatmapInsights()
val offsetMs = if (useUtc8) 8 * 3_600_000L else 0L
val hourCounts = IntArray(24)
for (r in records) {
// 纯算术取小时(避免逐条分配 Instant/OffsetDateTime):
// (时间戳 + 时区偏移) 对一天取余 → 所在小时(ts 恒为正,无负数取模问题)
val hour = (((r.timeCreated + offsetMs) % 86_400_000L) / 3_600_000L).toInt()
hourCounts[hour]++
}
val topHours = hourCounts.indices
.sortedWith(compareByDescending<Int> { hourCounts[it] }.thenBy { it })
.filter { hourCounts[it] > 0 }
.take(3)
return HeatmapInsights(totalRequests = records.size, topHours = topHours)
}
/** 返回时间戳在指定时区下的自然日零点(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

@ -516,6 +516,26 @@
</plurals> </plurals>
<string name="heatmap_day_used">%1$s使用了%2$s token</string> <string name="heatmap_day_used">%1$s使用了%2$s token</string>
<string name="heatmap_week_used">%1$s使用了%2$s token</string> <string name="heatmap_week_used">%1$s使用了%2$s token</string>
<!-- 个人资料(页面顶部,昵称/邮箱均可完全自定义) -->
<string name="heatmap_profile_title">个人资料</string>
<string name="heatmap_profile_nickname">昵称</string>
<string name="heatmap_profile_email">邮箱</string>
<string name="heatmap_profile_empty">未设置</string>
<string name="heatmap_profile_edit">编辑</string>
<string name="heatmap_profile_dialog_title">编辑个人资料</string>
<string name="heatmap_profile_avatar_change">更换头像</string>
<string name="heatmap_profile_avatar_pick">从相册选择</string>
<string name="heatmap_profile_avatar_remove">移除头像</string>
<string name="heatmap_profile_save">保存</string>
<string name="heatmap_profile_cancel">取消</string>
<!-- 活动洞察 -->
<string name="heatmap_insights_title">活动洞察</string>
<string name="heatmap_insights_requests">总请求次数</string>
<plurals name="heatmap_insights_request_count">
<item quantity="other">%1$d 次</item>
</plurals>
<string name="heatmap_insights_peak_hours">最多请求时段</string>
<string name="heatmap_insight_hour_range">%1$d~%2$d点</string>
<string name="month_1">一月</string> <string name="month_1">一月</string>
<string name="month_2">二月</string> <string name="month_2">二月</string>
<string name="month_3">三月</string> <string name="month_3">三月</string>

View File

@ -516,6 +516,26 @@
</plurals> </plurals>
<string name="heatmap_day_used">%1$s使用了%2$s Token</string> <string name="heatmap_day_used">%1$s使用了%2$s Token</string>
<string name="heatmap_week_used">%1$s使用了%2$s Token</string> <string name="heatmap_week_used">%1$s使用了%2$s Token</string>
<!-- 個人資料(頁面頂部,暱稱/郵箱均可完全自訂) -->
<string name="heatmap_profile_title">個人資料</string>
<string name="heatmap_profile_nickname">暱稱</string>
<string name="heatmap_profile_email">郵箱</string>
<string name="heatmap_profile_empty">未設定</string>
<string name="heatmap_profile_edit">編輯</string>
<string name="heatmap_profile_dialog_title">編輯個人資料</string>
<string name="heatmap_profile_avatar_change">更換頭像</string>
<string name="heatmap_profile_avatar_pick">從相冊選擇</string>
<string name="heatmap_profile_avatar_remove">移除頭像</string>
<string name="heatmap_profile_save">儲存</string>
<string name="heatmap_profile_cancel">取消</string>
<!-- 活動洞察 -->
<string name="heatmap_insights_title">活動洞察</string>
<string name="heatmap_insights_requests">總請求次數</string>
<plurals name="heatmap_insights_request_count">
<item quantity="other">%1$d 次</item>
</plurals>
<string name="heatmap_insights_peak_hours">最多請求時段</string>
<string name="heatmap_insight_hour_range">%1$d~%2$d點</string>
<string name="month_1">一月</string> <string name="month_1">一月</string>
<string name="month_2">二月</string> <string name="month_2">二月</string>
<string name="month_3">三月</string> <string name="month_3">三月</string>

View File

@ -520,6 +520,27 @@
</plurals> </plurals>
<string name="heatmap_day_used">%1$s used %2$s tokens</string> <string name="heatmap_day_used">%1$s used %2$s tokens</string>
<string name="heatmap_week_used">%1$s used %2$s tokens</string> <string name="heatmap_week_used">%1$s used %2$s tokens</string>
<!-- 个人资料(页面顶部,昵称/邮箱均可完全自定义) -->
<string name="heatmap_profile_title">Profile</string>
<string name="heatmap_profile_nickname">Nickname</string>
<string name="heatmap_profile_email">Email</string>
<string name="heatmap_profile_empty">Not set</string>
<string name="heatmap_profile_edit">Edit</string>
<string name="heatmap_profile_dialog_title">Edit Profile</string>
<string name="heatmap_profile_avatar_change">Change Avatar</string>
<string name="heatmap_profile_avatar_pick">Choose from gallery</string>
<string name="heatmap_profile_avatar_remove">Remove avatar</string>
<string name="heatmap_profile_save">Save</string>
<string name="heatmap_profile_cancel">Cancel</string>
<!-- 活动洞察 -->
<string name="heatmap_insights_title">Activity Insights</string>
<string name="heatmap_insights_requests">Total requests</string>
<plurals name="heatmap_insights_request_count">
<item quantity="one">%1$d request</item>
<item quantity="other">%1$d requests</item>
</plurals>
<string name="heatmap_insights_peak_hours">Peak hours</string>
<string name="heatmap_insight_hour_range">%1$d–%2$d</string>
<string name="month_1">Jan</string> <string name="month_1">Jan</string>
<string name="month_2">Feb</string> <string name="month_2">Feb</string>
<string name="month_3">Mar</string> <string name="month_3">Mar</string>

View File

@ -0,0 +1,102 @@
package com.rainy.token.ui.heatmap
import com.rainy.token.data.local.UsageRecord
import java.time.Instant
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for [HeatmapViewModel.Companion.computeInsights].
*
* All tests are pure JVM — no Android framework, no network.
*/
class HeatmapInsightsTest {
private fun rec(ts: Long, id: String = "r$ts"): UsageRecord = UsageRecord(
id = id,
workspaceId = "w",
timeCreated = ts,
timeUpdated = ts,
model = "m",
provider = "p",
inputTokens = 1,
outputTokens = 0,
reasoningTokens = 0,
cacheReadTokens = 0,
cost = 0,
keyId = "k",
sessionId = "s",
)
/** 生成指定 UTC 时刻(如 "2024-01-01T10:00:00Z")的记录 */
private fun recAt(utc: String, id: String = "r$utc"): UsageRecord =
rec(Instant.parse(utc).toEpochMilli(), id)
// ── 总请求次数 ──
@Test
fun `empty records yield zero requests and no hours`() {
val insights = HeatmapViewModel.computeInsights(emptyList())
assertEquals(0, insights.totalRequests)
assertEquals(emptyList<Int>(), insights.topHours)
}
@Test
fun `total requests equals record count`() {
val records = listOf(recAt("2024-01-01T10:00:00Z"), recAt("2024-01-02T11:00:00Z"), recAt("2024-01-03T12:00:00Z"))
assertEquals(3, HeatmapViewModel.computeInsights(records).totalRequests)
}
// ── Top3 时段(降序,并列小时小优先)──
@Test
fun `top hours sorted by count descending`() {
// hour 10 ×2、14 ×3、9 ×5、22 ×1 → [9, 14, 10]
val records = listOf(
recAt("2024-01-01T10:00:00Z"), recAt("2024-01-02T10:00:00Z"),
recAt("2024-01-01T14:00:00Z"), recAt("2024-01-02T14:00:00Z"), recAt("2024-01-03T14:00:00Z"),
recAt("2024-01-01T09:00:00Z"), recAt("2024-01-02T09:00:00Z"), recAt("2024-01-03T09:00:00Z"), recAt("2024-01-04T09:00:00Z"), recAt("2024-01-05T09:00:00Z"),
recAt("2024-01-01T22:00:00Z"),
)
assertEquals(listOf(9, 14, 10), HeatmapViewModel.computeInsights(records).topHours)
}
@Test
fun `ties broken by smaller hour first`() {
// hour 5 ×2、7 ×2、3 ×1 → 并列的 5 排在 7 前面 → [5, 7, 3]
val records = listOf(
recAt("2024-01-01T05:00:00Z"), recAt("2024-01-02T05:00:00Z"),
recAt("2024-01-01T07:00:00Z"), recAt("2024-01-02T07:00:00Z"),
recAt("2024-01-01T03:00:00Z"),
)
assertEquals(listOf(5, 7, 3), HeatmapViewModel.computeInsights(records).topHours)
}
@Test
fun `fewer than three hours returns shorter list`() {
val records = listOf(recAt("2024-01-01T10:00:00Z"), recAt("2024-01-02T10:00:00Z"), recAt("2024-01-03T10:00:00Z"))
assertEquals(listOf(10), HeatmapViewModel.computeInsights(records).topHours)
}
// ── 时区口径(与热力图分桶一致)──
@Test
fun `utc8 buckets by utc plus eight hours`() {
// UTC 16:30 → UTC+8 次日 00:30(hour 0);UTC 23:59 → UTC+8 07:59(hour 7)
val records = listOf(
recAt("2024-01-01T16:30:00Z"),
recAt("2024-01-01T23:59:00Z"),
)
val insights = HeatmapViewModel.computeInsights(records, useUtc8 = true)
assertEquals(2, insights.totalRequests)
// 两个时段各 1 次,并列按小时升序
assertEquals(listOf(0, 7), insights.topHours)
}
@Test
fun `utc mode buckets by utc hour`() {
val records = listOf(recAt("2024-01-01T16:30:00Z"))
val insights = HeatmapViewModel.computeInsights(records, useUtc8 = false)
assertEquals(listOf(16), insights.topHours)
}
}

View File

@ -37,8 +37,14 @@ class HeatmapStatsTest {
fun `current streak counts from last day backward`() { fun `current streak counts from last day backward`() {
// 末尾是 2(>0),往前数只有它自己(前一个 0 断档)→ 1 // 末尾是 2(>0),往前数只有它自己(前一个 0 断档)→ 1
assertEquals(1, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).currentStreak) assertEquals(1, HeatmapViewModel.computeStats(days(0, 5, 0, 3, 7, 0, 2)).currentStreak)
// 末尾断档 → 0 // 末尾为 0(当天尚未产生 token)不算断档:跳过末尾 0,从 3 往前数 3、5 连续 → 2
assertEquals(0, HeatmapViewModel.computeStats(days(5, 3, 0)).currentStreak) assertEquals(2, HeatmapViewModel.computeStats(days(5, 3, 0)).currentStreak)
// 末尾多个 0 同样跳过 → 2
assertEquals(2, HeatmapViewModel.computeStats(days(5, 3, 0, 0)).currentStreak)
// 跳过末尾 0 后从最后一个非零天往前数,前面 0 断档 → 2
assertEquals(2, HeatmapViewModel.computeStats(days(0, 5, 3, 0)).currentStreak)
// 末尾非零,正常从末尾往前数 → 2
assertEquals(2, HeatmapViewModel.computeStats(days(5, 3)).currentStreak)
// 全零 → 0 // 全零 → 0
assertEquals(0, HeatmapViewModel.computeStats(days(0, 0, 0)).currentStreak) assertEquals(0, HeatmapViewModel.computeStats(days(0, 0, 0)).currentStreak)
// 全连续 → 全部天数 // 全连续 → 全部天数