- Trae/WorkBuddy/Sub2API 支持多账号:每账号一张首页卡片、可设默认账号 (小组件/每日自动签到仅作用于默认账号),新增账号管理页 - 每日自动签到按账号执行,签到结果弹窗汇报(含积分变动) - 移除独立的 UsageStat 页,用量并入服务详情 - Sub2API 面板 token 过期自动用邮箱+密码重登并回写该账号, 自动重登失败才引导手动网页登录;并发 401 按账号互斥去重 - Sub2API 面板管理(Key/分组/兑换码)按当前账号隔离 - 首页恢复每账号一张普通卡片(回退叠放卡组样式)并保留历史排序兼容 - WebView 登录自动识别成功、去掉手动确认弹窗;登录后立即刷新余额 - 修复积分占位符泄漏(%1$s /%2$s 积分)
857 lines
38 KiB
Kotlin
857 lines
38 KiB
Kotlin
package com.rainy.token.ui
|
||
|
||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||
import androidx.compose.animation.ExitTransition
|
||
import androidx.compose.animation.core.tween
|
||
import androidx.compose.animation.fadeIn
|
||
import androidx.compose.animation.fadeOut
|
||
import androidx.compose.foundation.background
|
||
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.key
|
||
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.platform.LocalContext
|
||
import androidx.compose.ui.res.stringResource
|
||
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.currentBackStackEntryAsState
|
||
import androidx.navigation.compose.rememberNavController
|
||
import androidx.navigation.navArgument
|
||
import com.rainy.token.R
|
||
import com.rainy.token.data.repository.CommandCodeUsageRepository
|
||
import com.rainy.token.domain.service.ServiceConfigProvider
|
||
import com.rainy.token.domain.service.ServiceType
|
||
import com.rainy.token.ui.components.rememberWindowSizeClass
|
||
import com.rainy.token.ui.components.DebugLogScreen
|
||
import com.rainy.token.ui.components.TipsScreen
|
||
import com.rainy.token.ui.dashboard.DashboardScreen
|
||
import com.rainy.token.ui.dashboard.DashboardViewModel
|
||
import com.rainy.token.ui.dashboard.UsageChartViewModel
|
||
import com.rainy.token.ui.dashboard.UsageDataScreen
|
||
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.account.ApiManagementScreen
|
||
import com.rainy.token.ui.servicedetail.ServiceDetailScreen
|
||
import com.rainy.token.ui.settings.AccountManagementScreen
|
||
import com.rainy.token.ui.settings.CredentialEditScreen
|
||
import com.rainy.token.ui.settings.CredentialEditViewModel
|
||
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.CodexOAuthScreen
|
||
import com.rainy.token.ui.webview.WebViewLoginScreen
|
||
import com.rainy.token.ui.webview.WebViewPageScreen
|
||
import android.net.Uri
|
||
|
||
/**
|
||
* 应用导航图。
|
||
*
|
||
* 自适应策略:
|
||
* - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(200ms)防连点栈损坏,
|
||
* enter/exit/popEnter/popExit 显式使用同一套滑动过渡,避免默认淡入淡出与返回动画混用。
|
||
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换,
|
||
* 用量子路由用局部 NavHost。
|
||
*/
|
||
object Routes {
|
||
const val DASHBOARD = "dashboard"
|
||
const val SETTINGS = "settings"
|
||
const val API_MANAGEMENT = "api_management"
|
||
const val TIPS = "tips"
|
||
const val DEBUG_LOG = "debug_log"
|
||
const val CODEX_OAUTH = "codex_oauth"
|
||
const val CREDENTIAL_EDIT = "credential_edit/{type}?account={account}"
|
||
fun credentialEdit(type: ServiceType, accountId: String? = null): String =
|
||
if (accountId.isNullOrBlank()) {
|
||
"credential_edit/${type.name}"
|
||
} else {
|
||
"credential_edit/${type.name}?account=${Uri.encode(accountId)}"
|
||
}
|
||
const val ACCOUNTS = "accounts/{type}"
|
||
fun accounts(type: ServiceType) = "accounts/${type.name}"
|
||
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
|
||
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
|
||
if (accountId.isNullOrBlank()) {
|
||
"webview_login/${type.name}"
|
||
} else {
|
||
"webview_login/${type.name}?account=${Uri.encode(accountId)}"
|
||
}
|
||
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
|
||
fun webviewPage(url: String, title: String) =
|
||
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
|
||
const val SERVICE_DETAIL = "service/{type}?account={account}"
|
||
fun serviceDetail(type: ServiceType, accountId: String? = null): String =
|
||
if (accountId.isNullOrBlank()) {
|
||
"service/${type.name}"
|
||
} else {
|
||
"service/${type.name}?account=${Uri.encode(accountId)}"
|
||
}
|
||
const val USAGE_DETAIL = "usage_detail"
|
||
const val USAGE_OVERVIEW = "usage_overview"
|
||
const val USAGE_DATA = "usage_data"
|
||
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 =
|
||
typeName?.let {
|
||
ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull()
|
||
} ?: ServiceType.DEEPSEEK
|
||
|
||
/** 凭据入口:多账号服务先进账号管理页,其余直达凭据编辑页。 */
|
||
private fun credentialEntry(type: ServiceType): String =
|
||
if (ServiceType.isMultiAccount(type)) Routes.accounts(type) else Routes.credentialEdit(type)
|
||
|
||
private sealed class DetailPane {
|
||
object Empty : DetailPane()
|
||
data class ServiceDetail(val type: ServiceType, val accountId: String? = null) : DetailPane()
|
||
object OCGOUsage : DetailPane()
|
||
object CCGOUsage : DetailPane()
|
||
object Settings : DetailPane()
|
||
object ApiManagement : DetailPane()
|
||
object Heatmap : DetailPane()
|
||
/** 通用外部页面(充值/订阅页),App 内 WebView 打开。 */
|
||
data class WebPage(val url: String, val title: String) : DetailPane()
|
||
}
|
||
|
||
@Composable
|
||
fun RainyTokenNavHost() {
|
||
val windowSize = rememberWindowSizeClass()
|
||
val isExpanded = windowSize.widthSizeClass ==
|
||
androidx.compose.material3.windowsizeclass.WindowWidthSizeClass.Expanded
|
||
|
||
if (isExpanded) {
|
||
ExpandedLayout()
|
||
} else {
|
||
CompactNavHost()
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// Compact:单栈 NavHost —— guardedPop 围栏 + popExit/popEnter=None
|
||
// ═══════════════════════════════════════════════
|
||
|
||
/**
|
||
* 非 Compose State 的时间戳围栏。
|
||
*
|
||
* 为什么不用 mutableStateOf:
|
||
* mutableStateOf 写入会触发 CompactNavHost → NavHost 重组。若重组发生在
|
||
* AnimatedContent 过渡动画期间,会干扰内部 MutableTransitionState 状态机,
|
||
* 导致旧 composable 已移除、新 composable 进入动画被中断后未正确启动 → 空白页。
|
||
*
|
||
* 连点保护:cooldownMs 内只放行一次 pop。popBackStack() 返回 false 时 reset。
|
||
*/
|
||
private class PopGuard(
|
||
private val cooldownMs: Long = 200
|
||
) {
|
||
private var lastPopTime: Long = 0L
|
||
|
||
fun tryAcquire(): Boolean {
|
||
val now = System.currentTimeMillis()
|
||
if (now - lastPopTime < cooldownMs) return false
|
||
lastPopTime = now
|
||
return true
|
||
}
|
||
|
||
fun reset() {
|
||
lastPopTime = 0L
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun CompactNavHost() {
|
||
val navController = rememberNavController()
|
||
// 围栏:200ms 内只允许一次 pop;普通对象持有时间戳,不触发 NavHost 重组
|
||
val popGuard = remember { PopGuard() }
|
||
val guardedPop: () -> Unit = {
|
||
// 起始页无 previousBackStackEntry,直接跳过,避免无意义的 popBackStack() 调用
|
||
if (navController.previousBackStackEntry != null && popGuard.tryAcquire()) {
|
||
// popBackStack() 返回 false = 已在起始页,reset 围栏不浪费冷却
|
||
if (!navController.popBackStack()) {
|
||
popGuard.reset()
|
||
}
|
||
}
|
||
}
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = Routes.DASHBOARD,
|
||
enterTransition = {
|
||
slideIntoContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||
animationSpec = tween(220)
|
||
) + fadeIn(animationSpec = tween(90))
|
||
},
|
||
exitTransition = {
|
||
slideOutOfContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Left,
|
||
animationSpec = tween(220)
|
||
) + fadeOut(animationSpec = tween(90))
|
||
},
|
||
// Navigation Compose 会把 pop 过渡绑定到 Android 13+ predictive back 手势进度,
|
||
// 因此这里使用同一套滑动动画,避免默认淡入淡出与返回动画混用。
|
||
popExitTransition = {
|
||
slideOutOfContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||
animationSpec = tween(220)
|
||
) + fadeOut(animationSpec = tween(90))
|
||
},
|
||
popEnterTransition = {
|
||
slideIntoContainer(
|
||
AnimatedContentTransitionScope.SlideDirection.Right,
|
||
animationSpec = tween(220)
|
||
) + fadeIn(animationSpec = tween(90))
|
||
}
|
||
) {
|
||
composable(Routes.DASHBOARD) {
|
||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||
DashboardScreen(
|
||
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
|
||
onOpenApiManagement = { navController.navigate(Routes.API_MANAGEMENT) },
|
||
onOpenService = { type, accountId ->
|
||
navController.navigate(Routes.serviceDetail(type, accountId))
|
||
},
|
||
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
|
||
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
|
||
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
|
||
onOpenRecharge = { type ->
|
||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
composable(Routes.API_MANAGEMENT) {
|
||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||
ApiManagementScreen(
|
||
onBack = guardedPop,
|
||
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
|
||
onRecharge = { type ->
|
||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
composable(Routes.USAGE_DETAIL) {
|
||
UsageDetailScreen(
|
||
onBack = guardedPop,
|
||
onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) },
|
||
onOpenData = { navController.navigate(Routes.USAGE_DATA) }
|
||
)
|
||
}
|
||
composable(Routes.USAGE_OVERVIEW) {
|
||
UsageOverviewScreen(
|
||
onBack = guardedPop
|
||
)
|
||
}
|
||
composable(Routes.USAGE_DATA) {
|
||
UsageDataScreen(
|
||
onBack = guardedPop
|
||
)
|
||
}
|
||
composable(Routes.CCGO_USAGE_DETAIL) {
|
||
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)
|
||
}
|
||
UsageDetailScreen(
|
||
onBack = guardedPop,
|
||
onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) },
|
||
onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) },
|
||
viewModel = chartVm,
|
||
clearViewModel = usageVm
|
||
)
|
||
}
|
||
composable(Routes.CCGO_USAGE_OVERVIEW) {
|
||
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||
UsageOverviewScreen(
|
||
onBack = guardedPop,
|
||
viewModel = ovVm,
|
||
autoLoad = false
|
||
)
|
||
}
|
||
composable(Routes.CCGO_USAGE_DATA) {
|
||
val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}"
|
||
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { dataVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) }
|
||
UsageDataScreen(
|
||
onBack = guardedPop,
|
||
viewModel = dataVm,
|
||
autoLoad = false
|
||
)
|
||
}
|
||
composable(Routes.SETTINGS) {
|
||
SettingsScreen(
|
||
onBack = guardedPop,
|
||
onEditCredential = { type -> navController.navigate(credentialEntry(type)) },
|
||
onManageAccounts = { type -> navController.navigate(Routes.accounts(type)) },
|
||
onOpenTips = { navController.navigate(Routes.TIPS) },
|
||
onOpenDebugLog = { navController.navigate(Routes.DEBUG_LOG) }
|
||
)
|
||
}
|
||
composable(Routes.ACCOUNTS) { backStackEntry ->
|
||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||
AccountManagementScreen(
|
||
service = type,
|
||
onBack = guardedPop,
|
||
onEditAccount = { svc, accountId ->
|
||
navController.navigate(Routes.credentialEdit(svc, accountId))
|
||
}
|
||
)
|
||
}
|
||
composable(Routes.TIPS) {
|
||
TipsScreen(onBack = guardedPop)
|
||
}
|
||
composable(Routes.DEBUG_LOG) {
|
||
DebugLogScreen(onBack = guardedPop)
|
||
}
|
||
composable(Routes.CODEX_OAUTH) {
|
||
CodexOAuthScreen(
|
||
onBack = guardedPop,
|
||
onSuccess = guardedPop
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.CREDENTIAL_EDIT,
|
||
arguments = listOf(
|
||
navArgument("type") { type = NavType.StringType },
|
||
navArgument("account") {
|
||
type = NavType.StringType
|
||
nullable = true
|
||
defaultValue = null
|
||
}
|
||
)
|
||
) { backStackEntry ->
|
||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||
val accountId = backStackEntry.arguments?.getString("account")
|
||
CredentialEditScreen(
|
||
service = type,
|
||
accountId = accountId,
|
||
onBack = guardedPop,
|
||
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
||
onWebViewLoginSuccess = { },
|
||
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_LOGIN,
|
||
arguments = listOf(
|
||
navArgument("type") { type = NavType.StringType },
|
||
navArgument("account") {
|
||
type = NavType.StringType
|
||
nullable = true
|
||
defaultValue = null
|
||
}
|
||
)
|
||
) { backStackEntry ->
|
||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||
val accountId = backStackEntry.arguments?.getString("account")
|
||
WebViewLoginScreen(
|
||
service = type,
|
||
accountId = accountId,
|
||
onBack = guardedPop,
|
||
onLoginSucceeded = { _, effectiveId ->
|
||
// 把实际写入的账号 ID 回传给上一层(凭据编辑页),新建账号模式用于回填
|
||
if (!effectiveId.isNullOrBlank()) {
|
||
navController.previousBackStackEntry?.savedStateHandle?.set(
|
||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
||
)
|
||
}
|
||
// 登录成功后返回上一层(详情页或凭据编辑页),由 ON_RESUME 刷新内容
|
||
if (!navController.popBackStack(Routes.SERVICE_DETAIL, inclusive = false)) {
|
||
guardedPop()
|
||
}
|
||
}
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.SERVICE_DETAIL,
|
||
arguments = listOf(
|
||
navArgument("type") { type = NavType.StringType },
|
||
navArgument("account") {
|
||
type = NavType.StringType
|
||
nullable = true
|
||
defaultValue = null
|
||
}
|
||
)
|
||
) { backStackEntry ->
|
||
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
|
||
val accountId = backStackEntry.arguments?.getString("account")
|
||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||
ServiceDetailScreen(
|
||
service = type,
|
||
accountId = accountId,
|
||
onBack = guardedPop,
|
||
onSwitchAccount = { svc, acc ->
|
||
// 切换账号时替换当前详情页(避免同路由栈堆积)
|
||
navController.navigate(Routes.serviceDetail(svc, acc)) {
|
||
popUpTo(Routes.SERVICE_DETAIL) { inclusive = true }
|
||
}
|
||
},
|
||
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
||
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
||
onOpenRecharge = { svc ->
|
||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_PAGE,
|
||
arguments = listOf(
|
||
navArgument("url") { type = NavType.StringType },
|
||
navArgument("title") { type = NavType.StringType }
|
||
)
|
||
) { backStackEntry ->
|
||
val url = backStackEntry.arguments?.getString("url").orEmpty()
|
||
val title = backStackEntry.arguments?.getString("title").orEmpty()
|
||
WebViewPageScreen(url = url, title = title, onBack = guardedPop)
|
||
}
|
||
composable(Routes.HEATMAP) {
|
||
HeatmapScreen(onBack = guardedPop)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════
|
||
// Expanded:手动双窗格
|
||
// ═══════════════════════════════════════════════
|
||
|
||
@Composable
|
||
private fun ExpandedLayout() {
|
||
var detailPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
|
||
val dashboardVm: DashboardViewModel = hiltViewModel()
|
||
|
||
// 宽屏布局下 DashboardScreen 永久挂载,不会收到 ON_RESUME。
|
||
// 当详情面板关闭时手动触发 Dashboard 刷新凭据状态。
|
||
var prevPane by remember { mutableStateOf<DetailPane>(DetailPane.Empty) }
|
||
LaunchedEffect(detailPane) {
|
||
if (prevPane != DetailPane.Empty && detailPane == DetailPane.Empty) {
|
||
dashboardVm.reloadLocalState()
|
||
}
|
||
prevPane = detailPane
|
||
}
|
||
|
||
val rechargeLabel = stringResource(R.string.action_recharge)
|
||
|
||
Row(modifier = Modifier.fillMaxSize()) {
|
||
Box(
|
||
modifier = Modifier
|
||
.weight(0.35f)
|
||
.fillMaxHeight()
|
||
) {
|
||
DashboardScreen(
|
||
onOpenSettings = { detailPane = DetailPane.Settings },
|
||
onOpenApiManagement = { detailPane = DetailPane.ApiManagement },
|
||
onOpenService = { type, accountId ->
|
||
detailPane = DetailPane.ServiceDetail(type, accountId)
|
||
},
|
||
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
|
||
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
|
||
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
|
||
onOpenRecharge = { type ->
|
||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
|
||
}
|
||
},
|
||
viewModel = dashboardVm
|
||
)
|
||
}
|
||
|
||
VerticalDivider(
|
||
thickness = 1.dp,
|
||
color = MaterialTheme.colorScheme.outlineVariant
|
||
)
|
||
|
||
Box(
|
||
modifier = Modifier
|
||
.weight(0.65f)
|
||
.fillMaxHeight()
|
||
.background(MaterialTheme.colorScheme.background)
|
||
) {
|
||
ExpandedDetailPane(
|
||
pane = detailPane,
|
||
onClose = { detailPane = DetailPane.Empty },
|
||
onSwitchAccount = { svc, acc ->
|
||
detailPane = DetailPane.ServiceDetail(svc, acc)
|
||
},
|
||
onCredentialEditClosed = { dashboardVm.reloadLocalState() }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
private fun ExpandedDetailPane(
|
||
pane: DetailPane,
|
||
onClose: () -> Unit,
|
||
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
||
onCredentialEditClosed: () -> Unit = {}
|
||
) {
|
||
when (pane) {
|
||
is DetailPane.Empty -> {
|
||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||
Text(stringResource(R.string.nav_empty_title), style = MaterialTheme.typography.titleMedium, color = inkMuted())
|
||
Text(stringResource(R.string.nav_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = inkMuted())
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.ServiceDetail -> {
|
||
// key(pane.type + accountId):切换服务/账号时重建嵌套导航栈,避免停留在上一个页面
|
||
key("${pane.type.name}:${pane.accountId.orEmpty()}") {
|
||
val navController = rememberNavController()
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = "detail",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("detail") {
|
||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||
ServiceDetailScreen(
|
||
service = pane.type,
|
||
accountId = pane.accountId,
|
||
onBack = onClose,
|
||
onSwitchAccount = onSwitchAccount,
|
||
onConfigureCredential = { onClose() },
|
||
onStartWebViewLogin = { _, _ -> },
|
||
onOpenRecharge = { svc ->
|
||
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_PAGE,
|
||
arguments = listOf(
|
||
navArgument("url") { type = NavType.StringType },
|
||
navArgument("title") { type = NavType.StringType }
|
||
)
|
||
) { entry ->
|
||
WebViewPageScreen(
|
||
url = entry.arguments?.getString("url").orEmpty(),
|
||
title = entry.arguments?.getString("title").orEmpty(),
|
||
onBack = { navController.popBackStack() }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.OCGOUsage -> {
|
||
val navController = rememberNavController()
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = "chart",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("chart") {
|
||
UsageDetailScreen(
|
||
onBack = onClose,
|
||
onOpenOverview = { navController.navigate("overview") },
|
||
onOpenData = { navController.navigate("data") }
|
||
)
|
||
}
|
||
composable("overview") {
|
||
UsageOverviewScreen(onBack = { navController.popBackStack() }, autoLoad = true)
|
||
}
|
||
composable("data") {
|
||
UsageDataScreen(onBack = { navController.popBackStack() }, autoLoad = true)
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.CCGOUsage -> {
|
||
val navController = rememberNavController()
|
||
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)
|
||
}
|
||
NavHost(
|
||
navController = navController,
|
||
startDestination = "chart",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("chart") {
|
||
UsageDetailScreen(
|
||
onBack = onClose,
|
||
onOpenOverview = { navController.navigate("overview") },
|
||
onOpenData = { navController.navigate("data") },
|
||
viewModel = chartVm,
|
||
clearViewModel = usageVm
|
||
)
|
||
}
|
||
composable("overview") {
|
||
val key = "ccgo_$wid"
|
||
val ovVm: UsageViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { ovVm.setWorkspace(wid) }
|
||
UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false)
|
||
}
|
||
composable("data") {
|
||
val key = "ccgo_$wid"
|
||
val dataVm: UsageDataViewModel = hiltViewModel(key = key)
|
||
LaunchedEffect(Unit) { dataVm.setWorkspace(wid) }
|
||
UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false)
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.Heatmap -> {
|
||
HeatmapScreen(onBack = onClose)
|
||
}
|
||
is DetailPane.WebPage -> {
|
||
WebViewPageScreen(
|
||
url = pane.url,
|
||
title = pane.title,
|
||
onBack = onClose
|
||
)
|
||
}
|
||
is DetailPane.ApiManagement -> {
|
||
val apiNavController = rememberNavController()
|
||
// 从凭据编辑/WebView 返回到 api_mgmt_main 时刷新 Dashboard 凭据状态
|
||
val routeStack by apiNavController.currentBackStackEntryAsState()
|
||
val currentRoute = routeStack?.destination?.route
|
||
var prevRoute by remember { mutableStateOf("api_mgmt_main") }
|
||
LaunchedEffect(currentRoute) {
|
||
if (prevRoute != currentRoute && currentRoute == "api_mgmt_main") {
|
||
onCredentialEditClosed()
|
||
}
|
||
prevRoute = currentRoute ?: "api_mgmt_main"
|
||
}
|
||
NavHost(
|
||
navController = apiNavController,
|
||
startDestination = "api_mgmt_main",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("api_mgmt_main") {
|
||
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
|
||
ApiManagementScreen(
|
||
onBack = onClose,
|
||
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
|
||
onRecharge = { type ->
|
||
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||
}
|
||
}
|
||
)
|
||
}
|
||
composable(Routes.ACCOUNTS) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
AccountManagementScreen(
|
||
service = type,
|
||
onBack = { apiNavController.popBackStack() },
|
||
onEditAccount = { svc, accountId ->
|
||
apiNavController.navigate(Routes.credentialEdit(svc, accountId))
|
||
}
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.CREDENTIAL_EDIT,
|
||
arguments = listOf(
|
||
navArgument("type") { type = NavType.StringType },
|
||
navArgument("account") {
|
||
type = NavType.StringType
|
||
nullable = true
|
||
defaultValue = null
|
||
}
|
||
)
|
||
) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
val accountId = entry.arguments?.getString("account")
|
||
CredentialEditScreen(
|
||
service = type,
|
||
accountId = accountId,
|
||
onBack = { apiNavController.popBackStack() },
|
||
onStartWebViewLogin = { svc, acc ->
|
||
apiNavController.navigate(Routes.webviewLogin(svc, acc))
|
||
},
|
||
onWebViewLoginSuccess = { },
|
||
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_LOGIN,
|
||
arguments = listOf(
|
||
navArgument("type") { type = NavType.StringType },
|
||
navArgument("account") {
|
||
type = NavType.StringType
|
||
nullable = true
|
||
defaultValue = null
|
||
}
|
||
)
|
||
) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
val accountId = entry.arguments?.getString("account")
|
||
WebViewLoginScreen(
|
||
service = type,
|
||
accountId = accountId,
|
||
onBack = { apiNavController.popBackStack() },
|
||
onLoginSucceeded = { _, effectiveId ->
|
||
if (!effectiveId.isNullOrBlank()) {
|
||
apiNavController.previousBackStackEntry?.savedStateHandle?.set(
|
||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
||
)
|
||
}
|
||
apiNavController.popBackStack()
|
||
}
|
||
)
|
||
}
|
||
composable(Routes.CODEX_OAUTH) {
|
||
CodexOAuthScreen(
|
||
onBack = { apiNavController.popBackStack() },
|
||
onSuccess = { apiNavController.popBackStack() }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_PAGE,
|
||
arguments = listOf(
|
||
navArgument("url") { type = NavType.StringType },
|
||
navArgument("title") { type = NavType.StringType }
|
||
)
|
||
) { entry ->
|
||
WebViewPageScreen(
|
||
url = entry.arguments?.getString("url").orEmpty(),
|
||
title = entry.arguments?.getString("title").orEmpty(),
|
||
onBack = { apiNavController.popBackStack() }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
is DetailPane.Settings -> {
|
||
val settingsNavController = rememberNavController()
|
||
// 监听嵌套 NavHost 路由变化:当从凭据编辑页/WebView 返回 settings_main 时
|
||
// 通知 Dashboard 刷新凭据状态(宽屏布局 Dashboard 永久挂载不产生 ON_RESUME)
|
||
val routeStack by settingsNavController.currentBackStackEntryAsState()
|
||
val currentRoute = routeStack?.destination?.route
|
||
var prevRoute by remember { mutableStateOf("settings_main") }
|
||
LaunchedEffect(currentRoute) {
|
||
if (prevRoute != currentRoute && currentRoute == "settings_main") {
|
||
onCredentialEditClosed()
|
||
}
|
||
prevRoute = currentRoute ?: "settings_main"
|
||
}
|
||
NavHost(
|
||
navController = settingsNavController,
|
||
startDestination = "settings_main",
|
||
popExitTransition = { ExitTransition.None }
|
||
) {
|
||
composable("settings_main") {
|
||
SettingsScreen(
|
||
onBack = onClose,
|
||
onEditCredential = { type ->
|
||
settingsNavController.navigate(credentialEntry(type))
|
||
},
|
||
onManageAccounts = { type ->
|
||
settingsNavController.navigate(Routes.accounts(type))
|
||
},
|
||
onOpenTips = { settingsNavController.navigate("tips") },
|
||
onOpenDebugLog = { settingsNavController.navigate("debug_log") }
|
||
)
|
||
}
|
||
composable(Routes.ACCOUNTS) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
AccountManagementScreen(
|
||
service = type,
|
||
onBack = { settingsNavController.popBackStack() },
|
||
onEditAccount = { svc, accountId ->
|
||
settingsNavController.navigate(Routes.credentialEdit(svc, accountId))
|
||
}
|
||
)
|
||
}
|
||
composable("tips") {
|
||
TipsScreen(onBack = { settingsNavController.popBackStack() })
|
||
}
|
||
composable("debug_log") {
|
||
DebugLogScreen(onBack = { settingsNavController.popBackStack() })
|
||
}
|
||
composable(Routes.CODEX_OAUTH) {
|
||
CodexOAuthScreen(
|
||
onBack = { settingsNavController.popBackStack() },
|
||
onSuccess = { settingsNavController.popBackStack() }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.CREDENTIAL_EDIT,
|
||
arguments = listOf(
|
||
navArgument("type") { type = NavType.StringType },
|
||
navArgument("account") {
|
||
type = NavType.StringType
|
||
nullable = true
|
||
defaultValue = null
|
||
}
|
||
)
|
||
) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
val accountId = entry.arguments?.getString("account")
|
||
CredentialEditScreen(
|
||
service = type,
|
||
accountId = accountId,
|
||
onBack = { settingsNavController.popBackStack() },
|
||
onStartWebViewLogin = { svc, acc ->
|
||
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
|
||
},
|
||
onWebViewLoginSuccess = { },
|
||
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
||
)
|
||
}
|
||
composable(
|
||
route = Routes.WEBVIEW_LOGIN,
|
||
arguments = listOf(
|
||
navArgument("type") { type = NavType.StringType },
|
||
navArgument("account") {
|
||
type = NavType.StringType
|
||
nullable = true
|
||
defaultValue = null
|
||
}
|
||
)
|
||
) { entry ->
|
||
val type = parseServiceType(entry.arguments?.getString("type"))
|
||
val accountId = entry.arguments?.getString("account")
|
||
WebViewLoginScreen(
|
||
service = type,
|
||
accountId = accountId,
|
||
onBack = { settingsNavController.popBackStack() },
|
||
onLoginSucceeded = { _, effectiveId ->
|
||
if (!effectiveId.isNullOrBlank()) {
|
||
settingsNavController.previousBackStackEntry?.savedStateHandle?.set(
|
||
CredentialEditViewModel.KEY_LOGIN_ACCOUNT_ID, effectiveId
|
||
)
|
||
}
|
||
settingsNavController.popBackStack()
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} |