From dd91e94805702055e371e8bb3ab33ef2349aa2c5 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:30:28 +0000 Subject: [PATCH] feat(heatmap): add HeatmapScreen, navigation route, dashboard entry, i18n strings - HeatmapScreen with TopAppBar, view switcher (Daily/Weekly/Cumulative), Canvas - DashboardScreen: new HeatmapEntryCard with CalendarMonth icon - RainyTokenNavHost: HEATMAP route for both Compact and Expanded layouts - 9 new string resources across EN/zh-Hans/zh-Hant - ArrowBack -> AutoMirrored.Filled.ArrowBack (deprecation fix) --- .../com/rainy/token/ui/RainyTokenNavHost.kt | 13 ++- .../token/ui/dashboard/DashboardScreen.kt | 56 +++++++++ .../rainy/token/ui/heatmap/HeatmapScreen.kt | 110 ++++++++++++++++++ app/src/main/res/values-b+zh+Hans/strings.xml | 11 ++ app/src/main/res/values-b+zh+Hant/strings.xml | 11 ++ app/src/main/res/values/strings.xml | 11 ++ 6 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 9035a0b..39577f8 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -48,6 +48,7 @@ import com.rainy.token.ui.dashboard.UsageDataViewModel import com.rainy.token.ui.dashboard.UsageDetailScreen import com.rainy.token.ui.dashboard.UsageOverviewScreen import com.rainy.token.ui.dashboard.UsageViewModel +import com.rainy.token.ui.heatmap.HeatmapScreen import com.rainy.token.ui.servicedetail.ServiceDetailScreen import com.rainy.token.ui.settings.CredentialEditScreen import com.rainy.token.ui.settings.SettingsScreen @@ -83,6 +84,7 @@ object Routes { const val CCGO_USAGE_DETAIL = "ccgo_usage_detail" const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview" const val CCGO_USAGE_DATA = "ccgo_usage_data" + const val HEATMAP = "heatmap" } private fun parseServiceType(typeName: String?): ServiceType = @@ -96,6 +98,7 @@ private sealed class DetailPane { object OCGOUsage : DetailPane() object CCGOUsage : DetailPane() object Settings : DetailPane() + object Heatmap : DetailPane() } @Composable @@ -191,7 +194,8 @@ private fun CompactNavHost() { onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) }, onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }, - onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) } + onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) }, + onOpenHeatmap = { navController.navigate(Routes.HEATMAP) } ) } composable(Routes.USAGE_DETAIL) { @@ -303,6 +307,9 @@ private fun CompactNavHost() { onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) } ) } + composable(Routes.HEATMAP) { + HeatmapScreen(onBack = guardedPop) + } } } @@ -336,6 +343,7 @@ private fun ExpandedLayout() { onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) }, onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage }, onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }, + onOpenHeatmap = { detailPane = DetailPane.Heatmap }, viewModel = dashboardVm ) } @@ -442,6 +450,9 @@ private fun ExpandedDetailPane( } } } + is DetailPane.Heatmap -> { + HeatmapScreen(onBack = onClose) + } is DetailPane.Settings -> { val settingsNavController = rememberNavController() // 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时 diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 98d4992..3d27300 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings @@ -122,6 +123,7 @@ fun DashboardScreen( onOpenService: (ServiceType) -> Unit, onOpenUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit = {}, + onOpenHeatmap: () -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel() ) { // 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求) @@ -280,6 +282,7 @@ fun DashboardScreen( onOpenUsageDetail = onOpenUsageDetail, onOpenCcgoUsageDetail = onOpenCcgoUsageDetail, onOpenService = onOpenService, + onOpenHeatmap = onOpenHeatmap, refreshTrigger = usageSyncTrigger ) DraggableDashboardCards( @@ -346,6 +349,7 @@ private const val DASHBOARD_ORDER_KEY = "order" private const val USAGE_OCGO_CARD_ID = "usage:opencode_go" private const val USAGE_CCGO_CARD_ID = "usage:commandcode_go" private const val DASHBOARD_CARD_SPACING_DP = 12 +private const val HEATMAP_CARD_ID = "heatmap" private data class DashboardHomeItem( val id: String, @@ -359,6 +363,7 @@ private fun rememberDashboardItems( onOpenUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit, onOpenService: (ServiceType) -> Unit, + onOpenHeatmap: () -> Unit, refreshTrigger: Int ): List { val defaultItems = buildList { @@ -368,6 +373,9 @@ private fun rememberDashboardItems( add(DashboardHomeItem(USAGE_CCGO_CARD_ID) { CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger) }) + add(DashboardHomeItem(HEATMAP_CARD_ID) { + HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap) + }) cards.forEach { card -> add(DashboardHomeItem("service:${card.service.storageKey}") { DashboardCard( @@ -685,6 +693,54 @@ private fun DashboardCard(card: DashboardCardUi, onClick: () -> Unit, onOpenUsag } } +/** + * Token 活动热力图入口卡片。 + * 白底圆角,左侧日历图标,中间标题,右侧箭头,点击跳转热力图页面。 + */ +@Composable +private fun HeatmapEntryCard(onOpenHeatmap: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onOpenHeatmap() }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.CalendarMonth, + contentDescription = null, + tint = StrawberryPink, + modifier = Modifier.size(44.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.heatmap_entry), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.heatmap_entry_subtitle), + style = MaterialTheme.typography.bodySmall, + color = inkMuted() + ) + } + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = StrawberryPink + ) + } + } +} + /** * 仪表盘底部 footer:填空白 + 显示版本号。 * 容器透明无边框,让卡片列表与背景融合自然。 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 new file mode 100644 index 0000000..a63a23e --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/heatmap/HeatmapScreen.kt @@ -0,0 +1,110 @@ +package com.rainy.token.ui.heatmap + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.rainy.token.R +import com.rainy.token.ui.theme.inkMuted + +/** + * Token 活动热力图页面。 + * + * Scaffold + TopAppBar 布局,包含视图切换器(每日/每周/累计)和 HeatmapCanvas。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HeatmapScreen( + onBack: () -> Unit, + viewModel: HeatmapViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.load() + } + + Scaffold( + containerColor = Color.Transparent, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.heatmap_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // ── 视图切换器 ── + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(horizontal = 4.dp, bottom = 12.dp) + ) { + HeatmapViewMode.entries.forEach { mode -> + val label = when (mode) { + HeatmapViewMode.DAILY -> stringResource(R.string.heatmap_view_daily) + HeatmapViewMode.WEEKLY -> stringResource(R.string.heatmap_view_weekly) + HeatmapViewMode.CUMULATIVE -> stringResource(R.string.heatmap_view_cumulative) + } + val isSelected = state.viewMode == mode + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else inkMuted(), + modifier = Modifier.clickable { viewModel.setViewMode(mode) } + ) + } + } + + // ── 热力图 Canvas ── + HeatmapCanvas( + state = state, + onDayClick = { dayData -> + // Phase 4: 显示浮层 + }, + onWeekClick = { weekData -> + // Phase 4: 显示浮层 + } + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} \ No newline at end of file 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 8579044..e19d5ef 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -492,4 +492,15 @@ 提交回调 URL 重新生成授权链接 正在准备授权... + + + Token 活动 + 每日 + 每周 + 累计 + Token 活动 + 每日 Token 使用热力图 + 暂无数据 + 少 + 多 \ No newline at end of file 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 fcdde38..d4fb0f2 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -492,4 +492,15 @@ 提交回撥 URL 重新生成授權連結 正在準備授權... + + + Token 活動 + 每日 + 每週 + 累計 + Token 活動 + 每日 Token 使用熱力圖 + 暫無資料 + 少 + 多 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ead492..5ddda63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -495,4 +495,15 @@ Submit callback URL Regenerate auth link Preparing authorization... + + + Token Activity + Daily + Weekly + Cumulative + Token Activity + Daily Token usage heatmap + No data yet + Less + More