# UI 重设计 P6:原生浮层(悬浮球/悬浮窗)重设计 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。 **Goal:** 原生浮层(FloatingHelper 悬浮球 / FloatingBillView 浮窗 / FloatingTip 提示)视觉与文案对齐新设计系统:颜色与文案由 JS 层(theme tokens + i18n)通过 bridge 下发,原生不再硬编码中文与旧靛蓝;悬浮球位置持久化;浮窗补币种字段;前台账单 Alert i18n 化并去 emoji。 **背景决策(已确认):** - 原生 View 无法直接用 RN theme,采用 **`FloatingUiConfig` 下发模式**:JS 侧从当前 theme + i18n 构建 config → bridge `setFloatingUiConfig()` → 原生存 SharedPreferences(JSON)→ 三个浮层组件读取。原生保留现有硬编码值作为**默认值兜底**(JS 未推送时行为不变)。 - 浮窗卡片**跟随 App 主题**(浅色=白卡近黑 accent,深色=OLED 黑卡白 accent),卡片用实色(不再半透明磨砂),保证在其他 App 上的可读性。 - 浮窗金额校验从 `toDoubleOrNull` 改正则 + BigDecimal;自动消失 15s → 30s。 - 改动范围:`plugins/accessibility/`(Kotlin)+ `src/services/`(bridge/config)+ `src/app/_layout.tsx` + `src/services/automationPipeline.ts` + `src/i18n/`。**不碰 domain 写路径。** --- ## FloatingUiConfig 契约(T1-T5 共同遵守,先读这里) ### JS → 原生:`setFloatingUiConfig(config: ReadableMap): Promise` ```typescript interface FloatingUiConfig { colors: { accent: string; // 按钮/选中态背景(= theme accent:light #111318 / dark #F3F4F6) accentFg: string; // accent 上的文字色(= fgInverse) cardBg: string; // 卡片底色(= bgSecondary) inputBg: string; // 输入框/未选中 chip 底色(= bgTertiary) fgPrimary: string; fgSecondary: string; border: string; income: string; // financial.income expense: string; // financial.expense transfer: string; // financial.transfer }; labels: { billTitle: string; // 浮窗标题 dirExpense: string; dirIncome: string; dirTransfer: string; amountLabel: string; payeeLabel: string; narrationLabel: string; narrationHint: string; categoryExpense: string; // 「交易分类」 categoryIncome: string; // 「收入分类」 transferTarget: string; // 「转入账户」 accountExpense: string; // 「资金来源」 accountIncome: string; // 「存入账户」 accountTransfer: string; // 「转出账户」 openApp: string; dismiss: string; confirm: string; ballOcr: string; // 悬浮球「识别账单」 ballRemember: string; // 「记住此页」 rememberSuccess: string; // 记住页面成功提示(不含 emoji) rememberFail: string; // 失败提示前缀(原生拼接 ': ' + e.message) pageRemembered: string; // BillingAccessibilityService Toast「已记住页面签名」前缀 pageSignatureExists: string; // 「该页面签名已存在」前缀 }; } ``` - 颜色一律 `"#RRGGBB"` 六位数 hex(theme tokens 里有 rgba 的字段不要直接下发——accent/cardBg/fg/border 等都是纯色,确认无 rgba;border 深色主题是 `rgba(255,255,255,0.08)`,下发前换算为 hex:深色用 `#14202429`? **不许发明值**——正确做法:原生 `Color.parseColor` 支持 `#AARRGGBB`,JS 侧把 rgba(255,255,255,0.08) 换算为 `#14FFFFFF`(alpha 0.08≈0x14)。写一个 `rgbaToHex` 小工具处理这种情况,纯色 hex 原样通过)。 - 原生端用 `Color.parseColor` 解析,异常时回退默认值。 ### 原生持久化 - SharedPreferences 文件名:`floating_ui_config`,单键 `config_json` 存整个 JSON。 - 新增 `FloatingUiConfigStore.kt`(object):`save(context, ReadableMap)` / `load(context): FloatingUiConfig`(data class,字段缺省值=现状硬编码值,保证旧 JS 行为不变)。 - `FloatingUiConfig` data class 字段名与上面契约一致(camelCase)。 ### 事件契约变更(T3+T5) - `showFloatingBill` 增加参数 `currency: String`(放在 `direction` 之后、`draftId` 之前)。 - `billingConfirmed` / `billingOpenApp` 事件 map 增加 `putString("currency", currentCurrency)`。 - 浮窗金额行左侧显示当前币种 chip,点击在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 中循环切换;初始值 = 传入 currency(不在列表则插入到首位)。 --- ### Task 1: JS 侧 — i18n 键 + floatingUiConfig 服务 + bridge 接口 + 推送接线 + 前台 Alert i18n **Files:** - Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`、`src/services/accessibilityBridge.ts`、`src/app/_layout.tsx`、`src/services/automationPipeline.ts` - Create: `src/services/floatingUiConfig.ts`、`tests/floating-ui-config.test.ts` - [ ] **Step 1: i18n 新增 `floating.*` 键组(zh/en 对等,`%{name}` 插值语法)** ``` floating.billTitle 调整交易草稿 / Adjust Draft floating.dirExpense 支出 / Expense (复用现有方向键也行,但浮层独立一组避免耦合) floating.dirIncome 收入 / Income floating.dirTransfer 转账 / Transfer floating.amountLabel 金额 / Amount floating.payeeLabel 交易对手 / Payee floating.narrationLabel 描述/备注 / Narration floating.narrationHint 输入交易叙述 / Enter narration floating.categoryExpense 交易分类 / Category floating.categoryIncome 收入分类 / Income Category floating.transferTarget 转入账户 / To Account floating.accountExpense 资金来源 / From Account floating.accountIncome 存入账户 / To Account floating.accountTransfer 转出账户 / From Account floating.openApp 打开应用 / Open App floating.dismiss 忽略 / Dismiss floating.confirm 确认入账 / Confirm floating.ballOcr 识别账单 / Scan Bill floating.ballRemember 记住此页 / Remember Page floating.rememberSuccess 已将当前页面加入识别白名单 / Page added to recognition whitelist floating.rememberFail 记录失败 / Failed to save floating.pageRemembered 已记住页面签名 / Page signature saved floating.pageSignatureExists 该页面签名已存在 / Page signature already exists ``` (若现有键已有同文案可复用,但键组保持独立 `floating.*`;先 grep 避免重复键名。) 同时给前台 Alert 加键(放 `automation.*` 下,先读现有 automation 节): ``` automation.billDetectedTitle 识别到新账单 / New Bill Detected (无 emoji) automation.billDetectedReject 拒绝/丢弃 / Discard automation.billDetectedEdit 修改并入账 / Edit & Save automation.billDetectedConfirm 确认入账 / Confirm ``` Alert 的多行消息体(日期/商户/金额/分类/账户/叙述)逐行用既有标签键拼,不为每行加新键(行标签可复用表单/详情现有键,先 grep `transaction.payee` 之类;没有合适的就在 automation 节加 `billDetectedLine*` 键)。 - [ ] **Step 2: `src/services/floatingUiConfig.ts`** ```typescript export interface FloatingUiConfig { colors: {...}; labels: {...} } // 按契约 /** rgba(r,g,b,a) → #AARRGGBB;#RRGGBB 原样返回。 */ export function colorToHex(color: string): string; /** 从 theme tokens + t() 构建完整 config。 */ export function buildFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): FloatingUiConfig; /** 构建并推送到原生(bridge 不可用时静默返回 false)。 */ export async function pushFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): Promise; ``` - [ ] **Step 3: bridge 接口扩展** `accessibilityBridge.ts`:`NativeAccessibilityBridge` 加 `setFloatingUiConfig(config: FloatingUiConfig): Promise;`,文件头注释方法清单补一行。`showFloatingBill` 签名加 `currency: string`(direction 之后、draftId 之前)。 - [ ] **Step 4: _layout.tsx 推送接线** - 启动时(setFloatingBallEnabled 同一处,`src/app/_layout.tsx:139` 附近)也 `pushFloatingUiConfig(...)`。注意此处拿不到 useTheme 的 theme(在 ThemeProvider 外层?先读 _layout 结构确认)——若拿不到,启动这次推送可移到下一步的组件里统一做,避免重复。 - 在 ThemeProvider **内部**挂一个小组件(如 `FloatingUiConfigSyncer`,可直接写在 _layout.tsx 里):`const { theme } = useTheme(); const t = useT(); useEffect(() => { pushFloatingUiConfig(theme, t); }, [theme, t])` —— 主题切换/语言切换/启动都会重推。t 引用随 locale 变化(确认 useT 返回的 t 在语言切换时引用变化,先读 src/i18n 实现;若 t 引用稳定,则依赖里加 locale)。 - [ ] **Step 5: automationPipeline.ts 前台 Alert i18n + 去 emoji** `src/services/automationPipeline.ts:298` 的 `Alert.alert('🌟 识别到新账单', ...)`:标题/按钮全部改 t();消息体保留原信息结构。该文件是 service 层非组件——确认文件里如何拿 t(若没有,用 `i18n.t(...)` 直接调,读 src/i18n/index.ts 导出了什么)。`showFloatingBill` 调用处(:262)传第 7 个参数 `currency`(用 :225 已提取的 currency 变量)。 - [ ] **Step 6: 测试 + 验证** `tests/floating-ui-config.test.ts`: - `colorToHex`:`'#111318'` 原样;`'rgba(255,255,255,0.08)'` → `#14FFFFFF`;`'rgba(17,19,24,0.4)'` → `#66111318`(alpha 四舍五入 Math.round(a*255)) - `buildFloatingUiConfig`:用 lightTheme + 真实 zh t() 构建,断言 colors.accent === '#111318'、labels.billTitle === '调整交易草稿'、所有契约字段非空(遍历 keys) Run: `npm run typecheck && npm test` → 全绿(i18n parity 测试覆盖新键) --- ### Task 2: 原生 config 基础设施(FloatingUiConfigStore + bridge 方法 + FloatingTip/Toast 改读) **Files:** - Create: `plugins/accessibility/android/FloatingUiConfigStore.kt` - Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`、`plugins/accessibility/android/FloatingTip.kt`、`plugins/accessibility/android/BillingAccessibilityService.kt` - [ ] **Step 1: FloatingUiConfigStore.kt** - `data class FloatingUiConfig(...)`:colors/labels 全部字段,默认值 = 现状硬编码(accent `#5E6AD2`、cardBg `#FF050506`? —— **不**:默认值应是「现状视觉」,但现状 cardBg 是 0x8C050506 半透明。默认 cardBg 用 `#F2050506`? 简化:默认值就用现状各硬编码颜色换算成 `#AARRGGBB`/`#RRGGBB` 字符串,逐字段列注释对应原 Kotlin 常量)。labels 默认值 = 现状中文硬编码文案(去 emoji)。 - `object FloatingUiConfigStore`:`private const val PREFS = "floating_ui_config"`;`fun save(context: Context, map: ReadableMap)`(遍历契约字段,缺失字段跳过不覆盖,JSONObject 组装后写入);`fun load(context: Context): FloatingUiConfig`(JSONObject 读取,缺失/解析失败逐字段回退默认;`optString`)。颜色字段提供 `fun parseColorOr(value: String, fallback: Int): Int` 工具(`Color.parseColor` try/catch)。 - org.json 可用(Android 内置),无需新依赖。 - [ ] **Step 2: AccessibilityBridgeModule.setFloatingUiConfig** ```kotlin @ReactMethod fun setFloatingUiConfig(config: ReadableMap, promise: Promise) { try { FloatingUiConfigStore.save(reactContext, config) promise.resolve(true) } catch (e: Exception) { promise.reject("CONFIG_SAVE_FAIL", e.message) } } ``` - [ ] **Step 3: FloatingTip/RepeatToast 改读 config** - FloatingTip.show():背景色 `0xF0333333` → config accent(不透明化:解析后 `or 0xFF000000.toInt()`),文字色 → accentFg,ProgressBar `progressTintList` 设 accentFg。构造签名加可选 `config: FloatingUiConfig? = null`,null 时内部 `FloatingUiConfigStore.load(context)`。 - RepeatToast 的 `"⚠ $message"` 前缀 emoji 去掉,只留 message(⚠ 属 emoji 审计范围)。 - FloatingTip 调用处(FloatingHelper.kt:209/211)改传 config 文案:`labels.rememberSuccess`、`"${labels.rememberFail}: ${e.message}"`(T4 做也可以,此处先改文案读取,FloatingHelper 的彻底重设计在 T4)。 - [ ] **Step 4: BillingAccessibilityService 两处 Toast 文案** :387 `"已记住页面签名:\n$sig"` 与 :419 `"该页面签名已存在:\n$sig"` → 改读 `FloatingUiConfigStore.load(this).labels` 的 pageRemembered / pageSignatureExists + `":\n$sig"`。 - [ ] **Step 5: 编译验证** `android/` 已生成。把改动的 .kt 手动拷到 `android/app/src/main/java/com/beancount/mobile/accessibility/`(与 plugins 目录同名文件覆盖),然后: Run: `cd android && ./gradlew :app:compileDebugKotlin -q` → BUILD SUCCESSFUL (若编译环境不可用,记录为设备验证项并在报告中说明。) --- ### Task 3: FloatingBillView 重设计 + 币种 **Files:** - Modify: `plugins/accessibility/android/FloatingBillView.kt`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(showFloatingBill 加 currency 参数透传) **视觉规格(对齐 docs/ui-redesign-design.md §3 Bento):** - 卡片:实色 cardBg、圆角 16dp、1dp border 描边、padding 16dp(原 12/8 加宽) - 标题:fgPrimary 13sp bold;分段选择器:选中=accent 底 accentFg 字,未选中=透明 fgSecondary 字,圆角 6dp - 字段标签:fgSecondary 10sp bold;输入框:inputBg 底、圆角 10dp、1dp border、fgPrimary 字、hint fgSecondary - chips:圆角 999(用 dp(14f) 近似胶囊)、未选中=inputBg+border 描边+fgSecondary 字、选中:分类行支出/收入=accent;转账方向第一行=transfer 色;账户行按方向=income/expense 色(语义色保留,但改从 config 读) - 按钮:高 40dp、圆角 12dp;confirm=accent 底 accentFg 字 bold;openApp/dismiss=inputBg 底 fgPrimary 字 - 所有颜色经 `FloatingUiConfigStore.load(context)` + parseColorOr 兜底;所有文案经 labels - [ ] **Step 1: 构造签名 + showFloatingBill 参数** `FloatingBillView` 构造加 `initialCurrency: String = "CNY"`;`AccessibilityBridgeModule.showFloatingBill` 加 `currency: String` 参数(direction 后 draftId 前)并透传。 - [ ] **Step 2: 币种 chip** 金额行:水平布局,左侧币种 chip(inputBg+border,fgPrimary 字,显示 currentCurrency),右侧金额输入框(weight 1)。点击 chip 在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 循环(initialCurrency 不在列表则临时插到首位)。sendSaveEvent/sendOpenAppEvent 的 map 加 `putString("currency", currentCurrency)`。 - [ ] **Step 3: 金额校验改正则 + BigDecimal** `afterTextChanged`:`Regex("^\\d+(\\.\\d{1,2})?$")` 匹配且 `BigDecimal(text) > BigDecimal.ZERO` 才启用保存(BigDecimal 构造 try/catch)。禁用时按钮底色 inputBg + fgSecondary 字。 - [ ] **Step 4: 全面替换颜色与文案 + 自动消失 30s** 逐段按上面视觉规格重写 show() 与 rebuildChips()(结构不变,只换色值来源、文案来源、尺寸);`15000L` → `30000L`。标签按方向切换的文案(交易分类/收入分类/转入账户、资金来源/存入账户/转出账户)全部走 labels。 - [ ] **Step 5: 编译验证**(同 T2 Step 5 流程) --- ### Task 4: FloatingHelper 重设计 + 位置持久化 **Files:** - Modify: `plugins/accessibility/android/FloatingHelper.kt` - [ ] **Step 1: 颜色与文案走 config** - 指示竖线:accent(保持 70% 透明度:解析后 alpha 设为 0xB0) - 菜单:实色 cardBg、圆角 12dp、1dp border - 「识别账单」按钮:accent 底 accentFg 字(主操作);「记住此页」:inputBg 底 fgPrimary 字;按压态透明度变化保留(按下时 alpha 0.8) - ScanIconDrawable/PinIconDrawable 颜色:Scan 用 accentFg(在 accent 底按钮上),Pin 用 fgSecondary - 文案 labels.ballOcr / ballRemember / rememberSuccess / rememberFail(带 ': ' + message 拼接) - [ ] **Step 2: 位置持久化** - SharedPreferences 复用 `billing_accessibility_prefs`:键 `floating_ball_x` / `floating_ball_y` - `show()` 初始化:`params.x/y = prefs.getInt(...)`,无键时用现状默认(0/400);拖动抬起(吸附后)写 prefs - companion 的 lastX/lastY 保留为进程内缓存但初始从 prefs 读(或直接用局部变量+prefs,简化则删 companion——注意多实例语义,BillingAccessibilityService 单例,安全) - [ ] **Step 3: 编译验证**(同 T2 Step 5 流程) --- ### Task 5: 币种链路 JS 消费侧 **Files:** - Modify: `src/services/automationPipeline.ts`、`src/app/_layout.tsx` - [ ] **Step 1: billingConfirmed 处理器用 res.currency** 读 `src/services/automationPipeline.ts:50-110` 的 handleBillingConfirmed:`const currency = pending?.event?.currency || 'CNY'`(:78)→ 优先 `res.currency`(浮窗用户改过),`res.currency || pending?.event?.currency || 'CNY'`。确认 res 类型定义处加 currency 字段(NativeOpenAppEvent 之类,grep 类型声明一起改)。 - [ ] **Step 2: billingOpenApp 处理器** `src/app/_layout.tsx:255` `const currency = 'CNY'` → `const currency = res.currency || 'CNY'`。 - [ ] **Step 3: 验证** Run: `npm run typecheck && npm test` → 全绿 --- ### Task 6: P6 审计 + 验收 - [ ] **Step 1: 审计** ```bash grep -rn "0xFF5E6AD2\|0x995E6AD2\|0xB05E6AD2\|0x1F5E6AD2\|0x405E6AD2" plugins/accessibility/android/ # 无输出(旧靛蓝清零,默认值除外——FloatingUiConfigStore 默认值允许保留并注释) grep -rn '"[^"]*[一-鿿]' plugins/accessibility/android/*.kt | grep -v "FloatingUiConfigStore\|//" # 除 Store 默认值与注释外无硬编码中文 UI 字符串 grep -rn "⚠\|📌\|🌟" src/ plugins/ # 无输出 grep -n "currency" src/services/accessibilityBridge.ts # showFloatingBill 含 currency ``` - [ ] **Step 2: 全量测试 + typecheck + Kotlin 编译** Run: `npm test` → 全绿;`npm run typecheck` → 无错误;`cd android && ./gradlew :app:compileDebugKotlin -q` → 成功 - [ ] **Step 3: 手工走查(需设备)** Run: `npm run android` 1. 设置里切换 浅/深主题 → 触发一次浮窗(可用 automation 页调试入口或真实账单)→ 浮窗颜色跟随主题 2. 切换英文 → 浮窗/悬浮球/提示文案全英文 3. 拖动悬浮球 → 杀进程重启无障碍服务 → 位置保持 4. 浮窗点币种 chip 切换 USD → 确认入账 → 交易币种为 USD 5. 金额输入 `1e10` / `0` / `0.005` → 保存按钮禁用;`12.50` → 可用 6. 浮窗 30 秒无操作自动消失;触摸后不消失 --- ## 终审修订(已实施,以实际代码为准) **终审日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。 | # | 级别 | 问题 | 修复 | |---|---|---|---| | C1 | Critical | FloatingBillView 标题 `setTextColor(colorAccentFg)` — 浅色主题下白卡白字、深色主题下黑底黑字,标题不可见 | 改为 `colorFgPrimary` | | I1 | Important | 容器边框从 accent 派生 alpha 而非用 config.border | 删除 `colorContainerBorder`,直接使用 `colorBorder` | | I2 | Important | FloatingHelper btnRemember 背景从 fgPrimary 派生,计划要求 inputBg | 加 `colorInputBg` 解析,btnRemember 改从 inputBg 派生 | | I3 | Important | 悬浮球菜单背景半透明(65% cardBg),计划要求实色 | 菜单背景直接使用 `colorCardBg`(full opacity),仅描边保留半透明 | | M1 | Minor | FloatingTip 背景未强制不透明化(plan §T2 Step 3) | 加 `or 0xFF000000.toInt()` 保护 | | M2 | Minor | 金额校验先 BigDecimal 解析再正则,正则先匹配可早期短路 | 调整为先 `pattern.matches(text)` 再 `value != null && value > BigDecimal.ZERO` | **偏差说明(无需修复)**: - FloatingHelper 菜单背景 / 按钮背景 / 指示线的 alpha 分量组合(`(colorX and 0x00FFFFFF) or alpha`)是原生浮层叠加其他 App 所必需的透明度控制,不是颜色硬编码。颜色 RGB 分量完全来自 config。 - FloatingTip 默认 fallback `#FF000000` 是纯黑(非靛蓝),因为 accent 在极浅色主题下是 `#111318`(近黑),fallback 用黑色是安全的极限退化。 - ScanIconDrawable laserColor `0xFFF87171` 保留为语义色(扫描红光),不受主题控制。