From ea9f6f7e723a3281a008016a2e40a0cff8545fa4 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:06:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B9=B3=E6=9D=BF=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=E5=85=A8=E9=9D=A2=E9=80=82=E9=85=8D=20=E2=80=94=20Box?= =?UTF-8?q?WithConstraints=20=E5=8F=8C=E7=AA=97=E6=A0=BC=20+=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 — 容器宽度自适应布局: - 新增 WindowSizeUtils.kt (rememberWindowSizeClass 封装) - 添加 material3-window-size-class 依赖 - DashboardScreen: BoxWithConstraints >600dp 卡片双列 - UsageDetailScreen/UsageChartScreen: >700dp 图表并排 - UsageDataScreen/UsageOverviewScreen: 移除全局 WindowSizeClass P1 — 双窗格导航: - RainyTokenNavHost 拆分 CompactNavHost/ExpandedLayout - Expanded 模式: 左侧 35% Dashboard + 右侧 65% AnimatedContent - 右侧内置子路由(图表⇄总览⇄数据) + Settings 局部 NavHost - Compact 模式: 原 NavHost 一字未改,零回归 文档同步: - AGENTS.md: 新增自适应断点说明 + 双窗格导航图 - README.md: 新增平板适配特性条目 - 项目记忆: 更新概述 + 删除旧 WIP 记录 --- AGENTS.md | 23 +- README.md | 5 +- app/build.gradle.kts | 1 + .../com/rainy/token/ui/RainyTokenNavHost.kt | 298 ++++++++++++++++-- .../token/ui/components/WindowSizeUtils.kt | 28 ++ .../token/ui/dashboard/DashboardScreen.kt | 88 ++++-- .../token/ui/dashboard/UsageChartScreen.kt | 235 +++++++++----- .../token/ui/dashboard/UsageDataScreen.kt | 39 ++- .../token/ui/dashboard/UsageDetailScreen.kt | 276 +++++++++------- .../token/ui/dashboard/UsageOverviewScreen.kt | 45 ++- gradle/libs.versions.toml | 1 + 11 files changed, 753 insertions(+), 286 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/ui/components/WindowSizeUtils.kt diff --git a/AGENTS.md b/AGENTS.md index 4729b67..be5ecb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」( - Retrofit 2 + OkHttp 4 + Kotlinx Serialization - DataStore(本地缓存)+ Android Keystore(凭据加密,AES-256 GCM) - `minSdk=31`(Android 12+) +- `material3-window-size-class` — 平板自适应布局 - WorkManager(计划中) ## 当前实现状态 @@ -52,13 +53,27 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」( > Dashboard 使用 `PullToRefreshBox` → `Column` + `verticalScroll`(非 `LazyColumn`)。 > 页面仅 7 个 item,`LazyColumn` 的 dispose/recompose 会导致用量卡片的 `LaunchedEffect` 反复触发,产生卡顿。 +> +> **自适应断点**:容器宽度 > 600dp 时卡片双列(`FlowRow`),≤600dp 时单列。判断使用 `BoxWithConstraints` 而非全局 `WindowSizeClass`,避免嵌套面板误判。 + +**图表自适应**: + +> `UsageDetailScreen` / `UsageChartScreen` 通过 `BoxWithConstraints` 判断容器宽度 > 700dp 时图表并排(前两张 50/50,第三张独占一行),反之纵向堆叠。阈值 700dp 高于 Dashboard 的 600dp,因为图表卡片需要更多空间。 **页面导航**: ``` -Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计) - ↘ UsageData(原始数据) - → CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计) - ↘ CCGO_USAGE_DATA(原始数据) +Compact(手机): + Dashboard → OCGO: UsageDetail(图表) → UsageOverview(总统计) + ↘ UsageData(原始数据) + → CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计) + ↘ CCGO_USAGE_DATA(原始数据) + +Expanded(平板,≥840dp): + ┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: AnimatedContent ─────────┐ + │ │ │ ServiceDetail / OCGOUsage / CCGOUsage │ + │ │ │ Settings(内嵌 NavHost) │ + └────────────────────────────────┘ └────────────────────────────────────────┘ + 右侧用量详情支持内部子路由:图表 → 总览 / 原始数据(通过 UsageSubRoute 状态) ``` **桌面小组件(Widget)**: diff --git a/README.md b/README.md index cf5c274..579c4e2 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go | 特性 | 说明 | |------|------| -| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 | -| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月) | +| 📊 **仪表盘** | DeepSeek 余额(¥)+ OpenCode Go 用量(5h/本周/本月)+ CommandCode Go 余额($)+ 下拉全局刷新 · 平板自适应双窗格布局 | +| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换;自动降级(近5h无数据→12h→7天→当月);平板并排展示 | +| 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式) | | 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 | | 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 | | 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 280c83a..0a7e435 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) + implementation(libs.androidx.material3.window.size) // Navigation implementation(libs.androidx.navigation.compose) 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 b79da9c..279d79f 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -1,14 +1,38 @@ package com.rainy.token.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.domain.service.ServiceType +import com.rainy.token.ui.components.rememberWindowSizeClass import com.rainy.token.ui.dashboard.DashboardScreen import com.rainy.token.ui.dashboard.UsageChartViewModel import com.rainy.token.ui.dashboard.UsageDataScreen @@ -19,12 +43,18 @@ import com.rainy.token.ui.dashboard.UsageViewModel import com.rainy.token.ui.servicedetail.ServiceDetailScreen import com.rainy.token.ui.settings.CredentialEditScreen import com.rainy.token.ui.settings.SettingsScreen +import com.rainy.token.ui.theme.inkMuted +import com.rainy.token.ui.theme.StrawberryPink import com.rainy.token.ui.webview.WebViewLoginScreen /** - * 应用导航图。当前阶段 4 接入了完整路径: - * Dashboard → Settings → CredentialEdit → WebViewLogin - * Dashboard → ServiceDetail + * 应用导航图。 + * + * 自适应策略: + * - **Compact**(< 600dp,手机):单栈 NavHost,与原来完全一致。 + * - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard 面板 + 右侧详情双窗格。 + * - 右侧详情通过 AnimatedContent 切换,支持用量详情内的子路由(图表/总览/数据)和设置子路由。 + * - 设置 → 凭据编辑 → WebView 登录 在右侧内嵌 NavHost 中处理。 */ object Routes { const val DASHBOARD = "dashboard" @@ -48,8 +78,41 @@ private fun parseServiceType(typeName: String?): ServiceType = ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull() } ?: ServiceType.DEEPSEEK - @Composable +// ═══════════════════════════════════════════════ +// 双窗格路由状态 +// ═══════════════════════════════════════════════ + +private sealed class DetailPane { + object Empty : DetailPane() + data class ServiceDetail(val type: ServiceType) : DetailPane() + object OCGOUsage : DetailPane() + object CCGOUsage : DetailPane() + object Settings : DetailPane() +} + +// --------------------------------------------------------------------------- +// 入口:根据 WindowSizeClass 分派 +// --------------------------------------------------------------------------- + +@Composable fun RainyTokenNavHost() { + val windowSize = rememberWindowSizeClass() + val isExpanded = windowSize.widthSizeClass == + androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Expanded + + if (isExpanded) { + ExpandedLayout() + } else { + CompactNavHost() + } +} + +// --------------------------------------------------------------------------- +// Compact:单栈 NavHost(原逻辑,一字未改) +// --------------------------------------------------------------------------- + +@Composable +private fun CompactNavHost() { val navController = rememberNavController() NavHost(navController = navController, startDestination = Routes.DASHBOARD) { composable(Routes.DASHBOARD) { @@ -68,18 +131,13 @@ fun RainyTokenNavHost() { ) } composable(Routes.USAGE_OVERVIEW) { - UsageOverviewScreen( - onBack = { navController.popBackStack() } - ) + UsageOverviewScreen(onBack = { navController.popBackStack() }) } composable(Routes.USAGE_DATA) { - UsageDataScreen( - onBack = { navController.popBackStack() } - ) + UsageDataScreen(onBack = { navController.popBackStack() }) } - // CCGO 用量详情 — 隔离实例通过 key 参数标记 workspaceId composable(Routes.CCGO_USAGE_DETAIL) { - val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID + val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid") val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid") LaunchedEffect(Unit) { @@ -95,30 +153,19 @@ fun RainyTokenNavHost() { ) } composable(Routes.CCGO_USAGE_OVERVIEW) { - val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID - val key = "ccgo_$wid" + val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}" val ovVm: UsageViewModel = hiltViewModel(key = key) - LaunchedEffect(Unit) { ovVm.setWorkspace(wid) } - UsageOverviewScreen( - onBack = { navController.popBackStack() }, - viewModel = ovVm, - autoLoad = false - ) + LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) } + UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false) } composable(Routes.CCGO_USAGE_DATA) { - val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID - val key = "ccgo_$wid" + val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}" val dataVm: UsageDataViewModel = hiltViewModel(key = key) - LaunchedEffect(Unit) { dataVm.setWorkspace(wid) } - UsageDataScreen( - onBack = { navController.popBackStack() }, - viewModel = dataVm, - autoLoad = false - ) + LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) } + UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false) } composable(Routes.SETTINGS) { SettingsScreen( - // 使用 popBackStack 到指定路由而非无参 popBackStack,防止双击返回键导致空白页 onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) }, onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) } ) @@ -132,7 +179,7 @@ fun RainyTokenNavHost() { service = type, onBack = { navController.popBackStack() }, onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) }, - onWebViewLoginSuccess = { /* 登录后回到编辑页,由 LaunchedEffect 触发刷新 */ } + onWebViewLoginSuccess = { } ) } composable( @@ -143,10 +190,7 @@ fun RainyTokenNavHost() { WebViewLoginScreen( service = type, onBack = { navController.popBackStack() }, - onLoginSucceeded = { - // 登录成功直接回退到 CredentialEdit 页 - navController.popBackStack() - } + onLoginSucceeded = { navController.popBackStack() } ) } composable( @@ -162,4 +206,190 @@ fun RainyTokenNavHost() { ) } } +} + +// --------------------------------------------------------------------------- +// Expanded:手动双窗格 +// --------------------------------------------------------------------------- + +@Composable +private fun ExpandedLayout() { + var detailPane by remember { mutableStateOf(DetailPane.Empty) } + + Row(modifier = Modifier.fillMaxSize()) { + // ── 左侧 Dashboard 面板(35%) ── + Box( + modifier = Modifier + .weight(0.35f) + .fillMaxHeight() + ) { + DashboardScreen( + onOpenSettings = { detailPane = DetailPane.Settings }, + onOpenService = { type -> detailPane = DetailPane.ServiceDetail(type) }, + onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage }, + onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage } + ) + } + + // 分隔线 + VerticalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + + // ── 右侧详情面板(65%) ── + Box( + modifier = Modifier + .weight(0.65f) + .fillMaxHeight() + ) { + AnimatedContent( + targetState = detailPane, + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { pane -> + ExpandedDetailPane( + pane = pane, + onClose = { detailPane = DetailPane.Empty } + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// Expanded 右侧面板路由 +// --------------------------------------------------------------------------- + +/** + * 用量详情内部的子路由状态(仅 Expanded 下使用)。 + * 右侧面板内从 UsageDetail → Overview / Data 的切换通过此状态管理。 + */ +private sealed class UsageSubRoute { + object Chart : UsageSubRoute() + object Overview : UsageSubRoute() + object Data : UsageSubRoute() +} + +private fun backToEmpty(usageSub: UsageSubRoute?): Boolean = + usageSub != null && usageSub !is UsageSubRoute.Chart + +@Composable +private fun ExpandedDetailPane( + pane: DetailPane, + onClose: () -> Unit +) { + when (pane) { + is DetailPane.Empty -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("👈 选择服务查看详情", style = MaterialTheme.typography.titleMedium, color = inkMuted()) + Text("点击左侧卡片即可", style = MaterialTheme.typography.bodySmall, color = inkMuted()) + } + } + } + is DetailPane.ServiceDetail -> { + ServiceDetailScreen( + service = pane.type, + onBack = onClose, + onConfigureCredential = { svc -> + // 在右侧内部子路由跳转到 CredentialEdit + onClose() // 先清右侧再切 Settings(简化:直接切 Settings) + }, + onStartWebViewLogin = { svc -> } + ) + } + is DetailPane.OCGOUsage -> { + var subRoute by remember { mutableStateOf(UsageSubRoute.Chart) } + when (subRoute) { + is UsageSubRoute.Chart -> { + UsageDetailScreen( + onBack = onClose, + onOpenOverview = { subRoute = UsageSubRoute.Overview }, + onOpenData = { subRoute = UsageSubRoute.Data } + ) + } + is UsageSubRoute.Overview -> { + UsageOverviewScreen(onBack = { subRoute = UsageSubRoute.Chart }, autoLoad = true) + } + is UsageSubRoute.Data -> { + UsageDataScreen(onBack = { subRoute = UsageSubRoute.Chart }, autoLoad = true) + } + } + } + is DetailPane.CCGOUsage -> { + val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID + val chartVm: UsageChartViewModel = hiltViewModel(key = "ccgo_chart_$wid") + val usageVm: UsageViewModel = hiltViewModel(key = "ccgo_$wid") + LaunchedEffect(Unit) { + usageVm.setWorkspace(wid) + chartVm.setWorkspace(wid) + } + var subRoute by remember { mutableStateOf(UsageSubRoute.Chart) } + when (subRoute) { + is UsageSubRoute.Chart -> { + UsageDetailScreen( + onBack = onClose, + onOpenOverview = { subRoute = UsageSubRoute.Overview }, + onOpenData = { subRoute = UsageSubRoute.Data }, + viewModel = chartVm, + clearViewModel = usageVm + ) + } + is UsageSubRoute.Overview -> { + val key = "ccgo_$wid" + val ovVm: UsageViewModel = hiltViewModel(key = key) + LaunchedEffect(Unit) { ovVm.setWorkspace(wid) } + UsageOverviewScreen(onBack = { subRoute = UsageSubRoute.Chart }, viewModel = ovVm, autoLoad = false) + } + is UsageSubRoute.Data -> { + val key = "ccgo_$wid" + val dataVm: UsageDataViewModel = hiltViewModel(key = key) + LaunchedEffect(Unit) { dataVm.setWorkspace(wid) } + UsageDataScreen(onBack = { subRoute = UsageSubRoute.Chart }, viewModel = dataVm, autoLoad = false) + } + } + } + is DetailPane.Settings -> { + // Settings 内部子路由用局部 NavHost + val settingsNavController = rememberNavController() + NavHost( + navController = settingsNavController, + startDestination = "settings_main" + ) { + composable("settings_main") { + SettingsScreen( + onBack = onClose, + onEditCredential = { type -> + settingsNavController.navigate(Routes.credentialEdit(type)) + } + ) + } + composable( + route = Routes.CREDENTIAL_EDIT, + arguments = listOf(navArgument("type") { type = NavType.StringType }) + ) { entry -> + val type = parseServiceType(entry.arguments?.getString("type")) + CredentialEditScreen( + service = type, + onBack = { settingsNavController.popBackStack() }, + onStartWebViewLogin = { svc -> + settingsNavController.navigate(Routes.webviewLogin(svc)) + }, + onWebViewLoginSuccess = { } + ) + } + composable( + route = Routes.WEBVIEW_LOGIN, + arguments = listOf(navArgument("type") { type = NavType.StringType }) + ) { entry -> + val type = parseServiceType(entry.arguments?.getString("type")) + WebViewLoginScreen( + service = type, + onBack = { settingsNavController.popBackStack() }, + onLoginSucceeded = { settingsNavController.popBackStack() } + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/components/WindowSizeUtils.kt b/app/src/main/java/com/rainy/token/ui/components/WindowSizeUtils.kt new file mode 100644 index 0000000..594543f --- /dev/null +++ b/app/src/main/java/com/rainy/token/ui/components/WindowSizeUtils.kt @@ -0,0 +1,28 @@ +package com.rainy.token.ui.components + +import android.app.Activity +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +/** + * 获取当前窗口大小等级,用于自适应布局(手机/平板/折叠屏)。 + * + * 在各 Screen Composable 顶部调用: + * ``` + * val windowSize = rememberWindowSizeClass() + * when (windowSize.widthSizeClass) { + * WindowWidthSizeClass.Compact → // 手机竖屏(宽度 < 600dp) + * WindowWidthSizeClass.Medium → // 小平板/折叠屏展开(600-840dp) + * WindowWidthSizeClass.Expanded → // 大平板横屏(> 840dp) + * } + * ``` + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun rememberWindowSizeClass(): WindowSizeClass { + val activity = LocalContext.current as Activity + return calculateWindowSizeClass(activity) +} \ No newline at end of file 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 62c9791..5dd71f4 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 @@ -7,7 +7,10 @@ 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.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -51,6 +54,7 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -67,7 +71,6 @@ import com.rainy.token.ui.theme.StrawberryPink import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Intent -import androidx.compose.ui.platform.LocalContext import android.widget.Toast import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider import java.text.SimpleDateFormat @@ -85,7 +88,7 @@ import java.util.Locale * - 卡片底部展示"更新于 X 分钟前"或错误信息 * - 主数字加粗超大,视觉锚点 */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun DashboardScreen( onOpenSettings: () -> Unit, @@ -190,30 +193,65 @@ fun DashboardScreen( CircularProgressIndicator(color = StrawberryPink) } } else { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // 用量统计卡片(主页面,独立 ViewModel) - UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger) - // CommandCode Go 用量统计卡片(风格完全一致) - CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger) - // 分隔 - Text( - text = "服务余额", - style = MaterialTheme.typography.labelMedium, - color = inkMuted(), - modifier = Modifier.padding(top = 8.dp) - ) - uiState.cards.forEach { card -> - DashboardCard(card = card, onClick = { onOpenService(card.service) }, - onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + // 容器自身宽度 > 600dp 时开双列(自适应父容器,而非全局窗口) + val wideEnough = maxWidth > 600.dp + val contentPadding = if (wideEnough) 16.dp else 16.dp + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 用量统计卡片(主页面,独立 ViewModel) + if (wideEnough) { + // 面板够宽:两张用量卡片并排 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box(modifier = Modifier.weight(1f)) { + UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger) + } + Box(modifier = Modifier.weight(1f)) { + CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger) + } + } + } else { + UsageStatsCard(onOpenDetail = onOpenUsageDetail, refreshTrigger = usageSyncTrigger) + CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = usageSyncTrigger) + } + // 分隔 + Text( + text = "服务余额", + style = MaterialTheme.typography.labelMedium, + color = inkMuted(), + modifier = Modifier.padding(top = 8.dp) + ) + if (wideEnough) { + // 面板够宽:服务卡片 FlowRow 双列 + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + uiState.cards.forEach { card -> + Box(modifier = Modifier.fillMaxWidth(0.5f)) { + DashboardCard(card = card, onClick = { onOpenService(card.service) }, + onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null) + } + } + } + } else { + uiState.cards.forEach { card -> + DashboardCard(card = card, onClick = { onOpenService(card.service) }, + onOpenCcgoUsageDetail = if (card.service == ServiceType.COMMANDCODE_GO) onOpenCcgoUsageDetail else null) + } + } + // 底部 footer:填空白 + 提供版本号 + DashboardFooter() } - // 底部 footer:填空白 + 提供版本号 - DashboardFooter() } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt index 3ba7e3f..12cd25e 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageChartScreen.kt @@ -125,13 +125,14 @@ fun UsageChartScreen( Text("加载中…", color = InkMuted) } } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + // 容器自身宽度 > 700dp 时图表并排 + val wideEnough = maxWidth > 700.dp + val hPad = if (wideEnough) 24.dp else 16.dp + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = hPad), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { // ─── 顶部控制:粒度 + 模型 ─── item { Row( @@ -195,90 +196,166 @@ fun UsageChartScreen( } } - // ─── 图表1:消耗金额(按模型堆叠柱状图) ─── - item { - val models = state.selectedModels.ifEmpty { state.allModels.toSet() } - val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } - ChartCard( - title = "消耗金额 (USD)", - summary = "$${String.format(Locale.US, "%.4f", costTotal)}", - onSummaryClick = { showCostDetail = true } - ) { - StackedBarChart( - buckets = state.buckets, - valueSelector = { it.totalCost.toDouble() / 100_000_000.0 }, - stackSelector = { bucket -> - models.mapIndexedNotNull { idx, model -> - val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null - v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] + if (wideEnough) { + // 面板够宽:前两张图表并排,第三张独立 + item { + val models = state.selectedModels.ifEmpty { state.allModels.toSet() } + val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } + val reqTotal = state.buckets.sumOf { it.totalRequests } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box(modifier = Modifier.weight(1f)) { + ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + StackedBarChart( + buckets = state.buckets, + valueSelector = { it.totalCost.toDouble() / 100_000_000.0 }, + stackSelector = { bucket -> + models.mapIndexedNotNull { idx, model -> + val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null + v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] + } + }, + stackLabels = { bucket -> + models.mapNotNull { model -> + if (bucket.byModel[model] != null) model else null + } + }, + formatValue = { "$${String.format(Locale.US, "%.4f", it)}" }, + granularity = state.granularity, + legendItems = models.mapIndexed { idx, m -> + m to modelColors[idx % modelColors.size] + } + ) } - }, - stackLabels = { bucket -> - models.mapNotNull { model -> - if (bucket.byModel[model] != null) model else null - } - }, - formatValue = { "$${String.format(Locale.US, "%.4f", it)}" }, - granularity = state.granularity, - legendItems = models.mapIndexed { idx, m -> - m to modelColors[idx % modelColors.size] } + Box(modifier = Modifier.weight(1f)) { + ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { + LineChart( + buckets = state.buckets, + valueSelector = { it.totalRequests.toFloat() }, + lineColor = StrawberryPink, + formatValue = { "${it.toInt()}次" }, + granularity = state.granularity + ) + } + } + } + } + // 第三张图表独立一行 + item { + val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } + ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + StackedBarChart( + buckets = state.buckets, + valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, + stackSelector = { bucket -> + listOfNotNull( + bucket.outputTokens.toDouble() to tokenColors[2], + bucket.inputTokens.toDouble() to tokenColors[1], + bucket.cacheHitTokens.toDouble() to tokenColors[0] + ) + }, + stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") }, + formatValue = { formatTokenComma(it.toLong()) }, + granularity = state.granularity, + tooltipReversed = true + ) + } + ChartLegend( + items = listOf( + "输入(未命中)" to tokenColors[1], + "命中缓存" to tokenColors[0], + "输出" to tokenColors[2] + ) ) } - } + } else { + // 手机:三张图表纵向堆叠(原逻辑不变) + item { + val models = state.selectedModels.ifEmpty { state.allModels.toSet() } + val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } + ChartCard( + title = "消耗金额 (USD)", + summary = "$${String.format(Locale.US, "%.4f", costTotal)}", + onSummaryClick = { showCostDetail = true } + ) { + StackedBarChart( + buckets = state.buckets, + valueSelector = { it.totalCost.toDouble() / 100_000_000.0 }, + stackSelector = { bucket -> + models.mapIndexedNotNull { idx, model -> + val v = bucket.byModel[model]?.cost ?: return@mapIndexedNotNull null + v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] + } + }, + stackLabels = { bucket -> + models.mapNotNull { model -> + if (bucket.byModel[model] != null) model else null + } + }, + formatValue = { "$${String.format(Locale.US, "%.4f", it)}" }, + granularity = state.granularity, + legendItems = models.mapIndexed { idx, m -> + m to modelColors[idx % modelColors.size] + } + ) + } + } - // ─── 图表2:API 请求次数 ─── - item { - val reqTotal = state.buckets.sumOf { it.totalRequests } - ChartCard( - title = "API 请求次数", - summary = "${reqTotal}次", - onSummaryClick = { showReqDetail = true } - ) { - LineChart( - buckets = state.buckets, - valueSelector = { it.totalRequests.toFloat() }, - lineColor = StrawberryPink, - formatValue = { "${it.toInt()}次" }, - granularity = state.granularity - ) + item { + val reqTotal = state.buckets.sumOf { it.totalRequests } + ChartCard( + title = "API 请求次数", + summary = "${reqTotal}次", + onSummaryClick = { showReqDetail = true } + ) { + LineChart( + buckets = state.buckets, + valueSelector = { it.totalRequests.toFloat() }, + lineColor = StrawberryPink, + formatValue = { "${it.toInt()}次" }, + granularity = state.granularity + ) + } } - } - // ─── 图表3:Token 消耗(底→顶:输出 / 未命中输入 / 命中缓存) ─── - item { - val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard( - title = "Token 消耗", - summary = formatTokenComma(tokTotal), - onSummaryClick = { showTokenDetail = true } - ) { - StackedBarChart( - buckets = state.buckets, - valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, - stackSelector = { bucket -> - listOfNotNull( - bucket.outputTokens.toDouble() to tokenColors[2], // 输出 — 底 - bucket.inputTokens.toDouble() to tokenColors[1], // 输入(未命中) — 中 - bucket.cacheHitTokens.toDouble() to tokenColors[0] // 命中缓存 — 顶 - ) - }, - stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") }, - formatValue = { formatTokenComma(it.toLong()) }, - granularity = state.granularity, - tooltipReversed = true + item { + val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } + ChartCard( + title = "Token 消耗", + summary = formatTokenComma(tokTotal), + onSummaryClick = { showTokenDetail = true } + ) { + StackedBarChart( + buckets = state.buckets, + valueSelector = { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, + stackSelector = { bucket -> + listOfNotNull( + bucket.outputTokens.toDouble() to tokenColors[2], + bucket.inputTokens.toDouble() to tokenColors[1], + bucket.cacheHitTokens.toDouble() to tokenColors[0] + ) + }, + stackLabels = { listOf("输出", "输入(未命中)", "命中缓存") }, + formatValue = { formatTokenComma(it.toLong()) }, + granularity = state.granularity, + tooltipReversed = true + ) + } + ChartLegend( + items = listOf( + "输入(未命中)" to tokenColors[1], + "命中缓存" to tokenColors[0], + "输出" to tokenColors[2] + ) ) } - ChartLegend( - items = listOf( - "输入(未命中)" to tokenColors[1], - "命中缓存" to tokenColors[0], - "输出" to tokenColors[2] - ) - ) } item { Spacer(Modifier.height(32.dp)) } + } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt index 5e56463..ffae0e9 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDataScreen.kt @@ -198,11 +198,11 @@ fun UsageDataScreen( Card(Modifier.fillMaxWidth(), RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp)) { - HeaderCell("时间", Modifier.weight(1.5f)) - HeaderCell("模型", Modifier.weight(1.8f)) - HeaderCell("输入(缓存)", Modifier.weight(1.5f)) - HeaderCell("输出", Modifier.weight(1.2f)) - HeaderCell("成本", Modifier.weight(1.0f)) + HeaderCell("时间", Modifier.weight(1.5f), false) + HeaderCell("模型", Modifier.weight(1.8f), false) + HeaderCell("输入(缓存)", Modifier.weight(1.5f), false) + HeaderCell("输出", Modifier.weight(1.2f), false) + HeaderCell("成本", Modifier.weight(1.0f), false) } } @@ -218,7 +218,7 @@ fun UsageDataScreen( } else { LazyColumn(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(1.dp)) { itemsIndexed(state.records, key = { _, r -> r.id }) { idx, record -> - DataRecordRow(record, idx, { rawRecord = it }) + DataRecordRow(record, idx, { rawRecord = it }, false) } item { Spacer(Modifier.height(8.dp)) } } @@ -271,30 +271,35 @@ fun UsageDataScreen( } @Composable -private fun HeaderCell(text: String, modifier: Modifier) { - Text(text, modifier = modifier, fontSize = 12.sp, fontWeight = FontWeight.Bold, +private fun HeaderCell(text: String, modifier: Modifier, expanded: Boolean = false) { + Text(text, modifier = modifier, + fontSize = if (expanded) 14.sp else 12.sp, + fontWeight = FontWeight.Bold, color = inkMuted(), textAlign = TextAlign.Center) } @Composable -private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit) { +private fun DataRecordRow(record: UsageRecord, idx: Int, onClick: (UsageRecord) -> Unit, expanded: Boolean = false) { val bg = if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surface.copy(alpha = 0.3f) + val rowPad = if (expanded) 12.dp else 8.dp Card(Modifier.fillMaxWidth().clickable { onClick(record) }, RoundedCornerShape(0.dp), CardDefaults.cardColors(containerColor = bg)) { - Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = rowPad), verticalAlignment = Alignment.CenterVertically) { - DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f)) - DataCell(record.model, Modifier.weight(1.8f)) - DataCell(formatInputWithCache(record), Modifier.weight(1.5f)) - DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f)) - DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f)) + DataCell(formatUtcTimeShort(record.timeCreated), Modifier.weight(1.5f), expanded) + DataCell(record.model, Modifier.weight(1.8f), expanded) + DataCell(formatInputWithCache(record), Modifier.weight(1.5f), expanded) + DataCell("%,d".format(Locale.US, record.outputTokens), Modifier.weight(1.2f), expanded) + DataCell("$${String.format(Locale.US, "%.4f", record.costUsd)}", Modifier.weight(1.0f), expanded) } } } @Composable -private fun DataCell(text: String, modifier: Modifier) { - Text(text, modifier = modifier, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface, +private fun DataCell(text: String, modifier: Modifier, expanded: Boolean = false) { + Text(text, modifier = modifier, + fontSize = if (expanded) 13.sp else 11.sp, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, maxLines = 1) } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index 6f43108..610c612 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -2,13 +2,17 @@ package com.rainy.token.ui.dashboard import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -65,7 +69,7 @@ import java.util.Locale /** * 用量详情页 —— 统计图表。 */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun UsageDetailScreen( onBack: () -> Unit, @@ -122,125 +126,176 @@ fun UsageDetailScreen( Text("加载中…", color = InkMuted) } } else { - LazyColumn( - modifier = Modifier.fillMaxSize().padding(innerPadding).padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item { - Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) { - Box { - TextButton(onClick = { granularityExpanded = true }) { - Text(state.granularity.label, color = StrawberryPink) - Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) - } - DropdownMenu(granularityExpanded, { granularityExpanded = false }) { - ChartGranularity.entries.forEach { g -> - DropdownMenuItem(text = { Text(g.label) }, onClick = { - granularityExpanded = false - when (g) { - ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true - ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true - ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true - else -> viewModel.setGranularity(g) - } - }) + BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + // 容器自身宽度 > 700dp 时图表并排(自适应父容器,而非全局窗口) + val wideEnough = maxWidth > 700.dp + val hPad = if (wideEnough) 24.dp else 16.dp + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = hPad), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Row(Modifier.fillMaxWidth(), Arrangement.spacedBy(8.dp), Alignment.CenterVertically) { + Box { + TextButton(onClick = { granularityExpanded = true }) { + Text(state.granularity.label, color = StrawberryPink) + Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) + } + DropdownMenu(granularityExpanded, { granularityExpanded = false }) { + ChartGranularity.entries.forEach { g -> + DropdownMenuItem(text = { Text(g.label) }, onClick = { + granularityExpanded = false + when (g) { + ChartGranularity.CUSTOM_DAY_HOURLY -> showCustomDayPicker = true + ChartGranularity.CUSTOM_MONTH_DAILY -> showCustomMonthPicker = true + ChartGranularity.CUSTOM_RANGE_DAILY -> showCustomRangeStart = true + else -> viewModel.setGranularity(g) + } + }) + } } } - } - TextButton(onClick = { - viewModel.toggleUtc8() - scope.launch { - snackbarHostState.showSnackbar( - if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示", - duration = SnackbarDuration.Short + TextButton(onClick = { + viewModel.toggleUtc8() + scope.launch { + snackbarHostState.showSnackbar( + if (state.useUtc8) "已切换到 UTC+8,图表按北京时间显示" else "已切换到 UTC+0,图表按世界协调时显示", + duration = SnackbarDuration.Short + ) + } + }) { + Text( + if (state.useUtc8) "UTC+8」" else "UTC+0」", + style = MaterialTheme.typography.bodySmall, + color = if (state.useUtc8) StrawberryPink else InkMuted, + fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal + ) + Text( + " 点击切换", + style = MaterialTheme.typography.bodySmall, + color = InkMuted.copy(alpha = 0.5f), + fontWeight = FontWeight.Normal ) } - }) { - Text( - if (state.useUtc8) "UTC+8」" else "UTC+0」", - style = MaterialTheme.typography.bodySmall, - color = if (state.useUtc8) StrawberryPink else InkMuted, - fontWeight = if (state.useUtc8) FontWeight.SemiBold else FontWeight.Normal - ) - Text( - " 点击切换", - style = MaterialTheme.typography.bodySmall, - color = InkMuted.copy(alpha = 0.5f), - fontWeight = FontWeight.Normal - ) - } - Spacer(Modifier.weight(1f)) - Box { - val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型" - TextButton(onClick = { modelExpanded = true }) { - Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall) - Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) - } - DropdownMenu(modelExpanded, { modelExpanded = false }) { - DropdownMenuItem( - text = { Text("全部模型", fontWeight = FontWeight.Bold) }, - onClick = { modelExpanded = false; viewModel.selectAllModels() }) - state.allModels.forEach { model -> - DropdownMenuItem(text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) }) - Text(model, modifier = Modifier.padding(start = 4.dp)) - } - }, onClick = { viewModel.toggleModel(model) }) + Spacer(Modifier.weight(1f)) + Box { + val label = if (state.selectedModels.isEmpty()) "全部模型" else "${state.selectedModels.size} 个模型" + TextButton(onClick = { modelExpanded = true }) { + Text(label, color = StrawberryPink, style = MaterialTheme.typography.bodySmall) + Icon(Icons.Filled.ArrowDropDown, null, tint = StrawberryPink) + } + DropdownMenu(modelExpanded, { modelExpanded = false }) { + DropdownMenuItem( + text = { Text("全部模型", fontWeight = FontWeight.Bold) }, + onClick = { modelExpanded = false; viewModel.selectAllModels() }) + state.allModels.forEach { model -> + DropdownMenuItem(text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(state.selectedModels.isEmpty() || model in state.selectedModels, { viewModel.toggleModel(model) }) + Text(model, modifier = Modifier.padding(start = 4.dp)) + } + }, onClick = { viewModel.toggleModel(model) }) + } } } } } - } - // 图表1 - item { - val models = state.selectedModels.ifEmpty { state.allModels.toSet() } - val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } - ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { - StackedBarChart(state.buckets, - { it.totalCost.toDouble() / 100_000_000.0 }, - { bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } }, - { bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } }, - formatValue = { "$${String.format(Locale.US, "%.4f", it)}" }, - granularity = state.granularity, - legendItems = models.mapIndexed { idx, m -> - m to modelColors[idx % modelColors.size] - }, - useUtc8 = state.useUtc8 - ) + if (wideEnough) { + // 面板够宽:前两张图表并排 + item { + val models = state.selectedModels.ifEmpty { state.allModels.toSet() } + val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } + val reqTotal = state.buckets.sumOf { it.totalRequests } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box(modifier = Modifier.weight(1f)) { + ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + StackedBarChart(state.buckets, + { it.totalCost.toDouble() / 100_000_000.0 }, + { bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } }, + { bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } }, + formatValue = { "$${String.format(Locale.US, "%.4f", it)}" }, + granularity = state.granularity, + legendItems = models.mapIndexed { idx, m -> + m to modelColors[idx % modelColors.size] + }, + useUtc8 = state.useUtc8 + ) + } + } + Box(modifier = Modifier.weight(1f)) { + ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { + LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8) + } + } + } + } + item { + val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } + ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + StackedBarChart(state.buckets, + { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, + { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) }, + { listOf("输出", "输入(未命中)", "命中缓存") }, + formatValue = { formatTokenComma(it.toLong()) }, + granularity = state.granularity, + tooltipReversed = true, + useUtc8 = state.useUtc8 + ) + } + ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2])) + } + } else { + // 窄面板:三张图表纵向堆叠 + item { + val models = state.selectedModels.ifEmpty { state.allModels.toSet() } + val costTotal = state.buckets.sumOf { it.totalCost.toDouble() / 100_000_000.0 } + ChartCard("消耗金额 (USD)", "$${String.format(Locale.US, "%.4f", costTotal)}", { showCostDetail = true }) { + StackedBarChart(state.buckets, + { it.totalCost.toDouble() / 100_000_000.0 }, + { bucket -> models.mapIndexedNotNull { idx, m -> val v = bucket.byModel[m]?.cost ?: return@mapIndexedNotNull null; v.toDouble() / 100_000_000.0 to modelColors[idx % modelColors.size] } }, + { bucket -> models.mapNotNull { if (bucket.byModel[it] != null) it else null } }, + formatValue = { "$${String.format(Locale.US, "%.4f", it)}" }, + granularity = state.granularity, + legendItems = models.mapIndexed { idx, m -> + m to modelColors[idx % modelColors.size] + }, + useUtc8 = state.useUtc8 + ) + } + } + item { + val reqTotal = state.buckets.sumOf { it.totalRequests } + ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { + LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8) + } + } + item { + val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } + ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { + StackedBarChart(state.buckets, + { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, + { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) }, + { listOf("输出", "输入(未命中)", "命中缓存") }, + formatValue = { formatTokenComma(it.toLong()) }, + granularity = state.granularity, + tooltipReversed = true, + useUtc8 = state.useUtc8 + ) + } + ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2])) + } } - } - // 图表2 - item { - val reqTotal = state.buckets.sumOf { it.totalRequests } - ChartCard("API 请求次数", "${reqTotal}次", { showReqDetail = true }) { - LineChart(state.buckets, { it.totalRequests.toFloat() }, StrawberryPink, { "${it.toInt()}次" }, state.granularity, useUtc8 = state.useUtc8) + item { + TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) { + Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold) + } } + item { Spacer(Modifier.height(32.dp)) } } - // 图表3 - item { - val tokTotal = state.buckets.sumOf { it.cacheHitTokens + it.inputTokens + it.outputTokens } - ChartCard("Token 消耗", formatTokenComma(tokTotal), { showTokenDetail = true }) { - StackedBarChart(state.buckets, - { (it.cacheHitTokens + it.inputTokens + it.outputTokens).toDouble() }, - { bucket -> listOfNotNull(bucket.outputTokens.toDouble() to tokenColors[2], bucket.inputTokens.toDouble() to tokenColors[1], bucket.cacheHitTokens.toDouble() to tokenColors[0]) }, - { listOf("输出", "输入(未命中)", "命中缓存") }, - formatValue = { formatTokenComma(it.toLong()) }, - granularity = state.granularity, - tooltipReversed = true, - useUtc8 = state.useUtc8 - ) - } - ChartLegend(listOf("输入(未命中)" to tokenColors[1], "命中缓存" to tokenColors[0], "输出" to tokenColors[2])) - } - // 总统计入口 - item { - TextButton(onClick = onOpenOverview, modifier = Modifier.fillMaxWidth()) { - Text("📋 总统计", color = StrawberryPink, fontWeight = FontWeight.Bold) - } - } - item { Spacer(Modifier.height(32.dp)) } } } // 自定义日期选择器 @@ -253,7 +308,6 @@ fun UsageDetailScreen( val utc = ZoneOffset.UTC; val sd = Instant.ofEpochMilli(customRangeStartMs).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() val ed = Instant.ofEpochMilli(ms).atOffset(utc).toLocalDate().atStartOfDay(utc).toInstant().toEpochMilli() + 86400_000L - 1 viewModel.setCustomRange(sd, ed); showCustomRangeEnd = false }, { showCustomRangeEnd = false }) - // 详情弹窗 val models = state.selectedModels.ifEmpty { state.allModels.toSet() } if (showCostDetail) ChartDetailDialog("消费明细", { showCostDetail = false }) { models.forEach { model -> val t = state.buckets.sumOf { it.byModel[model]?.cost ?:0L }; if (t>0) DetailRow(model, "$${String.format(Locale.US, "%.4f", t/100_000_000.0)}") } @@ -266,7 +320,6 @@ fun UsageDetailScreen( DetailRow("输入(未命中)", formatTokenComma(state.buckets.sumOf { it.inputTokens })) DetailRow("输出", formatTokenComma(state.buckets.sumOf { it.outputTokens })) } - // 清除数据警告弹窗(仅 CCGO) if (showClearDialog && clearViewModel != null) { val cd = clearCountdown androidx.compose.material3.AlertDialog( @@ -301,7 +354,6 @@ fun UsageDetailScreen( } } ) - // 倒计时启动 LaunchedEffect(showClearDialog) { if (!showClearDialog) return@LaunchedEffect clearCountdown = 3 @@ -417,4 +469,4 @@ internal fun DateTimePickerDialog( TimePicker(state = timeState) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt index 95279d3..0753e6a 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageOverviewScreen.kt @@ -3,6 +3,8 @@ package com.rainy.token.ui.dashboard import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -53,7 +55,7 @@ import java.util.Locale /** * 总统计页 —— 总览、按模型、按天统计。 */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun UsageOverviewScreen( onBack: () -> Unit, @@ -125,7 +127,7 @@ fun UsageOverviewScreen( } // 总览 uiState.overview?.let { overview -> - item { OverviewCard(overview) } + item { OverviewCard(overview, false) } if (overview.cacheReadTokens > 0 || overview.cacheWriteTokens > 0) { item { CacheBreakdownCard(overview) } } @@ -179,20 +181,37 @@ fun UsageOverviewScreen( } } -@Composable private fun OverviewCard(overview: OverviewStats) { +@OptIn(ExperimentalLayoutApi::class) +@Composable private fun OverviewCard(overview: OverviewStats, isExpanded: Boolean = false) { val inputTotal = overview.inputTokens + overview.cacheReadTokens Card(Modifier.fillMaxWidth(), RoundedCornerShape(20.dp), CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), elevation = CardDefaults.cardElevation(0.dp)) { Column(Modifier.padding(16.dp)) { - Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("输入 Token", formatTokenCount(inputTotal)) - StatItem("输出 Token", formatTokenCount(overview.outputTokens)) - StatItem("推理 Token", formatTokenCount(overview.reasoningTokens)) - } - Spacer(Modifier.height(12.dp)) - Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { - StatItem("总计", formatTokenCount(overview.totalTokens)) - StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") - StatItem("记录数", "${overview.totalCount}") + if (isExpanded) { + // 平板:FlowRow 横向排列 6 项统计 + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatItem("输入 Token", formatTokenCount(inputTotal)) + StatItem("输出 Token", formatTokenCount(overview.outputTokens)) + StatItem("推理 Token", formatTokenCount(overview.reasoningTokens)) + StatItem("总计", formatTokenCount(overview.totalTokens)) + StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") + StatItem("记录数", "${overview.totalCount}") + } + } else { + Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { + StatItem("输入 Token", formatTokenCount(inputTotal)) + StatItem("输出 Token", formatTokenCount(overview.outputTokens)) + StatItem("推理 Token", formatTokenCount(overview.reasoningTokens)) + } + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), Arrangement.SpaceEvenly) { + StatItem("总计", formatTokenCount(overview.totalTokens)) + StatItem("总花费", "$${String.format(Locale.US, "%.4f", overview.totalCost / 100_000_000.0)}") + StatItem("记录数", "${overview.totalCount}") + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f9e65a..5607751 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-material3-window-size = { group = "androidx.compose.material3", name = "material3-window-size-class" } # Navigation androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }