# 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<boolean>`

```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<boolean>;
```

- [ ] **Step 3: bridge 接口扩展**

`accessibilityBridge.ts`：`NativeAccessibilityBridge` 加 `setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;`，文件头注释方法清单补一行。`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` 保留为语义色（扫描红光），不受主题控制。
