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)
This commit is contained in:
WaterRain 2026-08-06 07:30:28 +00:00
parent 6e4a5ff9cb
commit dd91e94805
No known key found for this signature in database
6 changed files with 211 additions and 1 deletions

View File

@ -48,6 +48,7 @@ import com.rainy.token.ui.dashboard.UsageDataViewModel
import com.rainy.token.ui.dashboard.UsageDetailScreen import com.rainy.token.ui.dashboard.UsageDetailScreen
import com.rainy.token.ui.dashboard.UsageOverviewScreen import com.rainy.token.ui.dashboard.UsageOverviewScreen
import com.rainy.token.ui.dashboard.UsageViewModel 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.servicedetail.ServiceDetailScreen
import com.rainy.token.ui.settings.CredentialEditScreen import com.rainy.token.ui.settings.CredentialEditScreen
import com.rainy.token.ui.settings.SettingsScreen 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_DETAIL = "ccgo_usage_detail"
const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview" const val CCGO_USAGE_OVERVIEW = "ccgo_usage_overview"
const val CCGO_USAGE_DATA = "ccgo_usage_data" const val CCGO_USAGE_DATA = "ccgo_usage_data"
const val HEATMAP = "heatmap"
} }
private fun parseServiceType(typeName: String?): ServiceType = private fun parseServiceType(typeName: String?): ServiceType =
@ -96,6 +98,7 @@ private sealed class DetailPane {
object OCGOUsage : DetailPane() object OCGOUsage : DetailPane()
object CCGOUsage : DetailPane() object CCGOUsage : DetailPane()
object Settings : DetailPane() object Settings : DetailPane()
object Heatmap : DetailPane()
} }
@Composable @Composable
@ -191,7 +194,8 @@ private fun CompactNavHost() {
onOpenSettings = { navController.navigate(Routes.SETTINGS) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) },
onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) }, onOpenService = { type -> navController.navigate(Routes.serviceDetail(type)) },
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) }, 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) { composable(Routes.USAGE_DETAIL) {
@ -303,6 +307,9 @@ private fun CompactNavHost() {
onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) } 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) }, onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) },
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage }, onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage }, onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
viewModel = dashboardVm viewModel = dashboardVm
) )
} }
@ -442,6 +450,9 @@ private fun ExpandedDetailPane(
} }
} }
} }
is DetailPane.Heatmap -> {
HeatmapScreen(onBack = onClose)
}
is DetailPane.Settings -> { is DetailPane.Settings -> {
val settingsNavController = rememberNavController() val settingsNavController = rememberNavController()
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时 // 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时

View File

@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.KeyboardArrowRight
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@ -122,6 +123,7 @@ fun DashboardScreen(
onOpenService: (ServiceType) -> Unit, onOpenService: (ServiceType) -> Unit,
onOpenUsageDetail: () -> Unit, onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit = {}, onOpenCcgoUsageDetail: () -> Unit = {},
onOpenHeatmap: () -> Unit = {},
viewModel: DashboardViewModel = hiltViewModel() viewModel: DashboardViewModel = hiltViewModel()
) { ) {
// 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求) // 从设置页返回时重新读取本地凭据状态 + 缓存(不自动发起网络请求)
@ -280,6 +282,7 @@ fun DashboardScreen(
onOpenUsageDetail = onOpenUsageDetail, onOpenUsageDetail = onOpenUsageDetail,
onOpenCcgoUsageDetail = onOpenCcgoUsageDetail, onOpenCcgoUsageDetail = onOpenCcgoUsageDetail,
onOpenService = onOpenService, onOpenService = onOpenService,
onOpenHeatmap = onOpenHeatmap,
refreshTrigger = usageSyncTrigger refreshTrigger = usageSyncTrigger
) )
DraggableDashboardCards( 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_OCGO_CARD_ID = "usage:opencode_go"
private const val USAGE_CCGO_CARD_ID = "usage:commandcode_go" private const val USAGE_CCGO_CARD_ID = "usage:commandcode_go"
private const val DASHBOARD_CARD_SPACING_DP = 12 private const val DASHBOARD_CARD_SPACING_DP = 12
private const val HEATMAP_CARD_ID = "heatmap"
private data class DashboardHomeItem( private data class DashboardHomeItem(
val id: String, val id: String,
@ -359,6 +363,7 @@ private fun rememberDashboardItems(
onOpenUsageDetail: () -> Unit, onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit, onOpenCcgoUsageDetail: () -> Unit,
onOpenService: (ServiceType) -> Unit, onOpenService: (ServiceType) -> Unit,
onOpenHeatmap: () -> Unit,
refreshTrigger: Int refreshTrigger: Int
): List<DashboardHomeItem> { ): List<DashboardHomeItem> {
val defaultItems = buildList { val defaultItems = buildList {
@ -368,6 +373,9 @@ private fun rememberDashboardItems(
add(DashboardHomeItem(USAGE_CCGO_CARD_ID) { add(DashboardHomeItem(USAGE_CCGO_CARD_ID) {
CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger) CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger)
}) })
add(DashboardHomeItem(HEATMAP_CARD_ID) {
HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap)
})
cards.forEach { card -> cards.forEach { card ->
add(DashboardHomeItem("service:${card.service.storageKey}") { add(DashboardHomeItem("service:${card.service.storageKey}") {
DashboardCard( 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:填空白 + 显示版本号。 * 仪表盘底部 footer:填空白 + 显示版本号。
* 容器透明无边框,让卡片列表与背景融合自然。 * 容器透明无边框,让卡片列表与背景融合自然。

View File

@ -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))
}
}
}

View File

@ -492,4 +492,15 @@
<string name="action_submit_callback">提交回调 URL</string> <string name="action_submit_callback">提交回调 URL</string>
<string name="action_regenerate_link">重新生成授权链接</string> <string name="action_regenerate_link">重新生成授权链接</string>
<string name="oauth_preparing">正在准备授权...</string> <string name="oauth_preparing">正在准备授权...</string>
<!-- ══════════ Token 活动热力图 ══════════ -->
<string name="heatmap_title">Token 活动</string>
<string name="heatmap_view_daily">每日</string>
<string name="heatmap_view_weekly">每周</string>
<string name="heatmap_view_cumulative">累计</string>
<string name="heatmap_entry">Token 活动</string>
<string name="heatmap_entry_subtitle">每日 Token 使用热力图</string>
<string name="heatmap_no_data">暂无数据</string>
<string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string>
</resources> </resources>

View File

@ -492,4 +492,15 @@
<string name="action_submit_callback">提交回撥 URL</string> <string name="action_submit_callback">提交回撥 URL</string>
<string name="action_regenerate_link">重新生成授權連結</string> <string name="action_regenerate_link">重新生成授權連結</string>
<string name="oauth_preparing">正在準備授權...</string> <string name="oauth_preparing">正在準備授權...</string>
<!-- ══════════ Token 活动热力图 ══════════ -->
<string name="heatmap_title">Token 活動</string>
<string name="heatmap_view_daily">每日</string>
<string name="heatmap_view_weekly">每週</string>
<string name="heatmap_view_cumulative">累計</string>
<string name="heatmap_entry">Token 活動</string>
<string name="heatmap_entry_subtitle">每日 Token 使用熱力圖</string>
<string name="heatmap_no_data">暫無資料</string>
<string name="heatmap_legend_less">少</string>
<string name="heatmap_legend_more">多</string>
</resources> </resources>

View File

@ -495,4 +495,15 @@
<string name="action_submit_callback">Submit callback URL</string> <string name="action_submit_callback">Submit callback URL</string>
<string name="action_regenerate_link">Regenerate auth link</string> <string name="action_regenerate_link">Regenerate auth link</string>
<string name="oauth_preparing">Preparing authorization...</string> <string name="oauth_preparing">Preparing authorization...</string>
<!-- ══════════ Token 活动热力图 ══════════ -->
<string name="heatmap_title">Token Activity</string>
<string name="heatmap_view_daily">Daily</string>
<string name="heatmap_view_weekly">Weekly</string>
<string name="heatmap_view_cumulative">Cumulative</string>
<string name="heatmap_entry">Token Activity</string>
<string name="heatmap_entry_subtitle">Daily Token usage heatmap</string>
<string name="heatmap_no_data">No data yet</string>
<string name="heatmap_legend_less">Less</string>
<string name="heatmap_legend_more">More</string>
</resources> </resources>