From 2ab5f2f3429cbc41f4f641ceda724a060aa2e82f Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:20:52 +0000 Subject: [PATCH] =?UTF-8?q?docs+nav:=20sync=20AGENTS.md=20=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E8=BF=87=E6=97=B6=E5=86=85=E5=AE=B9=20+=20guardedPop?= =?UTF-8?q?=20=E5=9B=B4=E6=A0=8F=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: AnimatedContent → when(pane) 原子切换 - AGENTS.md: 新增 Compact guardedPop 机制说明 + 已知连点空页面 Bug - RainyTokenNavHost.kt: Compact 用 guardedPop(150ms) + popExitTransition=None - Expanded 局部 NavHost 同步加 popExitTransition=None(预防性) --- AGENTS.md | 13 +- .../com/rainy/token/ui/RainyTokenNavHost.kt | 170 +++++++++--------- 2 files changed, 94 insertions(+), 89 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be5ecb0..341083d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,12 +68,21 @@ Compact(手机): → CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计) ↘ CCGO_USAGE_DATA(原始数据) + 返回用 guardedPop()(150ms 时间戳围栏)+ popExitTransition=None。 + 围栏防同一帧/连续帧的第二次 pop,popExitTransition 消旧 composable 残留窗口。 + 其余 transition 保留默认动画(enter/exit/popEnter)。 + +⚠️ 已知未修复 Bug:快速连点左上角返回 → 仍然会返回到空页面。 + 根因尚未定位(已排除:动画残留、回调竞态、目标路由 no-op)。 + 待查方向:UsageDetailScreen 内部返回链路 + NavController backQueue 状态。 + Expanded(平板,≥840dp): - ┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: AnimatedContent ─────────┐ + ┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐ │ │ │ ServiceDetail / OCGOUsage / CCGOUsage │ │ │ │ Settings(内嵌 NavHost) │ └────────────────────────────────┘ └────────────────────────────────────────┘ - 右侧用量详情支持内部子路由:图表 → 总览 / 原始数据(通过 UsageSubRoute 状态) + 右侧用量详情内部子路由:图表 → 总览 / 原始数据(OCGO/CCGO/Settings 各自用局部 NavHost) + 面板切换用 when(pane) 分支(同一帧原子重组,零穿透),子路由由局部 NavHost 的 popBackStack() 内置防护。 ``` **桌面小组件(Widget)**: 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 279d79f..39cbf32 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -1,9 +1,7 @@ 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.animation.ExitTransition +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -51,10 +49,10 @@ import com.rainy.token.ui.webview.WebViewLoginScreen * 应用导航图。 * * 自适应策略: - * - **Compact**(< 600dp,手机):单栈 NavHost,与原来完全一致。 - * - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard 面板 + 右侧详情双窗格。 - * - 右侧详情通过 AnimatedContent 切换,支持用量详情内的子路由(图表/总览/数据)和设置子路由。 - * - 设置 → 凭据编辑 → WebView 登录 在右侧内嵌 NavHost 中处理。 + * - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(150ms)防连点栈损坏, + * popExitTransition=None 消除旧 composable 残留,其余 transition 保留默认动画。 + * - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换, + * 用量子路由用局部 NavHost。 */ object Routes { const val DASHBOARD = "dashboard" @@ -78,10 +76,6 @@ private fun parseServiceType(typeName: String?): ServiceType = ServiceType.fromStorageKey(it) ?: runCatching { ServiceType.valueOf(it) }.getOrNull() } ?: ServiceType.DEEPSEEK -// ═══════════════════════════════════════════════ -// 双窗格路由状态 -// ═══════════════════════════════════════════════ - private sealed class DetailPane { object Empty : DetailPane() data class ServiceDetail(val type: ServiceType) : DetailPane() @@ -90,10 +84,6 @@ private sealed class DetailPane { object Settings : DetailPane() } -// --------------------------------------------------------------------------- -// 入口:根据 WindowSizeClass 分派 -// --------------------------------------------------------------------------- - @Composable fun RainyTokenNavHost() { val windowSize = rememberWindowSizeClass() @@ -107,14 +97,27 @@ fun RainyTokenNavHost() { } } -// --------------------------------------------------------------------------- -// Compact:单栈 NavHost(原逻辑,一字未改) -// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════ +// Compact:单栈 NavHost —— guardedPop 围栏 + popExitTransition=None +// ═══════════════════════════════════════════════ @Composable private fun CompactNavHost() { val navController = rememberNavController() - NavHost(navController = navController, startDestination = Routes.DASHBOARD) { + // 时间戳围栏:150ms 内只允许一次 pop,防止连点时旧 composable 残留接收触摸 + var lastPopTime by remember { mutableStateOf(0L) } + val guardedPop: () -> Unit = { + val now = System.currentTimeMillis() + if (now - lastPopTime >= 150) { + lastPopTime = now + navController.popBackStack() + } + } + NavHost( + navController = navController, + startDestination = Routes.DASHBOARD, + popExitTransition = { ExitTransition.None } + ) { composable(Routes.DASHBOARD) { DashboardScreen( onOpenSettings = { navController.navigate(Routes.SETTINGS) }, @@ -125,16 +128,20 @@ private fun CompactNavHost() { } composable(Routes.USAGE_DETAIL) { UsageDetailScreen( - onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) }, + onBack = guardedPop, onOpenOverview = { navController.navigate(Routes.USAGE_OVERVIEW) }, onOpenData = { navController.navigate(Routes.USAGE_DATA) } ) } composable(Routes.USAGE_OVERVIEW) { - UsageOverviewScreen(onBack = { navController.popBackStack() }) + UsageOverviewScreen( + onBack = guardedPop + ) } composable(Routes.USAGE_DATA) { - UsageDataScreen(onBack = { navController.popBackStack() }) + UsageDataScreen( + onBack = guardedPop + ) } composable(Routes.CCGO_USAGE_DETAIL) { val wid = CommandCodeUsageRepository.CCGO_WORKSPACE_ID @@ -145,7 +152,7 @@ private fun CompactNavHost() { chartVm.setWorkspace(wid) } UsageDetailScreen( - onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) }, + onBack = guardedPop, onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) }, onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) }, viewModel = chartVm, @@ -156,17 +163,25 @@ private fun CompactNavHost() { val key = "ccgo_${CommandCodeUsageRepository.CCGO_WORKSPACE_ID}" val ovVm: UsageViewModel = hiltViewModel(key = key) LaunchedEffect(Unit) { ovVm.setWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) } - UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false) + 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 = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false) + UsageDataScreen( + onBack = guardedPop, + viewModel = dataVm, + autoLoad = false + ) } composable(Routes.SETTINGS) { SettingsScreen( - onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) }, + onBack = guardedPop, onEditCredential = { type -> navController.navigate(Routes.credentialEdit(type)) } ) } @@ -177,7 +192,7 @@ private fun CompactNavHost() { val type = parseServiceType(backStackEntry.arguments?.getString("type")) CredentialEditScreen( service = type, - onBack = { navController.popBackStack() }, + onBack = guardedPop, onStartWebViewLogin = { service -> navController.navigate(Routes.webviewLogin(service)) }, onWebViewLoginSuccess = { } ) @@ -189,8 +204,8 @@ private fun CompactNavHost() { val type = parseServiceType(backStackEntry.arguments?.getString("type")) WebViewLoginScreen( service = type, - onBack = { navController.popBackStack() }, - onLoginSucceeded = { navController.popBackStack() } + onBack = guardedPop, + onLoginSucceeded = { guardedPop() } ) } composable( @@ -200,7 +215,7 @@ private fun CompactNavHost() { val type = parseServiceType(backStackEntry.arguments?.getString("type")) ServiceDetailScreen( service = type, - onBack = { navController.popBackStack(Routes.DASHBOARD, inclusive = false) }, + onBack = guardedPop, onConfigureCredential = { svc -> navController.navigate(Routes.credentialEdit(svc)) }, onStartWebViewLogin = { svc -> navController.navigate(Routes.webviewLogin(svc)) } ) @@ -208,16 +223,15 @@ private fun CompactNavHost() { } } -// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════ // Expanded:手动双窗格 -// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════ @Composable private fun ExpandedLayout() { var detailPane by remember { mutableStateOf(DetailPane.Empty) } Row(modifier = Modifier.fillMaxSize()) { - // ── 左侧 Dashboard 面板(35%) ── Box( modifier = Modifier .weight(0.35f) @@ -231,48 +245,25 @@ private fun ExpandedLayout() { ) } - // 分隔线 VerticalDivider( thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant ) - // ── 右侧详情面板(65%) ── Box( modifier = Modifier .weight(0.65f) .fillMaxHeight() + .background(MaterialTheme.colorScheme.background) ) { - AnimatedContent( - targetState = detailPane, - transitionSpec = { fadeIn() togetherWith fadeOut() } - ) { pane -> - ExpandedDetailPane( - pane = pane, - onClose = { detailPane = DetailPane.Empty } - ) - } + ExpandedDetailPane( + pane = detailPane, + 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, @@ -291,32 +282,34 @@ private fun ExpandedDetailPane( ServiceDetailScreen( service = pane.type, onBack = onClose, - onConfigureCredential = { svc -> - // 在右侧内部子路由跳转到 CredentialEdit - onClose() // 先清右侧再切 Settings(简化:直接切 Settings) - }, - onStartWebViewLogin = { svc -> } + onConfigureCredential = { onClose() }, + onStartWebViewLogin = { } ) } is DetailPane.OCGOUsage -> { - var subRoute by remember { mutableStateOf(UsageSubRoute.Chart) } - when (subRoute) { - is UsageSubRoute.Chart -> { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = "chart", + popExitTransition = { ExitTransition.None } + ) { + composable("chart") { UsageDetailScreen( onBack = onClose, - onOpenOverview = { subRoute = UsageSubRoute.Overview }, - onOpenData = { subRoute = UsageSubRoute.Data } + onOpenOverview = { navController.navigate("overview") }, + onOpenData = { navController.navigate("data") } ) } - is UsageSubRoute.Overview -> { - UsageOverviewScreen(onBack = { subRoute = UsageSubRoute.Chart }, autoLoad = true) + composable("overview") { + UsageOverviewScreen(onBack = { navController.popBackStack() }, autoLoad = true) } - is UsageSubRoute.Data -> { - UsageDataScreen(onBack = { subRoute = UsageSubRoute.Chart }, 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") @@ -324,37 +317,40 @@ private fun ExpandedDetailPane( usageVm.setWorkspace(wid) chartVm.setWorkspace(wid) } - var subRoute by remember { mutableStateOf(UsageSubRoute.Chart) } - when (subRoute) { - is UsageSubRoute.Chart -> { + NavHost( + navController = navController, + startDestination = "chart", + popExitTransition = { ExitTransition.None } + ) { + composable("chart") { UsageDetailScreen( onBack = onClose, - onOpenOverview = { subRoute = UsageSubRoute.Overview }, - onOpenData = { subRoute = UsageSubRoute.Data }, + onOpenOverview = { navController.navigate("overview") }, + onOpenData = { navController.navigate("data") }, viewModel = chartVm, clearViewModel = usageVm ) } - is UsageSubRoute.Overview -> { + composable("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) + UsageOverviewScreen(onBack = { navController.popBackStack() }, viewModel = ovVm, autoLoad = false) } - is UsageSubRoute.Data -> { + composable("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) + UsageDataScreen(onBack = { navController.popBackStack() }, viewModel = dataVm, autoLoad = false) } } } is DetailPane.Settings -> { - // Settings 内部子路由用局部 NavHost val settingsNavController = rememberNavController() NavHost( navController = settingsNavController, - startDestination = "settings_main" + startDestination = "settings_main", + popExitTransition = { ExitTransition.None } ) { composable("settings_main") { SettingsScreen(