feat(heatmap): 个人资料与活动洞察 + 当前连续天数逻辑修正
- 个人资料卡片(页面顶部):自定义头像(相册选图→私有目录持久化→降采样解码)+ 昵称/邮箱自由文本编辑,SharedPreferences 持久化 - 活动洞察卡片:总请求次数 + 最多请求时段 Top3(按小时,UTC+8/UTC 口径,全量历史) - 当前连续天数:结尾 0 token 天跳过,从最后一个非零天往前数(最后一天 0 也计入连续) - 三语言资源(en/Hans/Hant),HeatmapInsightsTest 7 用例,全量 126 用例通过
This commit is contained in:
parent
266d33d131
commit
99050554e2
@ -1,10 +1,16 @@
|
||||
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.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -58,7 +77,11 @@ 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.io.File
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Token 活动热力图页面。
|
||||
@ -93,6 +116,51 @@ fun HeatmapScreen(
|
||||
var yearMenuExpanded by remember { mutableStateOf(false) }
|
||||
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) {
|
||||
viewModel.load()
|
||||
}
|
||||
@ -155,6 +223,29 @@ fun HeatmapScreen(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.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(
|
||||
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(锚定到被点击格子的中心位置)──
|
||||
val day = selectedDay
|
||||
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"
|
||||
|
||||
@ -47,10 +47,22 @@ data class HeatmapWeekData(
|
||||
data class HeatmapStats(
|
||||
val totalTokens: Long = 0L, // 累计 token 数(当年全部天之和)
|
||||
val peakTokens: Long = 0L, // 峰值 token 数(单日最大;当年无数据=0)
|
||||
val currentStreak: Int = 0, // 当前连续天数(从数据最后一天往前数连续 >0 的天数;最后一天为 0 则=0)
|
||||
val currentStreak: Int = 0, // 当前连续天数(从最后一天往前数连续 >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 状态 */
|
||||
data class HeatmapUiState(
|
||||
val loading: Boolean = true,
|
||||
@ -63,6 +75,7 @@ data class HeatmapUiState(
|
||||
val cumulativeData: List<HeatmapDayData> = emptyList(),
|
||||
val colorLevels: IntArray = IntArray(6), // [0, p25, p50, p75, p95, max]
|
||||
val stats: HeatmapStats = HeatmapStats(), // 年度统计(累计/峰值/当前连续/最长连续)
|
||||
val insights: HeatmapInsights = HeatmapInsights(), // 活动洞察(全量记录:总请求次数 + 最多请求时段 Top3)
|
||||
val useUtc8: Boolean = false, // 与全局图表设置一致:true=UTC+8 分桶,false=UTC
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@ -78,6 +91,7 @@ data class HeatmapUiState(
|
||||
cumulativeData == other.cumulativeData &&
|
||||
colorLevels.contentEquals(other.colorLevels) &&
|
||||
stats == other.stats &&
|
||||
insights == other.insights &&
|
||||
useUtc8 == other.useUtc8
|
||||
}
|
||||
|
||||
@ -92,6 +106,7 @@ data class HeatmapUiState(
|
||||
result = 31 * result + cumulativeData.hashCode()
|
||||
result = 31 * result + colorLevels.contentHashCode()
|
||||
result = 31 * result + stats.hashCode()
|
||||
result = 31 * result + insights.hashCode()
|
||||
result = 31 * result + useUtc8.hashCode()
|
||||
return result
|
||||
}
|
||||
@ -233,6 +248,9 @@ class HeatmapViewModel @Inject constructor(
|
||||
records = fetched
|
||||
latestRequestedYear = currentYear
|
||||
|
||||
// 活动洞察基于全量历史记录(不随年份变化),在后台线程计算(万级记录仅数 ms)
|
||||
val insights = withContext(Dispatchers.Default) { computeInsights(fetched, useUtc8) }
|
||||
|
||||
// 所有重操作放在 Dispatchers.Default 上
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
computeAllViews(fetched, useUtc8, currentYear).also { yearResults[currentYear] = it }
|
||||
@ -257,6 +275,7 @@ class HeatmapViewModel @Inject constructor(
|
||||
cumulativeData = result.cumulativeData,
|
||||
colorLevels = levels.toIntArray(),
|
||||
stats = computeStats(result.dailyData),
|
||||
insights = insights,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@ -416,24 +435,59 @@ class HeatmapViewModel @Inject constructor(
|
||||
*
|
||||
* - totalTokens:全部天之和
|
||||
* - peakTokens:单日最大 token(当年无数据=0)
|
||||
* - currentStreak:从最后一天往前数连续 >0 的天数(最后一天为 0 则=0)
|
||||
* - currentStreak:从最后一天往前数连续 >0 的天数;结尾为 0 的天(如当天尚未产生 token)跳过,
|
||||
* 从最后一个非零天往前数(全部为 0 则=0)
|
||||
* - maxStreak:当年内连续 >0 的最大天数
|
||||
*/
|
||||
internal fun computeStats(dailyData: List<HeatmapDayData>): HeatmapStats {
|
||||
var total = 0L
|
||||
var peak = 0L
|
||||
var currentStreak = 0
|
||||
var maxStreak = 0
|
||||
var run = 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
|
||||
if (d.tokens > 0L) run++ else run = 0
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算活动洞察(基于全部历史记录,不随年份变化)。
|
||||
*
|
||||
* - 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 毫秒) */
|
||||
private fun startOfDayTs(ts: Long, zone: ZoneOffset): Long {
|
||||
return Instant.ofEpochMilli(ts).atOffset(zone).toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()
|
||||
|
||||
@ -516,6 +516,26 @@
|
||||
</plurals>
|
||||
<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_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_2">二月</string>
|
||||
<string name="month_3">三月</string>
|
||||
|
||||
@ -516,6 +516,26 @@
|
||||
</plurals>
|
||||
<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_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_2">二月</string>
|
||||
<string name="month_3">三月</string>
|
||||
|
||||
@ -520,6 +520,27 @@
|
||||
</plurals>
|
||||
<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_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_2">Feb</string>
|
||||
<string name="month_3">Mar</string>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -37,8 +37,14 @@ class HeatmapStatsTest {
|
||||
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(当天尚未产生 token)不算断档:跳过末尾 0,从 3 往前数 3、5 连续 → 2
|
||||
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
|
||||
assertEquals(0, HeatmapViewModel.computeStats(days(0, 0, 0)).currentStreak)
|
||||
// 全连续 → 全部天数
|
||||
|
||||
Loading…
Reference in New Issue
Block a user