Add predictive back navigation polish

This commit is contained in:
WaterRain 2026-07-02 22:29:09 +00:00
parent 4e5dda5521
commit 9cfb962bc8
No known key found for this signature in database
6 changed files with 41 additions and 34 deletions

View File

@ -2,9 +2,9 @@
## 项目概述
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go 三项服务的余额/配额。
Android(Jetpack Compose + Kotlin)APP,统一查询 DeepSeek、OpenCode Go、CommandCode Go、Codex / ChatGPT Plus 的余额/配额。
DeepSeek 走 REST API,OpenCode Go 通过 OkHttp 抓取 dashboard HTML 解析 SSR hydration 数据。
CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 auth.json 刷新 token 后查询 wham 用量。APP 名为「雨晴Token」(粉色调品牌),配套桌面小组件。
## 技术栈
@ -76,7 +76,7 @@ CommandCode Go 走 JSON API 抓取用量数据。APP 名为「雨晴Token」(
> - `visualDragOffsetX/Y` —— 视觉跟手偏移,持续累计
> - `dragStartCenterX/YInWindow` —— 拖动开始时卡片中心在窗口位置,作为偏移基准
> - `dragFromIndex` / `dragTargetIndex` —— 真实 index 和目标 index,不触发重组
> - `itemCenterById` —— 拖动中冻结的格子中心坐标表
> - `itemCenterById` —— 拖动中冻结的格子中心坐标表;使用普通 `HashMap`,不要改回 Compose StateMap(滚动/返回动画期间 `onGloballyPositioned` 高频写入会触发重组卡顿)
> - `displayOrder` —— 真实布局顺序,拖动中不改
> - `cardOrder`(外层 SharedPreferences)—— 持久化的用户偏好顺序
@ -92,24 +92,13 @@ Compact(手机):
→ CCGO: CCGO_USAGE_DETAIL(图表) → CCGO_USAGE_OVERVIEW(总统计)
↘ CCGO_USAGE_DATA(原始数据)
返回用 guardedPop()(200ms 时间戳围栏,PopGuard 非 State 对象)+ popExit=fadeOut(1ms)+popEnter=None。
围栏防同一帧/连续帧的第二次 pop;popExit=fadeOut(1ms) 确保旧 composable 正确从 layout 树移除
(ExitTransition.None 是字面零帧,连续 pop 时退出 composable 可能未清除 → 幽灵残影卡在上层),
popEnter=None 消除进入动画与连点 pop 的竞态(根因:默认 popEnter fadeIn(700ms) 时,
第二次 pop 中断进入动画导致 AnimatedContent 状态不一致 → 空白页)。
enter/exit 保留默认动画(仅影响前进导航)。
返回用 guardedPop()(200ms 时间戳围栏,PopGuard 非 State 对象)+ Android predictive back。
Manifest 开启 `android:enableOnBackInvokedCallback="true"`;`navigation-compose` 保持 2.9.x 以上,使用后续 predictive back 修复。
Compact 根 `NavHost` 必须显式配置 `enterTransition` / `exitTransition` / `popEnterTransition` / `popExitTransition` 四项:
前进统一左滑,返回统一右滑,避免只配置 pop 时短时间返回混入默认淡入淡出。
当前页面背景/渐变层不适合 `scaleOut` 或长透明淡出类返回动画,容易出现透明背景和文字叠影;若要改动画,先处理 destination 的不透明背景层。
PopGuard 额外检查 previousBackStackEntry != null,且 popBackStack() 返回 false 时 reset 围栏。
✅ 已修复:快速连点左上角返回 → 空页面 / 旧页面残影卡在上层。
根因:① mutableStateOf 写入触发 NavHost 重组,干扰 AnimatedContent 过渡状态机;
② popExitTransition=None 字面零帧,连续 pop 时退出 composable 未从 layout 树清除;
③ popExitTransition=None + 默认 popEnterTransition(700ms) 不匹配,第二次 pop
中断进入动画导致 AnimatedContent 状态不一致。
修复:① PopGuard 用普通对象替代 mutableStateOf,不触发重组;
② popExitTransition=fadeOut(tween(1)),极短非零帧确保 composable 正确移除;
③ popEnterTransition=EnterTransition.None,返回导航瞬时完成;
④ previousBackStackEntry 空值检查 + popBackStack() 返回值检查。
Expanded(平板,≥840dp):
┌─ 左侧 35%: Dashboard(固定) ─┐ ┌─ 右侧 65%: when(pane) 原子切换 ─────┐
│ │ │ ServiceDetail / OCGOUsage / CCGOUsage │

View File

@ -30,7 +30,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
|------|------|
| 📊 **仪表盘** | DeepSeek 余额(¥)+ 各服务用量/余额卡片;OCGO/CCGO 卡片可直达用量详情;长按拖动自由排序(持久化);下拉全局刷新 · 平板自适应双窗格布局 |
| 📈 **用量图表** | 3 张 Canvas 手绘图表 — 消耗金额 / API 请求次数 / Token 消耗(OCGO & CCGO 双数据源);支持 UTC+0/UTC+8 时区切换和自定义日/月/范围;自动降级(近5h无数据→12h→7天→当月);平板并排展示 |
| 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式) |
| 📱 **平板适配** | 全局 `BoxWithConstraints` 自适应容器宽度;≥600dp 卡片双列,≥700dp 图表并排;双窗格 35/65 左右分栏(Expanded 模式);支持 Android 13+ 预见性返回手势 |
| 📋 **详细数据** | 原始记录分页浏览,支持时间 + 模型筛选,点击查看完整字段 |
| 🔍 **多粒度筛选** | 5小时 / 12小时(10分钟桶) / 24小时 / 今天 / 昨天 / 最近7天 / 当月 / 自定义日·月·范围 |
| 🏷️ **模型筛选** | 多选 / 单选 / 全选,动态图例自适应换行 |
@ -172,7 +172,7 @@ chmod +x ./setup_android_env.sh
- ✅ API Key / Session 凭据存入 **Android Keystore**(AES-256 GCM 加密)
- ✅ 网络请求仅向 DeepSeek / OpenCode 官方 API 发出
- ✅ `allowBackup="false"`,拒绝应用数据被备份
- ✅ API Key / Session 由 Android Keystore 加密;应用数据备份规则由 `data_extraction_rules.xml` / `backup_rules.xml` 控制
- ✅ 签名密钥固定,每次 Release 可覆盖安装
- ✅ GitHub Secrets 加密存储签名密钥,CI 中解码使用

