diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt index e1aadb9..2630b1b 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt @@ -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(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(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, + 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" diff --git a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt index 3f3af85..e3dcf53 100644 --- a/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapViewModel.kt @@ -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 = emptyList(), +) + /** UI 状态 */ data class HeatmapUiState( val loading: Boolean = true, @@ -63,6 +75,7 @@ data class HeatmapUiState( val cumulativeData: List = 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): 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, 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 { 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() diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 8aecaec..4834964 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -516,6 +516,26 @@ %1$s使用了%2$s token %1$s使用了%2$s token + + 个人资料 + 昵称 + 邮箱 + 未设置 + 编辑 + 编辑个人资料 + 更换头像 + 从相册选择 + 移除头像 + 保存 + 取消 + + 活动洞察 + 总请求次数 + + %1$d 次 + + 最多请求时段 + %1$d~%2$d点 一月 二月 三月 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 50aee31..844cc36 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -516,6 +516,26 @@ %1$s使用了%2$s Token %1$s使用了%2$s Token + + 個人資料 + 暱稱 + 郵箱 + 未設定 + 編輯 + 編輯個人資料 + 更換頭像 + 從相冊選擇 + 移除頭像 + 儲存 + 取消 + + 活動洞察 + 總請求次數 + + %1$d 次 + + 最多請求時段 + %1$d~%2$d點 一月 二月 三月 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 716811a..3954dbf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,6 +520,27 @@ %1$s used %2$s tokens %1$s used %2$s tokens + + Profile + Nickname + Email + Not set + Edit + Edit Profile + Change Avatar + Choose from gallery + Remove avatar + Save + Cancel + + Activity Insights + Total requests + + %1$d request + %1$d requests + + Peak hours + %1$d–%2$d Jan Feb Mar diff --git a/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapInsightsTest.kt b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapInsightsTest.kt new file mode 100644 index 0000000..4d4f0a3 --- /dev/null +++ b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapInsightsTest.kt @@ -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(), 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) + } +} diff --git a/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt index 3c92bef..3ffe27b 100644 --- a/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt +++ b/app/src/test/java/com/rainy/token/ui/heatmap/HeatmapStatsTest.kt @@ -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) // 全连续 → 全部天数