View File

@ -7,6 +7,7 @@
<application
android:name=".RainyTokenApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View File

@ -1,9 +1,9 @@
package com.rainy.token.ui
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.EnterTransition
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
@ -54,7 +54,7 @@ import com.rainy.token.ui.webview.WebViewLoginScreen
*
* 自适应策略:
* - **Compact**(< 600dp,手机):单栈 NavHost。guardedPop 时间戳围栏(200ms)防连点栈损坏,
* popExit=fadeOut(1ms)+popEnter=None 消除过渡动画与连点 pop 的竞态,enter/exit 保留默认动画。
* enter/exit/popEnter/popExit 显式使用同一套滑动过渡,避免默认淡入淡出与返回动画混用。
* - **Expanded**(≥ 840dp,平板横屏):左侧 Dashboard + 右侧 when 分支原子切换,
* 用量子路由用局部 NavHost。
*/
@ -149,14 +149,32 @@ private fun CompactNavHost() {
NavHost(
navController = navController,
startDestination = Routes.DASHBOARD,
// popExit=fadeOut(1ms):极短但非零的退出动画,确保 AnimatedContent 正确执行
// transition 结束帧来移除旧 composable。ExitTransition.None 是字面零帧,
// 连续 pop 时上一帧的退出 composable 可能未从 layout 树中清除 → 幽灵残影。
// popEnter=None:新 composable 瞬时出现,消除进入动画与连点 pop 的竞态
// (根因:默认 popEnter fadeIn(700ms) 时,第二次 pop 中断进入动画导致
// AnimatedContent 状态不一致 → 空白页)
popExitTransition = { fadeOut(animationSpec = tween(1)) },
popEnterTransition = { EnterTransition.None }
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) {
DashboardScreen(

View File

@ -53,7 +53,6 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -346,7 +345,7 @@ private fun DraggableDashboardCards(
var cardHeightPx by remember { mutableIntStateOf(1) }
var dragFromIndex by remember { mutableIntStateOf(-1) }
var dragTargetIndex by remember { mutableIntStateOf(-1) }
val itemCenterById = remember { mutableStateMapOf<String, androidx.compose.ui.geometry.Offset>() }
val itemCenterById = remember { HashMap<String, androidx.compose.ui.geometry.Offset>() }
fun autoScrollDelta(): Float {
if (dragCenterYInViewport < 0f || scrollState.maxValue <= 0) return 0f

View File

@ -11,7 +11,7 @@ activityCompose = "1.9.3"
composeBom = "2024.10.01"
# Navigation
navigationCompose = "2.8.4"
navigationCompose = "2.9.8"
# Network (Retrofit 2.11.0 配 OkHttp 4.12.0 — 稳定组合,避开 Retrofit 3.x + OkHttp 5.x 的前沿兼容性问题)
retrofit = "2.11.0"