Compare commits

...

3 Commits

Author SHA1 Message Date
fengmengqi
bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

OCR 三层独立控制:
- OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关
- 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision)
- OCR 处理增加耗时与字符数日志

日志系统升级:
- logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log)
- 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx
- 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理
- _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号)

原生浮层 UI 主题同步(P6):
- 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生
- 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取
- FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal
- FloatingHelper / FloatingTip 同步适配
- _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送

UI 与组件增强:
- FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange
- 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗
- 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择
- AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型
- 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗)

去重优化:
- 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复
- DedupResult 新增 matchedItem 返回匹配对比项

文档重构:
- README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表)
- 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图)
- 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md
- UI 重设计文档(design spec + p1-p8)移入 docs/design/

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00
fengmengqi
2e73b5a2c6 feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级
UI 重设计(P1-P5):
- 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色
- 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度
- 圆角体系新增 xl(24),卡片/弹窗统一大圆角
- 移除 Caveat/Quicksand 自定义字体,切换为系统字体
- 新增 display(34/800) 字阶用于净资产等大数字展示
- 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希)
记一笔 NumpadSheet(P3 录入闭环):
- 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘
- 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题
- 高级模式保留多行分录编辑器,简单模式自动推断双腿
- postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填)
- 未保存守卫:整页 beforeRemove + modal 脏状态上报
- 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关
Tab 栏重构:
- AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板
- Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立
管理页模板 ManagementScreen:
- 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式
- 预算/分类/标签/信用卡等管理页消除手写样板
其他:
- OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换)
- GBK 解码器 import 修复(text-encoding-gbk ESM 兼容)
- 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值)
- 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作
- periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算)
- 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
2026-07-22 13:33:29 +08:00
fengmengqi
7fa345b558 feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构
品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00
211 changed files with 17932 additions and 23243 deletions

4
.gitignore vendored
View File

@ -47,4 +47,6 @@ CLAUDE.md
/.mimocode/
/.agents/
/reference_project/
/.zcode/
/.zcode/
# superpowers brainstorm mockups
.superpowers/

62
AGENTS.md Normal file
View File

@ -0,0 +1,62 @@
# Repository Guidelines
## Project Overview
DriftLedger (浮记) is an offline-first mobile client for [Beancount](https://beancount.github.io/) double-entry accounting, built with React Native + Expo (CNG), TypeScript, Zustand, and expo-sqlite. The app treats desktop-maintained `.bean` files as the read-only source of truth and appends new entries to `main.bean`.
## Project Structure & Module Organization
| Directory | Purpose |
|-----------|---------|
| `src/domain/` | Pure business logic (ledger parser, bill pipeline, dedup, rules, OCR processor). No React/RN imports. |
| `src/app/` | Expo Router file-based routes; `(tabs)/` is bottom navigation. |
| `src/components/` | Reusable React Native UI components and charts. |
| `src/store/` | Zustand stores (ledger, import, settings, metadata, automation). |
| `src/services/` | Platform-facing concerns (sync, backup, security, OCR bridge). |
| `src/storage/` | SQLite read-cache with versioned migrations. |
| `src/theme/` | Token-based theming system. |
| `src/i18n/` | zh/en localization dictionaries. |
| `plugins/` | Expo Config Plugins for native modules (ppocr, accessibility, sms-receiver, etc.). |
| `tests/` | Vitest unit tests (mock-backed, domain-focused). |
## Build, Test, and Development Commands
```bash
npm install --legacy-peer-deps # Install dependencies (legacy-peer-deps required)
npm run start # Start Metro dev server
npm run android # Build & run on Android device/emulator
npm test # Run full Vitest suite
npm run typecheck # TypeScript strict-mode check (tsc --noEmit)
# Run a single test file:
npx vitest run tests/ledger.test.ts
```
## Coding Style & Naming Conventions
- **TypeScript strict mode** is enabled; path alias `@/*` maps to `src/*`.
- **Money math** uses string decimals (`src/domain/decimal.ts`) — never raw JS floats.
- **Domain layer** (`src/domain/`) must remain pure: no React, RN, or Expo imports. Inject external dependencies via interfaces.
- Code comments and `plan.md` are written in **Chinese**; match that convention.
- New domain modules must be re-exported from `src/domain/index.ts`.
- Native code lives exclusively in `plugins/<name>/` as Expo Config Plugins — never edit generated `android/` files directly.
## Testing Guidelines
- Framework: **Vitest** (no config file; picks up `tests/**/*.test.ts` by default).
- Tests target the domain layer using mock backends (`MemoryBackend`, `MockOcrEngine`).
- Name test files descriptively: `<module-or-feature>.test.ts` (e.g., `decimal.test.ts`, `billPipeline.test.ts`).
- Run `npm run typecheck` after non-trivial changes to catch type regressions.
## Commit & Pull Request Guidelines
- Commit messages follow the pattern: `feat: <concise summary in Chinese>` with a detailed multi-line body listing changes by area.
- Example: `feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
- PRs should describe what changed and why; link related issues. Include screenshots for UI changes.
## Key Architectural Invariants
1. `.bean` files are the source of truth; SQLite is a disposable read-cache.
2. All bill ingestion funnels through `BillPipeline` (`src/domain/billPipeline.ts`) under a serialized mutex.
3. Every Config Plugin function **must `return config`** at the end.
4. IDs/checksums use FNV-1a hashing (`hash()` in `ledger.ts`), not crypto hashes.

View File

@ -1,26 +1,54 @@
# Bean Mobile
# DriftLedger (浮记)
面向 Beancount 用户的离线移动端原型:桌面账本只读,移动端仅写入 `mobile.bean`CSV 账单先生成复式分录草稿,再由用户确认
面向 [Beancount](https://beancount.github.io/) 用户的离线移动记账客户端。桌面账本只读,移动端仅追加写入 `main.bean`;支持 OCR、无障碍服务、通知监听、短信等多渠道自动记账
## 启动
**技术栈**React Native + Expo (CNG) · TypeScript (strict) · Zustand · expo-sqlite · PP-OCRv6 (ONNX Runtime)
## 快速开始
```bash
npm install
npm run start
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps
npm run start # 启动 Metro 开发服务器
npm run android # 构建并运行 Android
npm test # 运行 Vitest 全量测试
npm run typecheck # TypeScript 类型检查
```
使用 Expo CNG 开发构建运行:`npm run android` 或 `npm run ios`。应用配置启用了 `expo-sqlite` 的 SQLCipher 构建选项,实际密钥管理应接入 SecureStore/Keychain 后再发布。
> 本应用为侧载开源项目不上架应用商店。Android 构建详见 [docs/android-build-guide.md](docs/android-build-guide.md)。
## 模块概览
| 目录 | 职责 |
|------|------|
| `src/domain/` | 纯业务逻辑:`.bean` 解析器、账单管道、去重、规则引擎、OCR 处理器 |
| `src/app/` | Expo Router 文件路由(`(tabs)/` 底部导航) |
| `src/components/` | React Native UI 组件与图表 |
| `src/store/` | Zustand 状态管理ledger / import / settings / metadata / automation |
| `src/services/` | 平台服务同步、备份、安全、OCR 桥接) |
| `src/storage/` | SQLite 读缓存 + 版本化迁移 |
| `src/theme/` | Token 主题系统(浅色 / OLED 暗色) |
| `src/i18n/` | 中英文国际化 |
| `plugins/` | Expo Config Plugins 原生模块OCR、无障碍、通知、短信、截图 |
| `tests/` | Vitest 单元测试mock 后端,覆盖 domain 层) |
## 文档导航
| 文档 | 内容 |
|------|------|
| [docs/architecture.md](docs/architecture.md) | 系统架构与数据流 |
| [docs/development.md](docs/development.md) | 开发指南(环境、命令、规范、测试) |
| [docs/android-build-guide.md](docs/android-build-guide.md) | Android 打包与体积优化 |
| [docs/design/](docs/design/) | UI 重设计规格与分阶段实施计划 |
| [design-system/](design-system/beancount-mobile/MASTER.md) | 设计系统 Token 定义 |
| [AGENTS.md](AGENTS.md) | AI 编程助手贡献指南 |
| [plan.md](plan.md) | 设计决策与实施路线图(权威文档) |
## 账本约定
在主账本添加一次:
桌面主账本添加一次:
```beancount
include "mobile.bean"
include "main.bean"
```
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `mobile.bean`。导出的账本包由用户再同步到 Git、WebDAV 或 iCloud。
## 当前边界
已实现账务校验、基础 `.bean` 解析、CSV 账单规范化、去重、规则分类、转账候选和 `mobile.bean` 序列化。Tree-sitter 原生模块、真实文件选择/ZIP 导出、正式支付宝/微信/各银行字段映射、SecureStore 密钥和多设备冲突处理仍需在真机集成阶段接入。
应用不改写主账本或其他桌面维护文件;确认的交易只追加到 `main.bean`

View File

@ -1,8 +1,9 @@
{
"expo": {
"name": "Bean Mobile",
"slug": "bean-mobile",
"scheme": "beanmobile",
"name": "浮记",
"slug": "drift-ledger",
"scheme": "driftledger",
"icon": "./assets/icon/direction_a_feather.png",
"plugins": [
[
"expo-sqlite",
@ -25,10 +26,15 @@
"typedRoutes": true
},
"ios": {
"bundleIdentifier": "com.example.beanmobile"
"bundleIdentifier": "com.example.driftledger",
"icon": "./assets/icon/direction_a_feather.png"
},
"android": {
"package": "com.example.beanmobile"
"package": "com.example.driftledger",
"adaptiveIcon": {
"foregroundImage": "./assets/icon/direction_a_feather.png",
"backgroundColor": "#1B1F20"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

View File

@ -1,206 +1,72 @@
# Design System Master File
# Beancount Mobile 设计系统MASTER
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
> 修改配色/字阶/圆角时必须先改 presets.ts再同步本文件。
---
## 设计方向:明亮 Bento 现代风
**Project:** beancount-mobile
**Generated:** 2026-07-16 16:32:57
**Category:** Personal Finance Tracker
浅色为主、大圆角卡片Bento、近黑白单色 + 财务语义色点缀。
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
---
## 色板(= presets.ts
## Global Rules
### 浅色(默认)
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框 / chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | 按钮 / 选中态(近黑) |
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
| accentDark | `#000000` | 按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
| border | `#E5E7EB` | 边框 |
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
### Color Palette
### 暗色OLED
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6` |
| fgSecondary | `#9CA3AF` |
| fgInverse | `#111318` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
| border | `rgba(255,255,255,0.08)` |
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#1E40AF` | `--color-primary` |
| On Primary | `#FFFFFF` | `--color-on-primary` |
| Secondary | `#3B82F6` | `--color-secondary` |
| Accent/CTA | `#059669` | `--color-accent` |
| Background | `#0F172A` | `--color-background` |
| Foreground | `#FFFFFF` | `--color-foreground` |
| Muted | `#101A34` | `--color-muted` |
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
| Destructive | `#DC2626` | `--color-destructive` |
| Ring | `#1E40AF` | `--color-ring` |
分类/标签/渠道颜色见 `src/theme/palette.ts`(分类 12 色循环 + 具名映射、TAG_COLORS、渠道品牌色
其余 tokendivider / skeleton / progressBg / success / warning / error / info`presets.ts`
全工程颜色字面量只允许存在于 `presets.ts``palette.ts`
**Color Notes:** Trust blue + profit green on dark
## 字体
### Typography
系统字体iOS SF / Android Roboto不加载自定义字体。
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
- **Heading Font:** Caveat
- **Body Font:** Quicksand
- **Mood:** handwritten, personal, friendly, casual, warm, charming
- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap)
字阶display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap');
```
## 圆角 / 间距 / 阴影
### Spacing Variables
- 圆角sm 8 · md 12 · lg 16 · **xl 24弹窗默认卡片组件 P2 统一升级)** · full
- 间距xs 4 · sm 8 · md 16 · lg 24 · xl 32
- 阴影:浅色 412px 弥散轻阴影;暗色阴影减重,辅以半透边框
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
## 图标
### Shadow Depths
统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底CategoryIcon 组件P2 落地)。
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
## 反模式
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #059669;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #1E40AF;
border: 2px solid #1E40AF;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #0F172A;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #1E40AF;
outline: none;
box-shadow: 0 0 0 3px #1E40AF20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Dark Mode (OLED)
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
### Page Pattern
**Pattern Name:** Interactive Product Demo
- **CTA Placement:** Above fold
- **Section Order:** Hero > Features > CTA
---
## Anti-Patterns (Do NOT Use)
- ❌ Pure white backgrounds
### Additional Forbidden Patterns
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
- ❌ **Instant state changes** — Always use transitions (150-300ms)
- ❌ **Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
- 禁止瞬间状态变化,交互反馈 150300ms
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式

116
docs/architecture.md Normal file
View File

@ -0,0 +1,116 @@
# 系统架构
## 核心不变量
`.bean` 文件是唯一事实来源SQLite 仅为可丢弃的读缓存。
## 数据流
```mermaid
flowchart LR
subgraph DataSource[数据源]
A[".bean 文件"]
B["OCR / 无障碍 / 通知 / 短信"]
C[“手动录入 / CSV 导入"]
end
subgraph Pipeline[处理层]
D["BillPipeline 串行互斥锁"]
E[“转账识别"]
F[“去重"]
G[“规则分类"]
end
subgraph Storage[存储层]
H["main.bean 追加写入"]
I["SQLite 缓存"]
J["Zustand Store 内存索引"]
end
subgraph UI[展示层]
K["Expo Router 页面"]
L[“报表 / 图表"]
end
A -->|parseLedger| J
B -->|原始事件| D
C -->|TransactionDraft| D
D --> E --> F --> G
G -->|确认写入| H
H -->|reparse| J
J --> K
J --> L
I -.->|搜索查询| J
```
## 模块分层
```mermaid
graph TB
subgraph UILayer["UI 层: src/app + src/components"]
R[“Expo Router 页面"]
CO[“可复用组件"]
end
subgraph StateLayer["状态层: src/store"]
S[“Zustand Stores"]
end
subgraph DomainLayer["领域层: src/domain — 纯 TS 零 RN 依赖"]
LE["ledger.ts 解析器"]
P["billPipeline.ts"]
D2["dedup / transfer / rules"]
O["ocrProcessor.ts"]
end
subgraph Infra[“基础设施"]
FS["expo-file-system"]
DB["expo-sqlite"]
NAT["plugins/ 原生模块"]
end
R --> S
CO --> S
S --> LE
S --> P
P --> D2
P --> O
LE --> FS
D2 --> DB
O --> NAT
```
## BillPipeline 处理顺序
所有导入渠道手动、CSV、OCR、无障碍、通知、短信、截图统一经过 `BillPipeline`,严格按序执行:
1. **转账识别** — 配对收入+支出 → 单笔转账(必须先于去重)
2. **批内去重** — 时间窗口 + 金额 + 交易对手
3. **历史去重** — 按日期索引比对已提交交易
4. **规则分类** — 规则匹配 → 关键词 → `Uncategorized` 兆底
## 原生模块
原生功能以 Expo Config Plugin 形式封装在 `plugins/<name>/`,详见 [plugins/README.md](../plugins/README.md)。
原生服务**不直接写入**数据库或文件,而是通过 `NativeEventEmitter` 将原始事件推送到 JS 层管道。
## OCR 三层级联
```mermaid
flowchart LR
A[“图像输入"] --> B["Layer 1: 正则规则"]
B -->|未命中| C["Layer 2: 本地 OCR PP-OCRv6"]
C -->|未命中| D["Layer 3: AI Vision 付费"]
```
## 双轨映射
Beancount 无原生“分类/预算”概念,应用在本地 SQLite 维护 UI 元数据,写入时映射回 Beancount 语义:
| 概念 | 本地表 | 映射到 `.bean` |
|------|--------|----------------|
| 分类 | `categories` | `linkedAccount` → posting 账户 |
| 标签 | `tags` | narration 中的 `#tag` |
| 预算 | `budgets` | 仅本地,不影响余额 |
| 信用卡 | `credit_cards` | 账户在 `.bean`UI 字段本地 |

View File

@ -0,0 +1,183 @@
# Beancount Mobile 前端 UI 全面重设计 Spec
- 日期2026-07-21
- 状态:已确认(经逐节评审)
- 范围:`src/app`、`src/components`、`src/theme`、`design-system/`**不动** `src/domain`、`src/storage`、`src/services`、`plugins/`
## 1. 背景与问题
对现有 UI 的摸底发现以下问题(按严重度):
1. **品牌色混乱**:代码主题 accent = 靛蓝 `#4F46E5``design-system/beancount-mobile/MASTER.md` 规定 CTA = 绿 `#059669`、背景深蓝 `#0F172A`(代码暗色实为 OLED 纯黑 `#040508`)。设计文档与实现脱节。
2. **图标体系三套并存**Ionicons主力+ emoji 分类图标CategoryPicker+ MASTER.md 要求的 Lucide/Heroicons未落地
3. **硬编码颜色绕过 token**:首页 Hero 卡片写死白字;`CATEGORY_COLORS`、标签默认色 `#2196F3`、渠道色 `#1677FF/#07C160/#E53935`
4. **样式重复爆炸**40+ 处 `StyleSheet.create``header` 样式在 ~15 个页面重复8 个管理页(账户/分类/标签/预算/周期/规则/信用卡/备注模板)是同一"列表+FormModal"模式却各自实现。
5. **录入流程偏长**:方向 chip → 金额 → 账户 → 分类网格 → 折叠详情,不是金额优先;无 KeyboardAvoidingView日期靠手输 `YYYY-MM-DD`
6. **信息架构问题**:设置页 13 入口平铺;报表页周/月/年三套独立状态;年报内嵌月报与月 Tab 重复AI/导出图标无文字标签。
7. **Header 策略不统一**:二级页手写返回栏,唯独 transaction/new 用原生 header。
## 2. 已确认的关键决策
| 决策点 | 结论 |
|---|---|
| 重设计深度 | **全面重做**(所有页面) |
| 视觉方向 | **明亮 Bento 现代风**:浅色为主 + 大圆角黑白对比,财务语义色点缀 |
| 暗色模式 | **完整保留**OLED 纯黑,与浅色对等) |
| 记一笔交互 | **金额优先数字键盘面板**NumpadSheet |
| 底部导航 | **中央凸起 **4 个内容 Tab首页/交易/报表/我的) |
| 实施策略 | **设计系统先行,逐页替换**5 个阶段) |
## 3. 视觉语言Design Tokens
### 3.1 色板
**浅色(默认)**
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框/chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | **近黑**,按钮/选中态 |
| accentLight / accentDark | 重定义为中性色阶accentLight = accent 8% 透明度的底色选中高亮accentDark = accent 的按压加深态 | 高亮背景/按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
**暗色OLED完整对等**
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6`accent 反转为白) |
| fgSecondary | `#9CA3AF` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
原则:**accent 从靛蓝改为近黑白单色**,品牌感靠排版与财务语义色表达;对比度 ≥ 4.5:1暗色下用 1px 半透边框代替阴影。
### 3.2 字体 / 圆角 / 阴影 / 图标
- **移除 Caveat/Quicksand 自定义字体**改系统字体iOS SF / Android Roboto消除字体加载失败风险删除 `_layout.tsx` 字体加载代码。
- 金额数字统一 `fontVariant: ['tabular-nums']`
- 字阶display 34 / h1 28 / h2 22 / h3 17 / body 16 / bodySmall 14 / caption 12。
- 圆角sm 8 / md 12 / lg 16 / **xl 24卡片默认** / full。
- 阴影减重:浅色 212px 弥散阴影;暗色以边框代替。
- **图标统一 Ionicons**;全部 emoji 图标替换为 `CategoryIcon`Ionicon + 圆形彩色底)。
- 硬编码分类色收归 `theme.categoryPalette`12 色循环);渠道色、标签默认色等一并 token 化。
- `design-system/beancount-mobile/MASTER.md` 重写以匹配实现,结束"两份宪法"。
## 4. 核心交互NumpadSheet 记一笔面板
### 4.1 结构(一屏完成 90% 记账)
自上而下:方向 chip支出/收入/转账) → 大金额显示(等宽数字) → **账户 chip 行** → 分类快捷网格4 列,常用分类 + "全部" → 内嵌数字键盘0-9、小数点、⌫、**/ 连续计算**(如 20+15 直接出 35、"今天"日期键、完成键)。下滑展开"更多"抽屉备注、标签、日期、高级模式PostingEditor入口。原 SpeedDial 的 OCR/导入入口移入面板顶部工具行。
### 4.2 两个账户(复式记账的"两条腿"
| 方向 | 腿 1 | 腿 2 |
|---|---|---|
| 支出 | 资金来源账户Assets/Liabilities账户 chip 行,"从" | 分类账户Expenses:*,分类网格) |
| 收入 | 分类账户Income:*,分类网格) | 到账账户Assets账户 chip 行,"到" |
| 转账 | 转出账户chip 行) | 转入账户(第二 chip 行 + ⇅ 互换按钮),分类网格隐藏 |
配套规则:
- 账户 chip 行显示最常用的 3~4 个 Assets/Liabilities 账户(按使用频率排序),**默认选中上次使用的账户**settingsStore 持久化),分类同理;"⋯"弹出全部账户树。
- 转账双方限定 Assets/Liabilities 账户(与 transferRecognizer 校验一致);还信用卡 = 转账到 Liabilities 账户,天然支持。
- 落账走 `buildAndSaveTransaction()` → BillPipeline与手动/自动渠道完全一致,不产生第二套写入逻辑。
- 无 Assets 账户时 chip 行显示"去创建账户"引导,不死锁。
- 完成键上方可选显示分录预览 `Assets:招行 → Expenses:餐饮 ¥35`(可在设置关闭)。
- 编辑模式按 posting 方向回填两条腿。
- P3 阶段先在设置加"新版录入"开关灰度,稳定后默认开启。
## 5. 导航
自定义 **AppTabBar**(替换 expo-router 默认 tabBar4 个内容 Tab首页/交易/报表/我的)+ 中央凸起 +。+ **不是路由**,在任何页面唤起全局 ModalNumpadSheet不丢失上下文。**SpeedDial 退役**。
## 6. 核心组件库P2 交付物)
新增:
- **NumpadSheet** — 见第 4 节
- **AppTabBar** — 见第 5 节
- **ScreenHeader** — 统一二级页头(返回 + 标题 + 右操作位替换十几份手写返回栏transaction/new 的原生 header 一并撤掉
- **StatCard** — "标题 + 大数字 + caption"统计卡(首页/报表/年报共用)
- **ManagementScreen** — 管理页模板ScreenHeader+ / 可选分组 Tab / FlatList / 删除确认 / FormModal8 个管理页共用,各页只声明字段配置 + 数据读写 hooks
- **DatePickerField** — 日历选择器,终结手输 `YYYY-MM-DD`
- **FilterSheet** — 底部弹层高级筛选(账户/日期/金额区间)
- **CategoryIcon** — Ionicon + 彩色圆底,替换 emoji
改造/退役Button/Card/Chip/SearchBar 按新 token 重刷CategoryPicker 改底部弹层网格并 Ionicon 化FormModal 的 "✕" 字符换 Ioniconstransaction/new 整页重写为 NumpadSheet 宿主(编辑模式复用同面板)。
## 7. 逐页重设计要点
### 7.1 首页:从"数据墙"到"今日视角"
- 顶部:日期 + 问候语(替代应用名标题)。
- 净资产卡:去 accent 底色改白卡 + tabular 大数字,下方一行小字:本月支出 / 收入 / 预算剩余。
- 新增**待办条**:周期记账到期、信用卡还款提醒、未确认自动账单——首页回答"今天我要做什么"。
- 最近 5 条交易 + "查看全部 →"。
- 月度趋势卡TrendLine 重刷新色板)。
- 账户余额树下沉到「我的」页。
### 7.2 交易页:搜索优先 + 分组时间线
- 搜索框常驻;方向筛选 chip 保留;高级筛选收进 FilterSheet。
- 列表**按日期分组**(今天/昨天/具体日期),组头显示当日收支小计。
- TransactionCard 重刷CategoryIcon 圆底图标 + 商户/备注 + 账户小字 + 右侧等宽金额(支出黑色、收入绿色带 + 号,降低色彩噪音)。
- 左滑卡片:快捷"再记一笔(复制)/ 删除"。
- 解析诊断从页面底部移入设置 → 数据组。
### 7.3 报表页:统一时间导航 + 去重
- 周/月/年 Tab 保留三套独立状态viewYear/viewMonth/viewWeekDate合并为**单一 anchor 日期 + 周期类型**,左右箭头统一切换。
- 删除年报内嵌的 MonthlyReport与月 Tab 重复年报只留年度收支汇总、月度节奏迷你图、Top 分类。
- AI 总结/导出入口加文字标签,收进 "⋯" 菜单。
- CalendarView 保留在月 Tab配色收归 token热力图用 expense 色 5 级透明度。
- CategoryPie / StatCard / NetWorthChart 统一新 token。
### 7.4 设置 →「我的」4 分组重构
- **账户与分类**:账户树(含余额,从首页移来)、分类、标签、信用卡、备注模板。
- **记账自动化**:规则、周期记账、自动记账通道(无障碍/通知/短信)、导入。
- **数据**同步WebDAV/Git/iCloud、备份恢复、导出、解析诊断。
- **偏好**主题、语言、应用锁、AI 设置、每日提醒、关于。
- 每组一张 Bento 卡,条目 = 图标 + 名称 + 右箭头;所有二级页用统一 ScreenHeader。
### 7.5 管理页模板化
账户/分类/标签/预算/周期/规则/信用卡/备注模板 8 页统一套 ManagementScreen预计删除上千行重复代码。
## 8. 阶段计划
| 阶段 | 内容 | 验收 |
|---|---|---|
| P1 设计系统 | 新 tokens → 双主题 presets → 移除自定义字体 → categoryPalette → 重写 MASTER.md | typecheck 通过token 名不变只改值,旧组件接口兼容 |
| P2 组件库 | ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen | 组件调试页可逐个查看;纯逻辑单测 |
| P3 录入闭环 | NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役;"新版录入"开关灰度 | 手动记账/编辑/转账/还款全走面板pipeline 集成测试不回归 |
| P4 四个 Tab | 首页(待办条)/ 交易(时间线+左滑)/ 报表anchor 统一)/ 我的4 分组) | 逐页替换,每页替换后全量测试 |
| P5 管理页+收尾 | 8 页套模板图表重刷emoji 清零硬编码色清零grep 审计);无障碍标签补全 | `#[0-9A-Fa-f]{6}` 在 src/ 下只剩 presets.ts 与 categoryPalette |
## 9. 测试策略
- **不动 domain 层**billPipeline/dedup/rules 等纯逻辑零改动,现有 30+ 单测是安全网,必须保持全绿。
- 新增纯逻辑单测numpad 表达式求值(+/ 连续计算)、双腿账户解析(方向 → posting 映射、anchor 日期导航(周/月/年加减)。
- 每阶段结束跑 `npm test` + `npm run typecheck`UI 层靠深浅双主题手工走查清单。
- 硬编码色审计grep 全量扫描。
## 10. 风险与 YAGNI
风险对策:
- 主题切换过渡期"半新半旧" → P1 保持 token 名不变只改值,组件接口向后兼容。
- NumpadSheet 全新交互 → 设置开关灰度后再默认开启。
- 移除字体无数据迁移,删加载代码即可。
明确不做:自定义主题编辑器(保留 light/dark/system 三档);迁移图标库到 Lucide改 domain/存储/同步层;平板/桌面布局适配。

View File

@ -0,0 +1,678 @@
# UI 重设计 P1设计系统 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将主题系统切换到「明亮 Bento 现代风」新设计令牌(双主题),移除自定义字体,硬编码颜色收归 palette并重写 MASTER.md 使设计文档与实现一致。
**Architecture:** 只改 `src/theme/`、`src/app/_layout.tsx`(字体加载)、`src/domain/channelConfig.ts`(品牌色引用)、`src/components/CategoryPicker.tsx`(色板引用)、`design-system/`。Token 名不变只改值,组件接口向后兼容,现有页面自动"粗换皮"。
**Tech Stack:** React Native + Expo + TypeScript + Vitest。
**Spec:** `docs/ui-redesign-design.md` §3视觉语言、§10P1 验收)。
**验收标准:** `npm test` 全绿;`npm run typecheck` 通过;`src/` 下不再引用 `@expo-google-fonts/*``presets.ts` 的 typography 无 `fontFamily`
---
### Task 1: 新设计令牌的失败测试
**Files:**
- Modify: `tests/theme.test.ts`
- Create: `tests/palette.test.ts`
- [ ] **Step 1: 重写 tests/theme.test.ts 的断言以匹配新令牌**
完整替换文件内容为:
```typescript
import { describe, expect, it } from 'vitest';
import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
import { createTheme } from '../src/theme/createTheme';
import type { ThemeTokens } from '../src/theme/tokens';
describe('预置主题(明亮 Bento 设计系统)', () => {
it('lightTheme 与 darkTheme 关键色彩不同', () => {
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
});
it('浅色背景为 #F6F7F9,暗色为 OLED 纯黑 #040508', () => {
expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
expect(darkTheme.colors.bgPrimary).toBe('#040508');
});
it('accent 为近黑白单色:浅色 #111318,暗色反转为 #F3F4F6', () => {
expect(lightTheme.colors.accent).toBe('#111318');
expect(darkTheme.colors.accent).toBe('#F3F4F6');
});
it('accentLight 为 accent 的半透明底色(不再是靛蓝族)', () => {
expect(lightTheme.colors.accentLight).toBe('rgba(17,19,24,0.08)');
expect(darkTheme.colors.accentLight).toBe('rgba(243,244,246,0.10)');
});
it('财务语义色:暗色整体提亮一档', () => {
expect(lightTheme.colors.financial.income).toBe('#10B981');
expect(lightTheme.colors.financial.expense).toBe('#EF4444');
expect(lightTheme.colors.financial.transfer).toBe('#3B82F6');
expect(darkTheme.colors.financial.income).toBe('#34D399');
expect(darkTheme.colors.financial.expense).toBe('#F87171');
expect(darkTheme.colors.financial.transfer).toBe('#60A5FA');
});
it('圆角含 xl(24),卡片默认大圆角', () => {
for (const theme of [lightTheme, darkTheme]) {
expect(theme.radii.sm).toBe(8);
expect(theme.radii.md).toBe(12);
expect(theme.radii.lg).toBe(16);
expect(theme.radii.xl).toBe(24);
expect(theme.radii.full).toBe(9999);
}
});
it('字阶含 display(34),且不指定 fontFamily系统字体', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.typography.display.fontSize).toBe(34);
expect(theme.typography.h1.fontSize).toBe(28);
expect(theme.typography.h2.fontSize).toBe(22);
expect(theme.typography.h3.fontSize).toBe(17);
for (const key of ['display', 'h1', 'h2', 'h3', 'body', 'bodySmall', 'caption'] as const) {
expect(theme.typography[key].fontFamily).toBeUndefined();
}
}
});
it('presetThemes 注册表含 light 与 dark', () => {
expect(presetThemes.light).toBe(lightTheme);
expect(presetThemes.dark).toBe(darkTheme);
});
it('spacing/shadows 结构完整', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.spacing).toHaveProperty('xs');
expect(theme.spacing).toHaveProperty('xl');
expect(theme.shadows).toHaveProperty('sm');
expect(theme.shadows).toHaveProperty('lg');
}
});
});
describe('createTheme 自定义主题工厂', () => {
it('未覆盖时等价于 lightTheme', () => {
const custom = createTheme({});
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.spacing).toEqual(lightTheme.spacing);
});
it('覆盖 accent 颜色时其他颜色保留', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
expect(custom.colors.accent).toBe('#FF5722');
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.colors.success).toBe(lightTheme.colors.success);
});
it('覆盖 financial 子对象时深度合并', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
expect(custom.colors.financial.income).toBe('#000');
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense);
});
it('覆盖 spacing 部分键时其他键保留', () => {
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
expect(custom.spacing.lg).toBe(32);
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
});
});
```
- [ ] **Step 2: 新建 tests/palette.test.ts分类色板 + 渠道品牌色)**
```typescript
import { describe, expect, it } from 'vitest';
import { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from '../src/theme/palette';
describe('categoryPalette', () => {
it('色板为 12 色循环', () => {
expect(CATEGORY_PALETTE).toHaveLength(12);
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
it('内置分类有具名颜色(与原 CategoryPicker 一致)', () => {
expect(getCategoryColor('food')).toBe('#F59E0B');
expect(getCategoryColor('transport')).toBe('#3B82F6');
expect(getCategoryColor('salary')).toBe('#22C55E');
});
it('未知分类 id 走哈希循环,结果确定且在色板内', () => {
const a = getCategoryColor('user_custom_abc');
expect(CATEGORY_PALETTE).toContain(a);
expect(getCategoryColor('user_custom_abc')).toBe(a); // 幂等
});
it('空字符串 id 不崩溃', () => {
expect(CATEGORY_PALETTE).toContain(getCategoryColor(''));
});
it('渠道品牌色常量', () => {
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
});
});
```
- [ ] **Step 3: 运行测试确认失败**
Run: `npx vitest run tests/theme.test.ts tests/palette.test.ts`
Expected: FAIL —— `Cannot find module '../src/theme/palette'`,且 theme 断言多项不通过(旧色板)。
- [ ] **Step 4: Commit**
```bash
git add tests/theme.test.ts tests/palette.test.ts
git commit -m "test: P1 设计系统新令牌与色板的失败测试"
```
---
### Task 2: tokens.ts — display 字阶 + radii.xl
**Files:**
- Modify: `src/theme/tokens.ts`
- [ ] **Step 1: 修改 ThemeRadii 与 ThemeTypography**
`ThemeRadii` 改为(加 `xl`
```typescript
export interface ThemeRadii {
sm: number;
md: number;
lg: number;
xl: number; // 卡片默认大圆角24
full: number;
}
```
`ThemeTypographyEntry``fontFamily` 标记废弃(保留字段以兼容现存 `theme.typography.X.fontFamily` 引用,后续阶段逐页清理):
```typescript
export interface ThemeTypographyEntry {
fontSize: number;
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
lineHeight: number;
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
fontFamily?: string;
}
```
`ThemeTypography``display`
```typescript
export interface ThemeTypography {
display: ThemeTypographyEntry; // 34/800净资产等大数字
h1: ThemeTypographyEntry;
h2: ThemeTypographyEntry;
h3: ThemeTypographyEntry;
body: ThemeTypographyEntry;
bodySmall: ThemeTypographyEntry;
caption: ThemeTypographyEntry;
}
```
- [ ] **Step 2: 运行 typecheck 确认报错点**
Run: `npm run typecheck`
Expected: FAIL —— `presets.ts` 缺少 `display``radii.xl`Task 3 修复)。
- [ ] **Step 3: 暂不 commit与 Task 3 一起)**
---
### Task 3: presets.ts — 新双主题色板
**Files:**
- Modify: `src/theme/presets.ts`
- [ ] **Step 1: 完整替换 presets.ts**
```typescript
import type { ThemeTokens } from './tokens';
/**
* 预置主题 —— 「明亮 Bento 现代风」docs/ui-redesign-design.md §3
* 浅色为默认;暗色为 OLED 纯黑完整对等主题。
* accent 为近黑白单色:品牌感靠排版与财务语义色表达。
*/
export const lightTheme: ThemeTokens = {
colors: {
bgPrimary: '#F6F7F9',
bgSecondary: '#FFFFFF',
bgTertiary: '#EFF1F4',
fgPrimary: '#111318',
fgSecondary: '#6B7280',
fgInverse: '#FFFFFF',
accent: '#111318', // 近黑:按钮/选中态
accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
accentDark: '#000000', // 按压加深态
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6',
financial: {
income: '#10B981',
expense: '#EF4444',
transfer: '#3B82F6',
},
border: '#E5E7EB',
divider: '#F0F1F3',
overlay: 'rgba(17,19,24,0.4)',
skeleton: '#E5E7EB',
progressBg: 'rgba(17,19,24,0.06)',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
typography: {
display: { fontSize: 34, fontWeight: '800', lineHeight: 42 },
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36 },
h2: { fontSize: 22, fontWeight: '700', lineHeight: 30 },
h3: { fontSize: 17, fontWeight: '600', lineHeight: 24 },
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
},
shadows: {
sm: { shadowColor: '#111318', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4, elevation: 1 },
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
},
};
export const darkTheme: ThemeTokens = {
...lightTheme,
colors: {
bgPrimary: '#040508', // OLED 纯黑
bgSecondary: '#101218',
bgTertiary: '#1A1D24',
fgPrimary: '#F3F4F6',
fgSecondary: '#9CA3AF',
fgInverse: '#111318',
accent: '#F3F4F6', // 反转为白
accentLight: 'rgba(243,244,246,0.10)',
accentDark: '#FFFFFF',
success: '#34D399',
warning: '#FBBF24',
error: '#F87171',
info: '#60A5FA',
financial: {
income: '#34D399', // 提亮一档保证对比度
expense: '#F87171',
transfer: '#60A5FA',
},
border: 'rgba(255,255,255,0.08)',
divider: 'rgba(255,255,255,0.04)',
overlay: 'rgba(0,0,0,0.7)',
skeleton: '#1A1D24',
progressBg: 'rgba(243,244,246,0.08)',
},
shadows: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.5, shadowRadius: 4, elevation: 3 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.6, shadowRadius: 8, elevation: 5 },
},
};
/** 预置主题注册表。 */
export const presetThemes: Record<string, ThemeTokens> = {
light: lightTheme,
dark: darkTheme,
};
```
- [ ] **Step 2: 运行 Task 1 的测试**
Run: `npx vitest run tests/theme.test.ts`
Expected: PASSpalette.test.ts 仍 FAILTask 4 修复)。
- [ ] **Step 3: 运行 typecheck**
Run: `npm run typecheck`
Expected: PASS。
- [ ] **Step 4: Commit**
```bash
git add src/theme/tokens.ts src/theme/presets.ts
git commit -m "feat(theme): P1 明亮 Bento 令牌 —— 近黑 accent、xl 圆角、display 字阶、系统字体"
```
---
### Task 4: theme/palette.ts — 分类色板 + 渠道品牌色
**Files:**
- Create: `src/theme/palette.ts`
- Modify: `src/domain/channelConfig.ts:32,40,48`
- Modify: `src/components/CategoryPicker.tsx:31-48,59`
- [ ] **Step 1: 创建 src/theme/palette.ts纯 TS无 RN/domain 依赖,可被双方向引用)**
```typescript
/**
* 分类/渠道颜色统一色板docs/ui-redesign-design.md §3.2)。
*
* 纯 TS 模块,不依赖 React Native 与 domain 层,因此:
* - components/CategoryPicker 等)用它给分类配色;
* - domain/channelConfig.ts 用它引用渠道品牌色;
* 全工程的颜色字面量只允许存在于 presets.ts 与本文件P5 grep 审计)。
*/
/** 12 色循环色板:未知/用户自定义分类按 id 哈希取色。 */
export const CATEGORY_PALETTE = [
'#F59E0B', // Amber
'#3B82F6', // Blue
'#EC4899', // Pink
'#06B6D4', // Cyan
'#6366F1', // Indigo
'#8B5CF6', // Purple
'#10B981', // Emerald
'#64748B', // Slate
'#F43F5E', // Rose
'#14B8A6', // Teal
'#EF4444', // Red
'#84CC16', // Lime
] as const;
/** 内置分类的具名颜色(沿用原 CategoryPicker CATEGORY_COLORS 的映射,视觉不变)。 */
const NAMED_CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B',
transport: '#3B82F6',
shopping: '#EC4899',
housing_utility: '#06B6D4',
housing_rent: '#6366F1',
housing_communication: '#8B5CF6',
entertainment: '#10B981',
services: '#64748B',
personal_care: '#F43F5E',
clothing: '#14B8A6',
health: '#EF4444',
learning: '#84CC16',
salary: '#22C55E',
income_activity: '#EF4444',
income_investment: '#F59E0B',
};
/**
* 本地 FNV-1a 哈希。
* 注domain/ledger.ts 也有 hash(),但 theme 层不反向依赖 domain 解析器,故保留这份 8 行实现。
*/
function fnv1a(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** 取分类颜色:具名映射优先,否则按 id 哈希在 12 色板内确定性循环。 */
export function getCategoryColor(categoryId: string): string {
const named = NAMED_CATEGORY_COLORS[categoryId];
if (named) return named;
return CATEGORY_PALETTE[fnv1a(categoryId) % CATEGORY_PALETTE.length];
}
/** 渠道品牌色(供 domain/channelConfig.ts 引用)。 */
export const CHANNEL_BRAND_COLORS = {
alipay: '#1677FF',
wechat: '#07C160',
bank: '#E53935',
} as const;
```
- [ ] **Step 2: channelConfig.ts 改用品牌色常量**
文件顶部加 import
```typescript
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
```
三处字面量替换:`color: '#1677FF'` → `color: CHANNEL_BRAND_COLORS.alipay``color: '#07C160'` → `color: CHANNEL_BRAND_COLORS.wechat``color: '#E53935'` → `color: CHANNEL_BRAND_COLORS.bank`
- [ ] **Step 3: CategoryPicker.tsx 改用 getCategoryColor**
删除第 3148 行的 `CATEGORY_COLORS` 常量(含 `fallback`),文件顶部加:
```typescript
import { getCategoryColor } from '../theme/palette';
```
第 59 行 `const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;` 改为:
```typescript
const color = getCategoryColor(cat.id);
```
- [ ] **Step 4: 运行测试**
Run: `npx vitest run tests/palette.test.ts tests/channelConfig.test.ts`
Expected: PASS渠道色值未变channelConfig 测试不受影响)。
- [ ] **Step 5: Commit**
```bash
git add src/theme/palette.ts src/domain/channelConfig.ts src/components/CategoryPicker.tsx
git commit -m "feat(theme): 分类/渠道颜色收归 theme/palette消除组件与 domain 硬编码色"
```
---
### Task 5: 移除自定义字体Caveat/Quicksand
**Files:**
- Modify: `src/app/_layout.tsx:27-39,70-79,370`
- Modify: `package.json:14-15`
- [ ] **Step 1: 删除 _layout.tsx 的字体导入**
删除第 2739 行两个 `import { useFonts, ... } from '@expo-google-fonts/...'` 块。
- [ ] **Step 2: 删除 useFonts 调用**
删除第 7079 行的 `const [fontsLoaded, fontError] = useFonts({...});`
- [ ] **Step 3: 简化加载门禁**
第 370 行:
```typescript
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
```
改为:
```typescript
if (phase === 'loading') {
```
- [ ] **Step 4: 移除 package.json 依赖并重装**
删除 `package.json``"@expo-google-fonts/caveat"``"@expo-google-fonts/quicksand"` 两行,然后:
Run: `npm install --legacy-peer-deps`
Expected: lockfile 更新,无字体包残留。
- [ ] **Step 5: 验证无残留引用**
Run: `grep -rn "expo-google-fonts\|Caveat_\|Quicksand_" src/ package.json`
Expected: 无输出typography 中 `theme.typography.X.fontFamily` 的运行时引用返回 undefinedRN 回退系统字体,无需在 P1 清理)。
- [ ] **Step 6: 全量测试 + typecheck**
Run: `npm test && npm run typecheck`
Expected: 全绿。
- [ ] **Step 7: Commit**
```bash
git add src/app/_layout.tsx package.json package-lock.json
git commit -m "feat(theme): 移除 Caveat/Quicksand 自定义字体,切换系统字体"
```
---
### Task 6: commonStyles 适配新圆角
**Files:**
- Modify: `src/theme/commonStyles.ts`
- [ ] **Step 1: modalCard 圆角 lg → xl**
`modalCard``borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px)` 改为:
```typescript
borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
```
`input` 的 `theme.radii.md` 现在即 12px无需改动仅更新注释 `// 输入框标准 radii 为 md (12px)`。)
- [ ] **Step 2: 验证**
Run: `npm run typecheck`
Expected: PASS。
- [ ] **Step 3: Commit**
```bash
git add src/theme/commonStyles.ts
git commit -m "feat(theme): commonStyles 弹窗圆角升级为 xl(24)"
```
---
### Task 7: 重写 MASTER.md 使其与实现一致
**Files:**
- Modify: `design-system/beancount-mobile/MASTER.md`
- [ ] **Step 1: 完整替换 MASTER.md**
```markdown
# Beancount Mobile 设计系统MASTER
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
> 修改配色/字阶/圆角时必须先改 presets.ts再同步本文件。
## 设计方向:明亮 Bento 现代风
浅色为主、大圆角卡片Bento、近黑白单色 + 财务语义色点缀。
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
## 色板(= presets.ts
### 浅色(默认)
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框 / chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | 按钮 / 选中态(近黑) |
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
| accentDark | `#000000` | 按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
| border | `#E5E7EB` | 边框 |
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
### 暗色OLED
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6` |
| fgSecondary | `#9CA3AF` |
| fgInverse | `#111318` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
| border | `rgba(255,255,255,0.08)` |
分类/渠道颜色见 `src/theme/palette.ts`12 色循环 + 具名映射 + 渠道品牌色)。
全工程颜色字面量只允许存在于 `presets.ts``palette.ts`
## 字体
系统字体iOS SF / Android Roboto不加载自定义字体。
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
字阶display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
## 圆角 / 间距 / 阴影
- 圆角sm 8 · md 12 · lg 16 · **xl 24卡片与弹窗默认** · full
- 间距xs 4 · sm 8 · md 16 · lg 24 · xl 32
- 阴影:浅色 412px 弥散轻阴影;暗色以 1px 半透边框代替阴影
## 图标
统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底CategoryIcon 组件P2 落地)。
## 反模式
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
- 禁止瞬间状态变化,交互反馈 150300ms
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
```
- [ ] **Step 2: Commit**
```bash
git add design-system/beancount-mobile/MASTER.md
git commit -m "docs: MASTER.md 重写为与实现一致的明亮 Bento 设计系统"
```
---
### Task 8: P1 全量验收
- [ ] **Step 1: 全量测试**
Run: `npm test`
Expected: 全部通过30+ 文件,含新 theme/palette 测试)。
- [ ] **Step 2: typecheck**
Run: `npm run typecheck`
Expected: 无错误。
- [ ] **Step 3: 审计**
Run: `grep -rn "expo-google-fonts" src/ package.json` → 无输出
Run: `grep -rn "4F46E5\|EEF2FF\|3730A3" src/` → 无输出(靛蓝族清零)
已知遗留(不在 P1 处理,已在后续阶段计划内):`src/app/transaction/new.tsx:219` 的 `#2196F3`P3 重写 new.tsx 时消除);`src/app/(tabs)/index.tsx` Hero 卡硬编码白字P4 首页重写时消除);组件内 `fontFamily: theme.typography.X.fontFamily` 引用P4/P5 逐页清理)。
- [ ] **Step 4: 手工走查(需要设备/模拟器)**
Run: `npm run android`(或 expo start
走查清单:浅色/暗色各过一遍四个 Tab —— 页面背景为米白/纯黑chip 选中态为黑底白字/白底黑字;卡片圆角变大;无字体加载等待。
---
## 后续计划(不在本文件)
- **P2 组件库**ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen
- **P3 录入闭环**NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役
- **P4 四个 Tab 页**:首页待办条 / 交易时间线 / 报表 anchor 统一 / 我的 4 分组
- **P5 管理页模板化 + 清零审计**
每阶段完成后基于实际代码编写下一阶段计划。

View File

@ -0,0 +1,934 @@
# UI 重设计 P2核心组件库 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:** 建立新设计系统的核心组件库CategoryIcon去 emoji、BottomSheet、DatePickerField、ScreenHeader、StatCard、AppTabBar中央凸起、ManagementScreen 模板tag 页试点),并将 Button/Card/SearchBar/FormModal 重刷到新 token。
**Architecture:** 全部为 `src/components/` 下的新组件或既有组件改造;纯逻辑(图标映射、月历网格)抽成无 RN 依赖的独立模块以便 Vitest(node) 测试。AppTabBar 通过 expo-router Tabs 的 `tabBar` 插槽接入;+按钮 P2 暂跳 `/transaction/new`P3 换 NumpadSheet。
**Tech Stack:** React Native + Expo Router + Ionicons + Vitest。
**Spec:** `docs/ui-redesign-design.md` §5导航、§6组件库。**与 spec 的偏差**FilterSheet 移至 P4其形态依赖交易页筛选状态P4 一并设计CategoryPicker 的"改底部弹层"移至 P3随 NumpadSheet 一起设计P2 仅完成 Ionicon 化FormModal 增加 `children` 支持ManagementScreen 的颜色选择器需要)。
**前置状态:** P1 已完成(新 tokens/presets/palette 已生效系统字体538 测试全绿)。
---
### Task 1: 组件纯逻辑的失败测试
**Files:**
- Create: `tests/components-p2.test.ts`
- [ ] **Step 1: 新建 tests/components-p2.test.ts**
```typescript
import { describe, expect, it } from 'vitest';
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
import { buildMonthGrid } from '../src/components/dateGrid';
describe('getCategoryIcon', () => {
it('15 个内置分类都有具名图标', () => {
const builtin = [
'food', 'transport', 'shopping', 'housing_utility', 'housing_rent',
'housing_communication', 'entertainment', 'services', 'personal_care',
'clothing', 'health', 'learning', 'salary', 'income_activity', 'income_investment',
];
for (const id of builtin) {
const icon = getCategoryIcon(id);
expect(typeof icon).toBe('string');
expect(icon.length).toBeGreaterThan(0);
}
});
it('未知分类返回 fallback 图标', () => {
expect(getCategoryIcon('whatever_custom')).toBe('pricetag-outline');
});
it('原型链属性名 id 不穿透(返回 fallback', () => {
expect(getCategoryIcon('constructor')).toBe('pricetag-outline');
});
it('所有图标名以 -outline 结尾(风格统一)', () => {
for (const name of Object.values(CATEGORY_ICON_NAMES)) {
expect(name).toMatch(/-outline$/);
}
});
});
describe('buildMonthGrid', () => {
it('固定 6 行 × 7 列', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid).toHaveLength(6);
for (const week of grid) expect(week).toHaveLength(7);
});
it('2026-07首日周三周一开头填充 6 月末两天', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid[0][0]).toEqual({ date: '2026-06-29', inMonth: false });
expect(grid[0][1]).toEqual({ date: '2026-06-30', inMonth: false });
expect(grid[0][2]).toEqual({ date: '2026-07-01', inMonth: true });
expect(grid[5][6]).toEqual({ date: '2026-08-09', inMonth: false });
});
it('2026-02首日周日填充 1 月最后 6 天', () => {
const grid = buildMonthGrid(2026, 2);
expect(grid[0][0].date).toBe('2026-01-26');
expect(grid[0][6]).toEqual({ date: '2026-02-01', inMonth: true });
});
it('首日恰为周一时无跨月填充2026-06', () => {
const grid = buildMonthGrid(2026, 6);
expect(grid[0][0]).toEqual({ date: '2026-06-01', inMonth: true });
});
it('inMonth 标记与当月天数一致2026-07 共 31 天)', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid.flat().filter(c => c.inMonth)).toHaveLength(31);
});
});
```
- [ ] **Step 2: 运行确认失败**
Run: `npx vitest run tests/components-p2.test.ts`
Expected: FAIL —— `Cannot find module '../src/components/categoryIcons'`。**若意外通过,报告 BLOCKED。**
---
### Task 2: categoryIcons + CategoryIcon + CategoryPicker 去 emoji
**Files:**
- Create: `src/components/categoryIcons.ts`
- Create: `src/components/CategoryIcon.tsx`
- Modify: `src/components/CategoryPicker.tsx`
- [ ] **Step 1: 创建 src/components/categoryIcons.ts**
```typescript
/**
* 分类 id → Ionicons 图标名映射(替换原 CategoryPicker 的 emoji 表)。
* 纯数据模块:仅含类型导入(运行时零依赖),可在 Vitest(node) 中直接测试。
*/
import type { ComponentProps } from 'react';
import type { Ionicons } from '@expo/vector-icons';
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
food: 'fast-food-outline',
transport: 'bus-outline',
shopping: 'bag-handle-outline',
housing_utility: 'water-outline',
housing_rent: 'home-outline',
housing_communication: 'call-outline',
entertainment: 'game-controller-outline',
services: 'construct-outline',
personal_care: 'sparkles-outline',
clothing: 'shirt-outline',
health: 'medkit-outline',
learning: 'book-outline',
salary: 'wallet-outline',
income_activity: 'gift-outline',
income_investment: 'trending-up-outline',
};
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
/** 取分类图标:具名映射,否则 fallbackhasOwn 防原型链穿透)。 */
export function getCategoryIcon(categoryId: string): IoniconName {
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
? CATEGORY_ICON_NAMES[categoryId]
: FALLBACK_ICON;
}
```
- [ ] **Step 2: 创建 src/components/CategoryIcon.tsx**
```tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getCategoryColor } from '../theme/palette';
import { getCategoryIcon } from './categoryIcons';
interface CategoryIconProps {
categoryId: string;
/** 圆形底色直径,默认 36。 */
size?: number;
}
/** 分类图标Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji。 */
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
const color = getCategoryColor(categoryId);
return (
<View
style={[styles.circle, {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color + '1A',
}]}
>
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
</View>
);
}
const styles = StyleSheet.create({
circle: { alignItems: 'center', justifyContent: 'center' },
});
```
- [ ] **Step 3: CategoryPicker.tsx 接入 CategoryIcon**
- 删除整个 `CATEGORY_ICONS` 常量(第 1330 行)。
- 顶部加 `import { CategoryIcon } from './CategoryIcon';``getCategoryColor` 的 import 保留)。
- 删除 `const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;` 一行。
- JSX 中 `<Text style={[styles.icon, { color }]}>{icon}</Text>` 替换为 `<CategoryIcon categoryId={cat.id} size={32} />`
- 删除 label 样式里的 `fontFamily: theme.typography.caption.fontFamily,` 一行。
- 删除 styles 中的 `icon` 条目。
- 注意:`const color = getCategoryColor(cat.id);` 保留active 底色仍用)。
- [ ] **Step 4: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → categoryIcons 相关 4 条 PASSdateGrid 仍 FAILTask 3 修复)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 3: dateGrid 月历网格纯逻辑
**Files:**
- Create: `src/components/dateGrid.ts`
- [ ] **Step 1: 创建 src/components/dateGrid.ts**
```typescript
/**
* 月历网格生成(周一开头,固定 6 行×7 列,含前后月填充)。
* 纯 TS无 RN 依赖,供 DatePickerField / CalendarView 复用。
*/
export interface DayCell {
/** YYYY-MM-DD本地时区。 */
date: string;
inMonth: boolean;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
function fmt(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** 生成指定年月的月历网格。month 为 1-12。 */
export function buildMonthGrid(year: number, month: number): DayCell[][] {
const first = new Date(year, month - 1, 1);
// 周一为一周起点getDay() 周日=0 → 偏移 (day+6)%7
const offset = (first.getDay() + 6) % 7;
const cursor = new Date(year, month - 1, 1 - offset);
const weeks: DayCell[][] = [];
for (let w = 0; w < 6; w++) {
const week: DayCell[] = [];
for (let d = 0; d < 7; d++) {
week.push({ date: fmt(cursor), inMonth: cursor.getMonth() === month - 1 });
cursor.setDate(cursor.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
```
- [ ] **Step 2: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → Expected: 全部 9 条 PASS
---
### Task 4: BottomSheet 通用底部弹层
**Files:**
- Create: `src/components/BottomSheet.tsx`
- [ ] **Step 1: 创建 src/components/BottomSheet.tsx**
```tsx
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
export function BottomSheet({ visible, onClose, title, children }: BottomSheetProps) {
const { theme } = useTheme();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
{title ? (
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
) : null}
{children}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
title: { marginTop: 8, marginBottom: 12 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 5: DatePickerField 日期选择字段
**Files:**
- Create: `src/components/DatePickerField.tsx`
- [ ] **Step 1: 创建 src/components/DatePickerField.tsx**
```tsx
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { BottomSheet } from './BottomSheet';
import { buildMonthGrid } from './dateGrid';
interface DatePickerFieldProps {
/** YYYY-MM-DD空串表示未选。 */
value: string;
onChange: (date: string) => void;
placeholder?: string;
}
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD。 */
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
const openPicker = () => {
const y = Number(value.slice(0, 4));
const m = Number(value.slice(5, 7));
if (y) {
setViewYear(y);
setViewMonth(m || 1);
}
setOpen(true);
};
const shiftMonth = (delta: number) => {
const m = viewMonth - 1 + delta;
setViewYear(viewYear + Math.floor(m / 12));
setViewMonth(((m % 12) + 12) % 12 + 1);
};
const pick = (date: string) => {
onChange(date);
setOpen(false);
};
return (
<>
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button">
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
{value || placeholder || 'YYYY-MM-DD'}
</Text>
</Pressable>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
{/* 月份导航 */}
<View style={styles.navRow}>
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
{viewYear}-{String(viewMonth).padStart(2, '0')}
</Text>
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
</Pressable>
</View>
{/* 星期头 */}
<View style={styles.weekRow}>
{WEEKDAYS.map(w => (
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
))}
</View>
{/* 日期网格 */}
{grid.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const selected = cell.date === value;
return (
<Pressable
key={cell.date}
onPress={() => pick(cell.date)}
style={[styles.dayCell, {
backgroundColor: selected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.full,
}]}
>
<Text style={[theme.typography.bodySmall, {
color: selected
? theme.colors.fgInverse
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
opacity: cell.inMonth ? 1 : 0.5,
fontVariant: ['tabular-nums'],
}]}>
{Number(cell.date.slice(8, 10))}
</Text>
</Pressable>
);
})}
</View>
))}
</BottomSheet>
</>
);
}
const styles = StyleSheet.create({
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
weekRow: { flexDirection: 'row' },
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 6: ScreenHeader + StatCard
**Files:**
- Create: `src/components/ScreenHeader.tsx`
- Create: `src/components/StatCard.tsx`
- [ ] **Step 1: 创建 src/components/ScreenHeader.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
interface ScreenHeaderProps {
title: string;
/** 右侧操作位(图标按钮/文本按钮)。 */
right?: React.ReactNode;
/** 自定义返回行为;默认 router.back()。 */
onBack?: () => void;
/** 返回按钮的无障碍标签。 */
backLabel?: string;
}
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header。 */
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
const { theme } = useTheme();
const router = useRouter();
return (
<View style={styles.row}>
<Pressable
onPress={onBack ?? (() => router.back())}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={backLabel}
>
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{title}
</Text>
{right ?? <View style={styles.rightPlaceholder} />}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
title: { flex: 1 },
rightPlaceholder: { width: 24 },
});
```
- [ ] **Step 2: 创建 src/components/StatCard.tsx**
```tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface StatCardProps {
label: string;
value: string;
caption?: string;
/** 默认 fgPrimary收支场景传 financial.income/expense。 */
valueColor?: string;
}
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
const { theme } = useTheme();
return (
<View
style={[styles.card, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.xl,
borderColor: theme.colors.border,
}, theme.shadows.sm]}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
<Text
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
numberOfLines={1}
>
{value}
</Text>
{caption ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
});
```
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 7: AppTabBar 中央凸起+导航
**Files:**
- Create: `src/components/AppTabBar.tsx`
- Modify: `src/app/(tabs)/_layout.tsx`
- [ ] **Step 1: 创建 src/components/AppTabBar.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useTheme } from '../theme';
import type { IoniconName } from './categoryIcons';
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
const TAB_ICONS: Record<string, IoniconName> = {
index: 'home-outline',
transactions: 'list-outline',
report: 'pie-chart-outline',
settings: 'settings-outline',
};
/**
* 自定义底部导航4 个内容 Tab + 中央凸起+。
* 不是路由——P2 暂跳转 /transaction/newP3 改为唤起全局 NumpadSheet。
*/
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const renderTab = (route: (typeof state.routes)[number], index: number) => {
const focused = state.index === index;
const options = descriptors[route.key].options;
const label = (options.title ?? route.name) as string;
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
};
return (
<Pressable
key={route.key}
onPress={onPress}
style={styles.tab}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={label}
>
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
</Pressable>
);
};
return (
<View
style={[styles.bar, {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
paddingBottom: Math.max(insets.bottom, 8),
}]}
>
{state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}>
<Pressable
onPress={() => router.push('/transaction/new')}
style={[styles.plus, {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.bgPrimary,
}, theme.shadows.lg]}
accessibilityRole="button"
accessibilityLabel="记一笔"
>
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
</Pressable>
</View>
{state.routes.slice(2).map(renderTab)}
</View>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
tabLabel: { fontSize: 10, fontWeight: '600' },
plusSlot: { width: 64, alignItems: 'center' },
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
});
```
- [ ] **Step 2: 接入 (tabs)/_layout.tsx**
- 顶部加 `import { AppTabBar } from '../../components/AppTabBar';`,删除 `import { Ionicons } from '@expo/vector-icons';`(不再需要)。
- 第 7 行注释改为 `/** 底部 Tab 导航:首页/交易/报表/设置 + 中央AppTabBar。 */`
- `Tabs` 组件加 prop`tabBar={(props) => <AppTabBar {...props} />}`。
- 删除 4 个 `Tabs.Screen` 里的 `tabBarIcon` 属性(图标由 AppTabBar 的 TAB_ICONS 决定),`title` 保留。
- screenOptions 中 `tabBarActiveTintColor` / `tabBarInactiveTintColor` / `tabBarStyle` 可删除(自定义 tabBar 不用),仅保留 `headerShown: false`
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 8: FormModal children 支持 + ManagementScreen + tag 页试点
**Files:**
- Modify: `src/components/FormModal.tsx`
- Create: `src/components/ManagementScreen.tsx`
- Modify: `src/app/tag/index.tsx`(完整重写)
- [ ] **Step 1: FormModal 增加 children + ✕ 换图标 + 圆角 xl**
- `FormModalProps` 加字段:
```typescript
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode;
```
- 函数签名解构加 `children`;在 `</ScrollView>` 之后、`<View style={styles.actions}>` 之前插入 `{children}`
- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `<Text ...>✕</Text>` 替换为 `<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />`
- sheet 的 `borderRadius: theme.radii.lg` 改为 `theme.radii.xl`
- [ ] **Step 2: 创建 src/components/ManagementScreen.tsx**
```tsx
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal';
/**
* 管理页模板P2统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
* 各管理页只声明字段配置与数据读写,不再复制 header/Alert/Modal 样板。
*/
interface ManagementScreenProps<T> {
title: string;
items: T[];
keyExtractor: (item: T) => string;
/** 渲染单个条目handlers.openEdit 打开编辑弹窗handlers.confirmDelete 弹删除确认。 */
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
addLabel: string;
emptyText?: string;
formTitle: (editing: T | null) => string;
formFields: (editing: T | null) => FormField[];
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
onDelete: (item: T) => void;
deleteConfirmText: (item: T) => string;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
onOpenForm?: (editing: T | null) => void;
/** 列表上方的额外内容(分组 Tab 等)。 */
headerContent?: React.ReactNode;
footer?: React.ReactNode;
}
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
const { theme } = useTheme();
const t = useT();
const [editing, setEditing] = useState<T | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openForm = (item: T | null) => {
setEditing(item);
props.onOpenForm?.(item);
setModalOpen(true);
};
const confirmDelete = (item: T) => {
Alert.alert(t('common.delete'), props.deleteConfirmText(item), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
]);
};
const handleSubmit = (values: Record<string, string>) => {
if (props.onSubmit(values, editing)) setModalOpen(false);
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader
title={props.title}
right={
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} />
</Pressable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{props.headerContent}
{props.items.length === 0 && props.emptyText ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
) : null}
{props.items.map(item => (
<View key={props.keyExtractor(item)}>
{props.renderItem(item, {
openEdit: () => openForm(item),
confirmDelete: () => confirmDelete(item),
})}
</View>
))}
{props.footer}
</ScrollView>
<FormModal
visible={modalOpen}
title={props.formTitle(editing)}
fields={props.formFields(editing)}
onConfirm={handleSubmit}
onCancel={() => setModalOpen(false)}
>
{props.formExtra?.(editing)}
</FormModal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, gap: 12 },
});
```
- [ ] **Step 3: 完整重写 src/app/tag/index.tsxManagementScreen 试点)**
```tsx
/**
* 标签管理页面 —— ManagementScreen 模板试点P2
*
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
*/
import React, { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { ManagementScreen } from '../../components/ManagementScreen';
import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/tags';
export default function TagScreen() {
const { theme } = useTheme();
const t = useT();
const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag);
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
return (
<ManagementScreen<Tag>
title={t('tag.title')}
items={tags}
keyExtractor={tag => tag.id}
addLabel={t('tag.add')}
emptyText={t('tag.empty')}
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
renderItem={(tag, { openEdit, confirmDelete }) => (
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
)}
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
]}
formExtra={() => (
<View style={styles.colorRow}>
{TAG_COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
)}
onSubmit={(values, editing) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return false;
}
if (editing) updateTag(editing.id, { name, color: selectedColor });
else addTag({ id: generateId('tag'), name, color: selectedColor });
return true;
}}
onDelete={tag => removeTag(tag.id)}
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
chip: { alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 },
});
```
- [ ] **Step 4: 验证**
Run: `npm test` → Expected: 全部通过tag 页逻辑变化不影响单测,但全量跑一遍防回归)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 9: Button / Card / SearchBar 重刷
**Files:**
- Modify: `src/components/Button.tsx`
- Modify: `src/components/Card.tsx`
- Modify: `src/components/SearchBar.tsx`
- [ ] **Step 1: Button.tsx**
- `borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- 删除 Text 样式中的 `fontFamily: theme.typography.body.fontFamily`(系统字体)。
- [ ] **Step 2: Card.tsx**
- cardStyle 中 `borderRadius: theme.radii.lg,` 改为 `borderRadius: theme.radii.xl,`
- [ ] **Step 3: SearchBar.tsx**
- `borderRadius: theme.radii.lg, // 升级为 lg 圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → Expected: 全部通过
---
### Task 10: P2 全量验收
- [ ] **Step 1: 全量测试 + typecheck**
Run: `npm test` → Expected: 全部通过(含 components-p2 的 9 条新测试)
Run: `npm run typecheck` → Expected: 无错误
- [ ] **Step 2: 审计**
Run: `grep -rn "🍔\|🚗\|🛍\|💧\|🏠\|📞\|🎮\|⚙️\|💅\|👕\|🏥\|📚\|💰\|🧧\|📈\|🏷" src/` → Expected: 无输出(分类 emoji 清零)
Run: `grep -n "fontFamily" src/components/Button.tsx src/components/CategoryPicker.tsx` → Expected: 无输出
Run: `grep -n "✕" src/components/` → Expected: 无输出
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 底部导航为自定义 bar中央黑色凸起4 个 tab 可切换
2. → 跳转记一笔页P2 占位行为)
3. 记一笔页分类网格:图标为 Ionicons 圆底彩色,无 emoji
4. 标签管理页:新 ScreenHeader +号新增、点按编辑、长按删除、颜色选择器在弹窗内正常工作
5. 任意 FormModal关闭按钮为 Ionicons 图标而非 ✕ 字符
---
## 后续计划(不在本文件)
- **P3 录入闭环**NumpadSheet + 双腿账户选择 + AppTabBar 改全局唤起 + transaction/new 重写 + SpeedDial 退役
- **P4 四个 Tab 页**:首页待办条 / 交易时间线FilterSheet 在此设计)/ 报表 anchor 统一 / 我的 4 分组
- **P5 管理页全面模板化**tag 页为已验证模板)+ 清零审计

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,233 @@
# UI 重设计 P5管理页模板化 + 清零收尾 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:** 7 个管理页套 ManagementScreen 模板、account 页与 3 个设置二级页统一 ScreenHeader、图表/fontFamily/emoji/星期头清理、`numpadGlobalEntry` 翻默认(含 modal 未保存守卫)、清零审计验收。
**Spec:** `docs/ui-redesign-design.md` §7.5/§8。**已确认现状**2026-07-22 审计hex 硬编码已清零(`#[0-9A-Fa-f]{6}` 在 src/ 仅剩 presets.ts/palette.tsfontFamily 残留 14 处emoji 5 处monthlySummary.ts 4 + ai/chat.tsx 1tag 页已是模板试点。
**与 spec 的偏差**①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
**模板适配决策**(已通读 7 页源码):
- budget/rules/remark-template → 直套T1
- category → `headerContent` 放支出/收入 chips新增按当前类型T2
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete不用长按T2
- credit-card → 富 renderItem账单盒直套T2
---
### Task 1: 管理页模板化批次 1budget / rules / remark-template
**Files:**
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`
通用映射:页面 state 消失modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`
**budget/index.tsx**
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
- renderItem保留现有 Card + 进度条结构Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
- formFields5 字段name/amount decimal-pad/period/categoryAccount/startDatedefaultValue 从 editing 取
- onSubmit保留校验amount parseFloat ≤ 0 → Alert + return falseadd/edit 分支调 addBudget/updateBudget
- onDelete`removeBudget(budget.id)`deleteConfirmText`t('budget.deleteConfirm', { name: budget.name })`
**rules/index.tsx**
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
- renderItem保留规则行narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
- formFields7 字段priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags
- onSubmit保留现有 Rule 组装逻辑addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
**remark-template/index.tsx**
- 2 字段name/templaterenderItem 的模板文本 `fontFamily: 'monospace'``fontVariant: ['tabular-nums']`(审计项)
- onSubmitname 空回退 `t('common.untitled')`
- [ ] **Step 2: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/index.tsx src/app/remark-template/index.tsx` → 无输出
---
### Task 2: 管理页模板化批次 2category / recurring / credit-card
**Files:**
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
- 保留一个本地 state`const [catType, setCatType] = useState<'expense' | 'income'>('expense')`
- `headerContent`:两个 chip支出/收入commonStyles.chip/chipActive切换 setCatType
- `items={categories.filter(c => c.type === catType)}`
- `formTitle`editing ? `t('category.editTitle')` : `t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })`(插值语法以现有键为准,先 grep category.addTitle
- formFields3 字段name/linkedAccount/keywords编辑时带 defaultValue
- onSubmitadd 时 `type: catType`linkedAccount 空回退按类型Income:/Expenses:Uncategorizedkeywords 拆分逻辑保留edit 保留原逻辑
- renderItem保留名称/linkedAccount/关键词行
- [ ] **Step 2: recurring/index.tsx — 显式按钮用 handlers**
- renderItem保留信息行 Card卡片底部两个按钮编辑/删除)分别调 `handlers.openEdit()` / `handlers.confirmDelete()`**不**用长按,交互更显式);外层 Pressable 去掉
- formFields6 字段;`new Date().toISOString().slice(0, 10)` 两处 → `toDateString(new Date())`
- onSubmit保留校验/draft 组装/成功 Alert返回 true校验失败 Alert 后 return false
- 成功 Alert 在 onSubmit 内setModal(null) 由模板接管——删除手动 setModal 调用)
- [ ] **Step 3: credit-card/index.tsx — 富 renderItem 直套**
- accountBalances useMemo 保留在页面顶层
- renderItem保留整张卡银行/账单日/还款日/额度/账单盒Pressable onPress={openEdit} onLongPress={confirmDelete}
- formFields8 字段onSubmit 保留组装逻辑
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurring/index.tsx src/app/credit-card/index.tsx` → 无输出
---
### Task 3: ScreenHeader 统一account + settings 三页)
**Files:**
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
- [ ] **Step 1: account/index.tsx**
手写 headerarrow-back + title替换为 `<ScreenHeader title={t('account.title')} />`(组件保持其余逻辑不变:类型 Tab/开户 FormModal/调余额 FormModal/关户 Alert删除 styles.header。调余额表单的 date 默认值 `new Date().toISOString().slice(0, 10)``toDateString(new Date())`
- [ ] **Step 2: settings/sync.tsx、ai.tsx、preferences.tsx**
三页手写 header约在 sync.tsx:460、ai.tsx:51、preferences.tsx:44替换为 ScreenHeader标题沿用原文案键删除对应 styles.header 与不再使用的 importIonicons/router 若仅 header 使用。sync.tsx 546 行,只动 header 块,其余不动。
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
---
### Task 4: 图表与杂项清理fontFamily / emoji / 星期头 i18n
**Files:**
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: fontFamily 清零**
- `theme.typography.X.fontFamily` 引用CategoryPie 2 处、TrendLine 4 处、ai/chat.tsx 1 处直接删除该属性P1 后 typography 无 fontFamily 字段,这些是无效引用)
- `fontFamily: 'monospace'`
- 金额/数字场景CategoryPie:35、transaction/[id].tsx:314、PostingEditor:63、import/index.tsx:391`fontVariant: ['tabular-nums']`
- 纯文本场景_error.tsx:70 堆栈文本)→ 保留 monospace堆栈对齐需要或删除——保留加注释说明
- NetWorthChart/CalendarHeatmap 顺便检查一遍grep 未命中但过一眼 token 合规)
- [ ] **Step 2: emoji 清零**
- `src/ai/monthlySummary.ts:81-85`:剥离 `📊/💰/💸/📝` 前缀,保留纯文本行(如 `'总收入:...'`
- `src/app/ai/chat.tsx:123`:方向 emoji💰/🔄/💸替换为符号文本income `''`、transfer `'⇄'`、expense `''`(或直接金额上色——读上下文选与周边一致的处理;金额色用 theme.colors.financial.*
- [ ] **Step 3: 星期头 i18n3 处硬编码中文)**
- i18n 加键zh/en 对等,单键逗号分隔):`'datepicker.weekdays': '一,二,三,四,五,六,日'` / `'Mo,Tu,We,Th,Fr,Sa,Su'`周一起DatePickerField 用);`'calendar.weekdays': '日,一,二,三,四,五,六'` / `'Su,Mo,Tu,We,Th,Fr,Sa'`周日起CalendarView/CalendarHeatmap 用)
- `DatePickerField.tsx`、`CalendarView.tsx`、`CalendarHeatmap.tsx` 的 WEEKDAYS 常量改为 `t('datepicker.weekdays').split(',')` / `t('calendar.weekdays').split(',')`(组件内 useT注意保持周一/周日起始顺序与各自网格一致,不要换序)
- [ ] **Step 4: NumpadKeyboard backspace 标签 i18n**
- i18n 加键:`'numpad.backspace': '退格'` / `'Backspace'`
- NumpadKeyboard props 加 `backspaceLabel: string`backspace 键 accessibilityLabel 用它;`NumpadSheet.tsx` 调用处传 `t('numpad.backspace')`
- [ ] **Step 5: 验证**
Run: `npm run typecheck && npm test` → 全绿i18n 对等校验覆盖新键)
Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` → 无输出
---
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
**Files:**
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
- Delete: `src/components/charts/MonthlyReport.tsx`
- [ ] **Step 1: numpadGlobalEntry 默认翻 true**
`src/store/settingsStore.ts` DEFAULTS 中 `numpadGlobalEntry: false``true`。**注意**:已持久化 false 的用户不受影响hydrate 合并优先级),仅新安装生效——在代码注释中写明。
- [ ] **Step 2: modal 模式未保存守卫(翻默认的前置)**
- `NumpadSheet.tsx`:加可选 prop `onDirtyChange?: (dirty: boolean) => void`;把 beforeRemove 守卫里的 dirty 计算抽成 `computeDirty()`(读 formRef`useEffect(() => { onDirtyChange?.(computeDirty()); })`(每次渲染后上报,轻量)
- `NumpadSheetHost.tsx``const dirtyRef = useRef(false)`NumpadSheet 传 `onDirtyChange={d => { dirtyRef.current = d; }}`overlay onPress 与 Modal onRequestClose 改调 `requestClose`
```typescript
const requestClose = () => {
if (!dirtyRef.current) { close(); return; }
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.discard'), style: 'destructive', onPress: close },
]);
};
```
Host 需 useT + Alert importcommon.discard 键先 grep 确认存在。)
- [ ] **Step 3: 删除 MonthlyReport.tsx**P4 确认无引用的死文件)
Run: `rm src/components/charts/MonthlyReport.tsx`,再 `grep -rn "MonthlyReport" src/` → 无输出
- [ ] **Step 4: budgets.ts getPeriodRange 本地时区修复**
`src/domain/budgets.ts``getPeriodRange`(约 93 行):`new Date('YYYY-MM-DD')` 是 UTC 解析,负时区会把边界日算到前一天。改为 split 构造本地 Date`const [y, m, d] = dateStr.split('-').map(Number); new Date(y, m - 1, d)`),与 periodNav 的 parse 一致。在现有 budget 测试文件(`ls tests/ | grep -i budget`)追加边界用例:`getPeriodRange('monthly', '2026-07-01')` 的 start 必须是 `'2026-07-01'`、end `'2026-07-31'``getPeriodRange('weekly', ...)`/`yearly` 各补一例(先读现有测试风格与函数实际行为写期望值——若修复改变了现有行为导致旧测试失败,停止报告 BLOCKED
- [ ] **Step 5: handleDuplicate 带 tags**
`src/app/(tabs)/transactions.tsx` 的 handleDuplicate draftJson 加 `tags: tx.tags`;同时检查 `NumpadSheet.tsx` 的 draftJson 预填 effect——目前不解析 tags参照 editId 预填的 tags 回填逻辑tags store 查名 → fallback TAG_COLORS[3])补上 draft.tags 解析(`Array.isArray(draft.tags)` 时。cost/price 不带hasExtras 逻辑已保证含 cost 交易从编辑入口进高级模式;复制是新建,丢 cost 属可接受简化,注释说明)。
- [ ] **Step 6: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 6: P5 清零审计 + 全项目验收
- [ ] **Step 1: 清零审计(全部应无输出或仅剩允许项)**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rnE "#[0-9A-Fa-f]{3}\b" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/ --include="*.tsx" # 仅 _error.tsx monospace
grep -rn "📊\|💰\|💸\|📝\|🔄\|🎉\|✨" src/ --include="*.ts" --include="*.tsx" # 无输出
grep -rn "MonthlyReport\|SpeedDial" src/ # 无输出
grep -rn "arrow-back" src/app/ # 无输出ScreenHeader 内部除外)
grep -rn "WEEKDAYS" src/components/ # 无输出(全部走 i18n
```
- [ ] **Step 2: 全量测试 + typecheck**
Run: `npm test` → 全绿
Run: `npm run typecheck` → 无错误
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板新增、点按编辑、删除recurring 为显式按钮,其余长按)、空态文案
2. account 页:类型 Tab、开户、调余额、关户
3. settings/sync、ai、preferencesheader 统一有返回箭头
4. **+按钮默认开全局面板**(新装/清数据后):任意页面弹 modal输入金额后点遮罩/Android 返回 → 弹「放弃修改」确认;无输入直接关
5. 设置→偏好里开关关闭 → +回到跳整页
6. 交易页左滑复制带标签的交易 → 标签预填
7. 报表月 Tab 日历、年报节奏图、TrendLine/CategoryPie 显示正常
8. 英文语言:日期选择器/日历星期头为英文缩写
---
## 完成后的项目状态
P1P5 全部交付spec §8 验收标准全达成。剩余已知非阻塞项不修记录TransactionCard memo 被内联 onPress 击穿性能todayStr useMemo 跨午夜不刷新NumpadSheet page 模式无可见返回按钮spec 允许_error.tsx monospace 保留(堆栈对齐)。

View File

@ -0,0 +1,317 @@
# 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()` → 原生存 SharedPreferencesJSON→ 三个浮层组件读取。原生保留现有硬编码值作为**默认值兜底**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 accentlight #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"` 六位数 hextheme tokens 里有 rgba 的字段不要直接下发——accent/cardBg/fg/border 等都是纯色,确认无 rgbaborder 深色主题是 `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()`),文字色 → accentFgProgressBar `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、圆角 12dpconfirm=accent 底 accentFg 字 boldopenApp/dismiss=inputBg 底 fgPrimary 字
- 所有颜色经 `FloatingUiConfigStore.load(context)` + parseColorOr 兜底;所有文案经 labels
- [ ] **Step 1: 构造签名 + showFloatingBill 参数**
`FloatingBillView` 构造加 `initialCurrency: String = "CNY"``AccessibilityBridgeModule.showFloatingBill` 加 `currency: String` 参数direction 后 draftId 前)并透传。
- [ ] **Step 2: 币种 chip**
金额行:水平布局,左侧币种 chipinputBg+borderfgPrimary 字,显示 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` 保留为语义色(扫描红光),不受主题控制。

View File

@ -0,0 +1,192 @@
# P7权限快捷引导 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax. **不执行任何 git 操作**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 引导页权限步骤从纯文字改可操作状态清单automation 页补齐通知监听/短信/存储三项权限的状态检测与快速跳转按钮。不新增原生权限,只加 UI 入口和一座通知监听状态检测 bridge 方法。
---
## 背景决策
- **不新增权限**。所有权限已在 AndroidManifest 中声明(通过 Config PluginP7 只加 UI 入口。
- **通知监听状态检测**需要一个原生 bridge 方法(`isNotificationListenerEnabled`),因为 RN 侧无法直接检查。用 `NotificationManager.getEnabledListenerPackages()`
- **短信 + 存储**用 React Native 内置 `PermissionsAndroid` API。
- **引导页 UI** 沿用现有 Card + Button 组件,与 P1-P5 设计系统一致。
---
### Task 1: 新增桥接方法 `isNotificationListenerEnabled`
**Files:**
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`
- Modify: `src/services/accessibilityBridge.ts`
- [ ] **Step 1: AccessibilityBridgeModule.kt 加方法**
```kotlin
@ReactMethod
fun isNotificationListenerEnabled(promise: Promise) {
try {
val pm = reactContext.packageManager
val enabledListeners = android.app.NotificationManager::class.java
.getMethod("getEnabledListenerPackages")
.invoke(reactContext.getSystemService(Context.NOTIFICATION_SERVICE)) as? List<String>
val myPkg = reactContext.packageName
promise.resolve(enabledListeners?.any { it.startsWith(myPkg) } == true)
} catch (e: Exception) {
promise.resolve(false)
}
}
```
**注意**`getEnabledListenerPackages()` 在 Android 11+ 需 `isNotificationListenerEnabled(ComponentName)`;优先用反射避免 API level lint 错误,反射失败返回 false。
- [ ] **Step 2: JS 侧 bridge 接口**
`NativeAccessibilityBridge``isNotificationListenerEnabled(): Promise<boolean>;`
- [ ] **Step 3: 编译验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
---
### Task 2: 引导页权限步骤(`_onboarding.tsx` 重写 permissions 步骤)
**Files:**
- Modify: `src/app/_onboarding.tsx`
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
- [ ] **Step 1: i18n 新键zh/en 对等)**
`onboarding` section 加:
```
permAccessibility 无障碍服务 / Accessibility Service
permNotification 通知监听 / Notification Listener
permOverlay 悬浮窗 / Overlay
permSms 短信读取 / SMS
permStorage 相册/存储 / Photos & Storage
permGranted 已授权 / Granted
permNotGranted 未授权 / Not Granted
permOpenSettings 前往设置 / Open Settings
permRequest 请求权限 / Request
```
- [ ] **Step 2: 重写 permissions 步骤**
替换 `_onboarding.tsx:48``key: 'permissions'` 步骤内容。在当前 description 下方加权限状态清单Card 包裹,每行:图标 + 名称 + 状态点 + 操作按钮)。
权限列表:
1. **无障碍服务**`BIND_ACCESSIBILITY_SERVICE`)—— 最核心。检测 `serviceRunning`(从 `getAccessibilityBridge().isServiceRunning()` 异步取)。未授权 → 跳转 `ACCESSIBILITY_SETTINGS`
2. **通知监听**`BIND_NOTIFICATION_LISTENER_SERVICE`)—— 检测 `bridge.isNotificationListenerEnabled()`。未授权 → 跳转 `ACTION_NOTIFICATION_LISTENER_SETTINGS`
3. **短信**`RECEIVE_SMS`)—— `PermissionsAndroid.check()`。未授权 → `PermissionsAndroid.request()`
4. **存储**`READ_MEDIA_IMAGES`Android 13+ 用 `READ_MEDIA_IMAGES`,否则 `READ_EXTERNAL_STORAGE`)—— 同上
**实现细节**
- `const [permStates, setPermStates] = useState<Record<string, boolean | null>>({})` —— null = 加载中
- `useEffect(() => { checkAllPermissions(); }, [])` —— 步骤切到 permissions 时触发
- `checkAllPermissions`:调 `PermissionsAndroid.check()` 检查短信和存储;调 bridge 检查无障碍和通知监听
- 每行渲染:`Ionicons` 图标 + `permLabel` + 右侧状态文字(绿「已授权」/ 灰「未授权」)+ 未授权时显示操作按钮
- 「全部已授权」时显示一个大绿色勾 + 提示文字,按钮不显示
**权限 API 版本适配**
- `PermissionsAndroid.PERMISSIONS` 里 Android 13+ 是 `READ_MEDIA_IMAGES`,低版本用 `READ_EXTERNAL_STORAGE`
- 统一用 `Platform.Version >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE'`
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -rn "onboarding.perm" src/i18n/zh.ts src/i18n/en.ts` → 确认键对等
---
### Task 3: automation 页补齐权限入口
**Files:**
- Modify: `src/app/automation/index.tsx`
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
- [ ] **Step 1: i18n 新键**
`automation` section 加:
```
notificationTitle 通知监听服务 / Notification Listener
notificationEnabled 已启用 / Enabled
notificationDisabled 未启用 / Disabled
notificationOpenSettings 前往系统设置 / Open Settings
smsPermissionTitle 短信读取权限 / SMS Permission
smsPermissionGranted 已授权 / Granted
smsPermissionRequest 请求权限 / Request
storagePermissionTitle 存储权限 / Storage Permission
storagePermissionGranted 已授权 / Granted
storagePermissionRequest 请求权限 / Request
```
- [ ] **Step 2: automation 页加权限卡片**
在现有「无障碍服务状态与控制」Card **之后**加一个新 Card`<Card title={t('automation.otherPermissions')}>`。
内容三行:
1. **通知监听**:异步 `bridge.isNotificationListenerEnabled()` → 状态文字 + 未启用时「前往系统设置」按钮(`Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS')`
2. **短信**`PermissionsAndroid.check('android.permission.RECEIVE_SMS')` + `PermissionsAndroid.request()`
3. **存储**`PermissionsAndroid.check(StoragePermission)` + `PermissionsAndroid.request()`
**实现模式**
```tsx
const [notifEnabled, setNotifEnabled] = useState(false);
const [smsGranted, setSmsGranted] = useState(false);
const [storageGranted, setStorageGranted] = useState(false);
useEffect(() => {
// 异步获取各权限状态
checkNotif(); checkSms(); checkStorage();
}, []);
```
每行渲染:`Ionicons` 图标 + 权限名称 + 状态文字 + 未授权时的按钮。
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 4: 编译 + 测试 + 终审
- [ ] **Step 1: 编译验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
- [ ] **Step 2: 全量测试**
`npm test` → 全绿;`npm run typecheck` → 0 错误
- [ ] **Step 3: 审计**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出
grep -rnE "📊|💰|💸|📝|🔄|🎉|✨|⚠|📌|🌟" src/i18n/ src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出(预存 i18n fallbackWarn ⚠ 除外)
```
- [ ] **Step 4: 手工走查(需设备)**
1. 清数据 → 启动应用 → 引导页第 5 步(权限)→ 看到 4 个权限状态
2. 点未授权的无障碍 → 跳系统无障碍设置 → 返回看到状态已变
3. 点未授权的通知监听 → 跳系统通知设置
4. 点短信 → 弹出系统权限对话框
5. 引导页全部完成后 → 进入 automation 页 → 看到新增的权限卡片
---
## 终审修订(已实施,以实际代码为准)
**终审日期**2026-07-22审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 处理 |
|---|---|---|---|
| — | — | 无 Critical / Important 发现 | — |
**偏差说明**
1. `onboarding.permOverlay`(悬浮窗)未加入引导页——计划 Task 2 决策列为可选项实际未实现。不影响核心功能sideload 自动授予 + 无障碍运行时浮窗走 ACCESSIBILITY_OVERLAY 不依赖此权限)。
2. T3 中 `Platform.Version >= 33` 改为 `Number(Platform.Version) >= 33`——React Native 的 `Platform.Version` 类型为 `string | number`,直接比较会 TS 报错。
3. T1 的 `isNotificationListenerEnabled` 使用反射而非 `enabledListenerPackages` 属性——minSdk=24 < API 27反射是兼容方案

View File

@ -0,0 +1,338 @@
# P8自动记账可配置化 + 模型按需下载 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 操作**。不创建额外任务清单。
**Goal:** automation 页统一管控所有自动记账能力(规则/OCR/AI Vision 三层独立开关 + 模型下载管理 + AI 配置整合),`settings/ai.tsx` 删除并重定向OCR 模型从 APK assets 改为应用内下载filesystem 路径加载 ONNX
**背景决策**
- 三层开关默认值L1 规则 `true`、L2 OCR `true`、L3 AI Vision `false`(需用户配 API Key 后才可开启)
- 模型下载:首次启用 L2 时检查模型是否存在,无则弹出下载提示
- `settings/ai.tsx` 删除,原入口重定向到 automation 页
- 去重/转账识别两个 setting 字段不动(它们不是「层」而是管道的独立步骤),但在 automation 页加开关
- 模型从 assets 复制到 filesystem预构建时仍打 APK assets 以兼容旧用户,应用首次启动将模型文件从 assets 拷贝到 documentDirectoryOcrModule 从 filesystem 加载。**这样之后卸载 assets 打包就变成了纯删除**(未来版本切到纯下载只需移除 assets + app.plugin.js 禁拷贝)。
---
### Task 1: settingsStore 新增字段 + 迁移
**Files:** `src/store/settingsStore.ts`
- [ ] **Step 1: 接口 + DEFAULTS + 持久化 + setter**
`SettingsState` 接口加 5 个新字段:
```typescript
/** 自动记账 L1正则规则匹配 */
layer1RuleEnabled: boolean;
/** 自动记账 L2本地 OCR 识别 */
layer2OcrEnabled: boolean;
/** 自动记账 L3云端 AI Vision */
layer3AiEnabled: boolean;
/** OCR 模型版本(已下载),空字符串表示未安装 */
ocrModelVersion: string;
/** OCR 模型下载路径 */
ocrModelDir: string;
```
DEFAULTS
- `layer1RuleEnabled: true`
- `layer2OcrEnabled: true`
- `layer3AiEnabled: false`
- `ocrModelVersion: ''`
- `ocrModelDir: ''`
`PersistableSettings` 加对应 5 字段;`toPersistable` 加 5 行;新增 5 个 setter 函数。现有 `aiEnabled` 保留作为 LLM 的总开关语义(`layer3AiEnabled` 是在 automation 页独立控制,「启用」需同时满足 `aiEnabled && aiApiKey`)。
**注意**`isNotificationListenerEnabled` 这类 bridge 方法在 `setFloatingBallEnabled` 附近——新字段与它们无关,不加 bridge 方法。
- [ ] **Step 2: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 2: OcrProcessor 逐级回退逻辑
**Files:** `src/domain/ocrProcessor.ts`、`src/services/ocrBridge.ts`、`src/services/automationPipeline.ts`
- [ ] **Step 1: OcrProcessorConfig 扩展**
`OcrProcessorConfig` 接口加:
```typescript
layer1Enabled: boolean; // 默认 true
layer2Enabled: boolean; // 默认 true
layer3Enabled: boolean; // 默认 false
```
- [ ] **Step 2: doProcess() 逐级跳过**
`OcrProcessor.doProcess()` 中修改流程(约 :103-162
```
// 1. 横屏 / 图片去重 守卫不变
// 2. L1+L2 需要 OCR 文本
if (config.layer1Enabled || config.layer2Enabled) {
if (!ocrEngine) 返回 none引擎不可用
ocrText = await ocrEngine.recognizeText(imageBase64)
if (!ocrText) → 如果 L3 启用则 goto L3否则返回 none
} else {
ocrText = ''
}
// 3. Layer 1仅当启用
if (config.layer1Enabled && ocrText && !isDetailPage) {
result = matchOcrRule(ocrText, packageName)
if (result) return { layer: 'layer1-rule', ... }
}
// 4. Layer 2仅当启用
if (config.layer2Enabled && ocrText) {
result = parseOcrBill(ocrText, packageName)
if (result) return { layer: 'layer2-ocr', ... }
}
// 5. Layer 3仅当启用
if (config.layer3Enabled) {
return tryLayer3(imageBase64, ocrText)
}
// 6. 全部未命中或全部关闭
return { event: null, layer: 'none', skipped: 'non-bill' }
```
- [ ] **Step 3: automationPipeline.ts 的 getOcrProcessor 改读新字段**
`getOcrProcessor()` 约 :116-147 行:从 `settingsStore` 读取 `layer1RuleEnabled` / `layer2OcrEnabled` / `layer3AiEnabled` 传入 `OcrProcessorConfig`
- [ ] **Step 4: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 3: 模型文件从 assets 拷贝到 filesystem + OcrModule 改路径
**Files:** `plugins/ppocr/android/OcrModule.kt`、`plugins/ppocr/app.plugin.js`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(新 bridge 方法)
- [ ] **Step 1: AccessibilityBridgeModule 加 copyModelFromAssets 方法**
```kotlin
@ReactMethod
fun copyOcrModelsFromAssets(promise: Promise) {
try {
val assetManager = reactContext.assets
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
if (!destDir.exists()) destDir.mkdirs()
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
for (filename in files) {
val destFile = java.io.File(destDir, filename)
if (destFile.exists()) continue // skip existing
val input = assetManager.open(filename)
val output = java.io.FileOutputStream(destFile)
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
}
input.close()
output.close()
}
promise.resolve(destDir.absolutePath)
} catch (e: Exception) {
promise.reject("COPY_MODEL_FAIL", e.message)
}
}
```
- [ ] **Step 2: OcrModule.kt 改为 filesystem 路径加载**
`OcrModule.kt``createSession` 调用的位置,改从传入路径加载而非 asset 文件名。当前构造可能接受 asset 路径——改为接收 filesystem 绝对路径。新增一个 `@ReactMethod setModelDir(dir: String)` 存储路径,或直接在 `initialize(assetManager: ...)` 的参数中改为 `initialize(modelDir: String, ...)`
**注意**ONNX Runtime Android 的 `OrtEnvironment.createSession(filePath)` 接受文件系统路径String不需要 asset 特殊处理——只需确认 `createSession` 的参数类支持文件路径,否则改用 `OrtEnvironment.createSession(byte[] modelBytes)` 从内存加载。**最终方案**:保持 `OrtEnvironment.createSession(filePath)` ,将 `modelDir + "/ppocrv6_det.onnx"` 作为绝对路径传入。
- [ ] **Step 3: JS 侧 bridge 接口 + modelDownloader 服务**
`NativeAccessibilityBridge``copyOcrModelsFromAssets(): Promise<string>` → 返回模型目录绝对路径。
新建 `src/services/modelDownloader.ts`
```typescript
export async function ensureOcrModels(): Promise<string> {
// 1. 如果 settingsStore.ocrModelDir 有效且文件存在 → 直接返回
// 2. 尝试从 assets 拷贝到 documentDirectory/ocr_models_v6
// 3. 返回 modelsDir 绝对路径,存储到 settingsStore.ocrModelDir + ocrModelVersion
}
```
`ocrModelVersion` 在从 assets 拷贝成功后写 `'v6-assets'`(区分下载版本和打包版本)。
- [ ] **Step 4: automationPipeline.ts 的 OcrProcessor 从 filesystem 初始化**
`getNativeOcrBridge()``getOcrProcessor()` 中,在初始化 OCR 引擎前调用 `ensureOcrModels()` 获取模型路径,传入 `NativeOcrBridge` 构造(或 OcrModule initialize
- [ ] **Step 5: 验证**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
---
### Task 4: automation 页 UI —— 三层开关 + 模型管理 + AI 配置整合
**Files:** `src/app/automation/index.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: i18n 新键zh/en 对等)**
`automation` section 加:
```
autoBookkeeping 自动记账层级 / Auto Bookkeeping Layers
layer1Rule 规则匹配 / Rule Matching
layer1RuleDesc 基于正则规则的快速账单识别 / Fast regex-based bill recognition
layer2Ocr OCR 识别 / OCR Recognition
layer2OcrDesc 本地 PP-OCRv6 模型识别 / On-device PP-OCRv6 model
layer3Ai AI 视觉识别 / AI Vision
layer3AiDesc 云端多模态大模型识别 / Cloud multimodal LLM
layer3AiDisabled 请先配置 AI Key / Configure AI Key first
ocrModelTitle OCR 模型 / OCR Model
ocrModelNotInstalled 未安装 / Not Installed
ocrModelInstalled 已安装 ({version}) / Installed ({version})
ocrModelInstall 安装模型 / Install Model
ocrModelInstalling 安装中... / Installing...
ocrModelCopying 正在复制模型文件... / Copying model files...
dedupSwitch 多通道联合去重 / Cross-channel Dedup
transferSwitch 转账智能识别 / Transfer Recognition
```
- [ ] **Step 2: automation 页 UI 重组织**
在现有截图监控按钮 Card **之后**加以下新 Card 区块:
**Card A「自动记账层级」**
- 三行 SwitchL1 规则匹配always enabled不依赖外部条件
- L2 OCR 识别Switch开启时检查模型→无模型则弹下载提示 Dialog
- L3 AI 视觉Switch如果 aiEnabled=false 或 aiApiKey 为空则 disabled+提示文字;开启后展开 Provider/Key/Model 输入)
**Card B「OCR 模型」**
- 状态行:版本号(未安装显示灰色「未安装」;已安装显示绿色「已安装 (v6-assets)」)
- 操作按钮:安装/重新安装(从 assets 拷贝 → 进度状态 → 完成)
**Card C「管道设置」**(复用现有 Card B 和 C 部分)
- `dedupEnabled` Switch
- `transferRecognitionEnabled` Switch
- [ ] **Step 3: 操作逻辑**
```typescript
// 模型安装
const handleInstallModel = async () => {
setModelStatus('installing');
try {
const bridge = getAccessibilityBridge();
if (!bridge) throw new Error('Bridge unavailable');
const modelDir = await bridge.copyOcrModelsFromAssets();
setOcrModelVersion('v6-assets');
setOcrModelDir(modelDir);
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert('安装失败', String(e));
}
};
// L2 开关变化时检查模型
const handleL2Toggle = (val: boolean) => {
setLayer2OcrEnabled(val);
if (val && !ocrModelVersion) {
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
]);
}
};
// L3 开关变化时检查 AI Key
const handleL3Toggle = (val: boolean) => {
if (val && (!aiEnabled || !aiApiKey)) {
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'));
return;
}
setLayer3AiEnabled(val);
};
```
- [ ] **Step 4: AI 配置内联**
L3 启用时展开的子区域:
- `aiProviderId`:三选一 chipopenai/gemini/deepseek
- `aiApiKey`输入框secureTextEntry
- `aiBaseUrl`:输入框(默认值 placeholder
- `aiModel`:输入框(默认值 placeholder
- `aiEnabled`Switch总开关控制 L3 的实际可用性)
- [ ] **Step 5: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
---
### Task 5: AI 设置页清理 + settings 导航更新
**Files:** `src/app/settings/ai.tsx`(重写为跳转)、`src/app/(tabs)/settings.tsx`(更新入口文案)
- [ ] **Step 1: settings/ai.tsx 改为 redirect**
不删除文件(保留路由),但内容改为:`useEffect(() => { router.replace('/automation'); }, [])` 跳转到 automation 页,期间显示 loading。
- [ ] **Step 2: settings.tsx 入口文案**
`src/app/(tabs)/settings.tsx` 中「智能记账 & AI」入口的文案保持不变但实际点击跳转已由 expo-router 自动处理ai.tsx → redirect → automation不需要改路由注册。
- [ ] **Step 3: 验证**
`npm run typecheck` → 0 错误;`npm test` → 全绿
`grep -rn "settings/ai" src/app/(tabs)/settings.tsx` → 确认无需改动即可跳转
---
### Task 6: 编译 + 测试 + 终审
- [ ] **Step 1: Kotlin 编译**
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
- [ ] **Step 2: 全量测试**
`npm test` → 全绿;`npm run typecheck` → 0 错误
- [ ] **Step 3: 审计**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/app/automation/index.tsx # 无输出
grep -rn "📊|💰|💸|📝" src/app/automation/ # 无输出
```
- [ ] **Step 4: 手工走查清单**
1. settings 页点「智能记账」→ 跳转到 automation 页
2. automation 页看到三个层级开关L1 默认开 / L2 默认开 / L3 默认关灰)
3. 关闭 L1 → 仍可记账(走 L2 OCR
4. 关闭 L2 → L1 失败不再调用 OCR
5. 开启 L3 → 提示配 AI Key → 配置后可用
6. OCR 模型未安装 → 点安装 → 从 assets 拷贝 → 显示绿色已安装
7. L2 开关关闭再打开 → 已有模型直接可用,不再提示
8. AI Provider 切换 openai/gemini/deepseek → 对应的 baseUrl 默认值变化
---
## 终审修订(已实施,以实际代码为准)
**日期**2026-07-22审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
| # | 级别 | 问题 | 处理 |
|---|---|---|---|
| — | — | 无 Critical / Important 发现 | — |
**偏差说明**
1. 管道设置 Card 复用现有 `settings.dedupLabel`/`settings.transferLabel` i18n 键(已在 settings/ai.tsx 中使用),未新键 `dedupSwitch`/`transferSwitch`——避免重复键。
2. OcrModule.kt 从 assets 加载路径改为 `modelDir` 字段 + `setModelDir` 方法——保留原 assets 加载作为兜底modelDir 为空时走原路径),保证向后兼容。
3. `ensureOcrModels()``_layout.tsx` ready 前调用一次(异步非阻塞),`getOcrProcessor()` 中也传 `ocrModelDir` 作为安全网。

68
docs/development.md Normal file
View File

@ -0,0 +1,68 @@
# 开发指南
## 环境要求
- Node.js 18+
- JDK 21 + Android SDK 35 + NDK 27.1.12297006(仅 Android 构建需要)
- 详见 [android-build-guide.md](android-build-guide.md)
## 常用命令
```bash
npm install --legacy-peer-deps # 安装依赖(必须带 --legacy-peer-deps
npm run start # 启动 Metro 开发服务器
npm run android # expo run:android设备/模拟器)
npm test # Vitest 全量测试
npm run typecheck # tsc --noEmit 类型检查
# 单文件测试
npx vitest run tests/ledger.test.ts
# 按名称运行测试
npx vitest run -t "拒绝不平衡交易"
# 重新生成原生项目
npx expo prebuild --platform android
```
## 编码规范
### TypeScript
- **strict 模式**已开启,路径别名 `@/*``src/*`
- 非平凡改动后必须运行 `npm run typecheck`
### 金额计算
- 使用字符串十进制运算(`src/domain/decimal.ts`**禁止**用 JS 浮点数处理金额
- 关键 API`addDecimals`、`subtractDecimals`、`multiplyDecimal`、`divideDecimals`、`negateDecimal`
### 领域层纯净性
- `src/domain/` 禁止导入 React / React Native / Expo 任何模块
- 外部依赖通过接口注入:`MobileBeanBackend`、`OcrEngine`、`AiVisionProvider`
- 新模块必须在 `src/domain/index.ts` 重导出
### 原生代码
- 原生 Kotlin/XML 只能放在 `plugins/<name>/` 下,不可直接编辑 `android/`
- 每个 Config Plugin 函数**必须 `return config`**
- `expo prebuild` 后检查 `onnxruntime-android:1.20.0` 版本
### 注释与文档
- 代码注释和 `plan.md` 使用**中文**
- ID/校验和使用 FNV-1a 哈希(`hash()` in `ledger.ts`),不用加密哈希
## 测试
- 框架:**Vitest**(无配置文件,默认拾取 `tests/**/*.test.ts`
- 测试对象:主要覆盖 `src/domain/` 层,使用 mock 后端(`MemoryBackend`、`MockOcrEngine`
- 命名:`<模块或功能>.test.ts`,如 `decimal.test.ts`、`billPipeline.test.ts`
- 新增 domain 逻辑时必须同步添加测试
## 提交规范
- 提交消息格式:`feat: <中文摘要>`,多行 body 按模块分区列举变更
- 示例:`feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构`
- PR 需描述变更内容与原因UI 变更附截图,关联相关 issue

18
package-lock.json generated
View File

@ -1,15 +1,13 @@
{
"name": "beancount-mobile",
"name": "drift-ledger",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beancount-mobile",
"name": "drift-ledger",
"version": "0.1.0",
"dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0",
@ -2017,18 +2015,6 @@
"node": ">=18"
}
},
"node_modules/@expo-google-fonts/caveat": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/caveat/-/caveat-0.4.2.tgz",
"integrity": "sha512-kCG35tRUX8Wi+u0Jh5ck3K1u7vZkdLnwRTuKMrEnlVmikvxQFXN1KjF5cL2vqRG7Hwm7648Zl8xe1BWAoIsPCA==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo-google-fonts/quicksand": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/quicksand/-/quicksand-0.4.1.tgz",
"integrity": "sha512-LpGdmY+CDm/GTv29qDPy8Nz1a3idWO1R6Bb+ty7flS6vfHXsdOQTRKNz93SFHdXQj0FSvVhvMLe3wTtMgf312A==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo/code-signing-certificates": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile",
"name": "drift-ledger",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
@ -11,8 +11,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0",

31
plugins/README.md Normal file
View File

@ -0,0 +1,31 @@
# Expo Config Plugins
本目录包含所有原生模块的 Expo Config Plugin。由于 `android/` 目录由 `expo prebuild` 自动生成且被 Git 忽略,所有原生代码必须以 Config Plugin 形式存在。
## 插件结构
```
plugins/<name>/
app.plugin.js # prebuild 时执行,注入 Kotlin/XML、修改 gradle、注册服务
package.json # 插件元数据
android/*.kt # 原生 Kotlin 代码
assets/ # 模型文件等静态资源(仅 ppocr
```
## 当前插件
| 插件 | 功能 | 备注 |
|------|------|------|
| `ppocr` | PP-OCRv6 检测+识别 | ONNX Runtime模型在 `assets/` |
| `accessibility` | 无障碍服务账单抓取 + 悬浮窗 + OCR 贴片 | 仅侧载场景 |
| `notification-listener` | 通知栏账单监听 | |
| `sms-receiver` | 短信账单解析 | |
| `screenshot-monitor` | 截图触发 OCR | |
| `size-optimization` | ABI 分包 + NDK 版本统一 | 仅影响 gradle 配置 |
## 开发须知
1. 每个插件函数**必须 `return config`**,否则后续插件会崩溃
2. 修改插件后需重新运行 `npx expo prebuild --platform android`
3. 原生服务通过 `NativeEventEmitter` 向 JS 层推送事件,**不直接写入**数据库或文件
4. `ppocr` 依赖 `onnxruntime-android:1.20.0`prebuild 后请验证版本

View File

@ -1,5 +1,6 @@
package com.beancount.mobile.accessibility
import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
@ -7,6 +8,7 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.WritableNativeArray
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
/**
* 无障碍服务 RN 桥接模块plan.md3.6 无障碍服务JS 接线
@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "AccessibilityBridge"
}
init {
ReactContextHolder.context = reactContext
}
@ -188,17 +194,18 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
@ReactMethod
fun showFloatingBill(
amount: String,
merchant: String,
time: String,
packageName: String,
amount: String,
merchant: String,
time: String,
packageName: String,
categories: ReadableArray,
accounts: ReadableArray,
direction: String,
currency: String,
draftId: String,
promise: Promise
) {
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return
@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
android.os.Handler(android.os.Looper.getMainLooper()).post {
try {
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction)
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
floatingView.show()
promise.resolve(true)
} catch (e: Exception) {
@ -230,4 +237,88 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
}
}
}
/** 设置悬浮球开关状态。 */
@ReactMethod
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
BillingAccessibilityService.floatingBallEnabled = enabled
val service = BillingAccessibilityService.instance
if (service == null) {
promise.resolve(true)
return
}
if (!enabled) {
service.dismissFloatingHelper()
} else {
service.refreshFloatingHelper()
}
promise.resolve(true)
}
/** 从 APK assets 复制 OCR 模型文件到 filesDir/ocr_models_v6。 */
@ReactMethod
fun copyOcrModelsFromAssets(promise: Promise) {
try {
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
if (!destDir.exists()) destDir.mkdirs()
val assetManager = reactContext.assets
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
for (filename in files) {
val destFile = java.io.File(destDir, filename)
if (destFile.exists() && destFile.length() > 0) continue
assetManager.open(filename).use { input ->
java.io.FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
}
promise.resolve(destDir.absolutePath)
} catch (e: Exception) {
promise.reject("COPY_MODEL_FAIL", e.message)
}
}
/** 检查通知监听服务是否已启用(反射调用 getEnabledListenerPackages避免 API level lint 错误)。 */
@ReactMethod
fun isNotificationListenerEnabled(promise: Promise) {
try {
// 通过 Settings.Secure 读取系统设置(公开 API无需反射不受 hidden API 限制)
// enabled_notification_listeners 格式:
// "com.pkg1/com.pkg1.Service:com.pkg2/com.pkg2.Service"
val flat = android.provider.Settings.Secure.getString(
reactContext.contentResolver,
"enabled_notification_listeners"
) ?: ""
val myPkg = reactContext.packageName
promise.resolve(flat.split(":").any { it.startsWith(myPkg) })
} catch (e: Exception) {
Log.w(TAG, "isNotificationListenerEnabled 检测失败", e)
promise.resolve(false)
}
}
/** 检查悬浮窗权限SYSTEM_ALERT_WINDOW是否已授予。 */
@ReactMethod
fun canDrawOverlays(promise: Promise) {
try {
promise.resolve(android.provider.Settings.canDrawOverlays(reactContext))
} catch (e: Exception) {
promise.resolve(false)
}
}
/**
* 下发浮层 UI 配置颜色 + 文案
* JS 侧从 theme tokens + i18n 构建 config
* 原生存 SharedPreferences三个浮层组件读取
*/
@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)
}
}
}

View File

@ -49,7 +49,14 @@ class BillingAccessibilityService : AccessibilityService() {
var instance: BillingAccessibilityService? = null
private set
/** 支付 App 白名单。 */
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
@Volatile
var floatingBallEnabled = true
/**
* 支付 App 包名自动生成来源src/domain/constants.ts PAYMENT_PACKAGES
* 修改时只需编辑 constants.ts运行 expo prebuild 即可同步
*/
val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone", // 支付宝
"com.tencent.mm", // 微信
@ -79,6 +86,7 @@ class BillingAccessibilityService : AccessibilityService() {
}
private var ocrDoing = false
private var ocrDoingTimestamp = 0L
private val handler = Handler(Looper.getMainLooper())
private val debounceRunnable = Runnable { processContentChange() }
@Volatile private var topPackage: String? = null
@ -93,6 +101,14 @@ class BillingAccessibilityService : AccessibilityService() {
Log.i(TAG, "无障碍账单识别服务已连接")
loadPageSignatures()
configureService()
// 修复:检查当前前台 App恢复悬浮球
val root = rootInActiveWindow
val pkg = root?.packageName?.toString()
root?.recycle()
if (pkg != null) {
topPackage = pkg
updateFloatingHelperVisibility(pkg)
}
}
/** 动态配置服务能力(截图 + 页面变化监听)。 */
@ -110,7 +126,15 @@ class BillingAccessibilityService : AccessibilityService() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (ocrDoing) return // 处理中,跳过
// ocrDoing 超时兜底5秒后强制重置
if (ocrDoing) {
if (System.currentTimeMillis() - ocrDoingTimestamp > 5000) {
Log.w(TAG, "ocrDoing 超时重置")
ocrDoing = false
} else {
return
}
}
val eventPackage = event?.packageName?.toString() ?: return
when (event.eventType) {
@ -120,13 +144,12 @@ class BillingAccessibilityService : AccessibilityService() {
topPackage = eventPackage
topActivity = activityName
Log.d(TAG, "页面切换: $eventPackage / $activityName")
// 更新悬浮窗助手状态
updateFloatingHelperVisibility(eventPackage)
if (PAYMENT_PACKAGES.contains(eventPackage)) {
scheduleContentChange()
}
// 页面切换时检查是否有已记住的签名需要触发文本提取
scheduleContentChange()
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
@ -159,16 +182,20 @@ class BillingAccessibilityService : AccessibilityService() {
}
}
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
if (PAYMENT_PACKAGES.contains(eventPackage)) {
scheduleContentChange()
}
// 内容变化时也检查(防抖合并)
scheduleContentChange()
}
}
}
private fun updateFloatingHelperVisibility(pkg: String?) {
handler.post {
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) {
val shouldShow = floatingBallEnabled
&& pkg != null
&& !filterPackage(pkg, topActivity ?: "")
&& !isLandscape()
if (shouldShow) {
if (floatingHelper == null) {
floatingHelper = FloatingHelper(this)
floatingHelper?.show()
@ -236,6 +263,7 @@ class BillingAccessibilityService : AccessibilityService() {
private fun takeScreenshotAndProcess(packageName: String) {
if (ocrDoing) return
ocrDoing = true
ocrDoingTimestamp = System.currentTimeMillis()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
ocrDoing = false
return
@ -352,11 +380,12 @@ class BillingAccessibilityService : AccessibilityService() {
val pkg = topPackage ?: return
val activity = topActivity ?: ""
val sig = "$pkg|$activity"
val uiLabels = FloatingUiConfigStore.load(this).labels
if (pageSignatures.add(sig)) {
savePageSignatures()
Log.i(TAG, "已记住页面: $sig")
handler.post {
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
}
// 抓取并提取屏幕所有无障碍文本
@ -388,7 +417,7 @@ class BillingAccessibilityService : AccessibilityService() {
}
} else {
handler.post {
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show()
android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
}
}
}
@ -451,6 +480,21 @@ class BillingAccessibilityService : AccessibilityService() {
}
}
/** 关闭悬浮球(由 JS 层 setFloatingBallEnabled(false) 调用)。 */
fun dismissFloatingHelper() {
handler.post {
floatingHelper?.dismiss()
floatingHelper = null
}
}
/** 刷新悬浮球状态(由 JS 层 setFloatingBallEnabled(true) 调用)。 */
fun refreshFloatingHelper() {
handler.post {
updateFloatingHelperVisibility(topPackage)
}
}
/** 横屏检测plan.md「3.10 横屏免打扰」)。 */
private fun isLandscape(): Boolean {
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
@ -459,17 +503,18 @@ class BillingAccessibilityService : AccessibilityService() {
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
}
/** 过滤系统组件/桌面(不触发 OCR)。 */
/** 过滤不应处理的系统组件桌面启动器、SystemUI、输入法等)。 */
private fun filterPackage(pkg: String, className: String): Boolean {
val p = pkg.lowercase()
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗;
// 其他自身组件(如悬浮球容器 LinearLayout一律过滤避免自毁式关闭。
// 自身 App只放行 MainActivity避免悬浮球容器触发自毁式关闭
if (pkg == packageName) {
return className != "com.example.beanmobile.MainActivity"
if (className.isEmpty()) return false
return !className.endsWith(".MainActivity")
}
if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true
if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true
// 桌面启动器(悬浮球在桌面无意义)
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
// SystemUI / 输入法(悬浮球在这些组件上会遮挡系统 UI
if (p.contains("systemui") || p.contains("inputmethod") || p.contains("keyboard")) return true
return false
}

View File

@ -17,9 +17,11 @@ import android.widget.LinearLayout
import android.text.InputType
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.math.BigDecimal
/**
* 浮窗账单提示直接呈现高度优化支持三方向切换的修改入账面板
* P6 重设计颜色/文案走 FloatingUiConfigStore新增币种 chip金额校验改正则+BigDecimal
*/
class FloatingBillView(
private val context: Context,
@ -30,12 +32,27 @@ class FloatingBillView(
private val packageName: String,
private val categories: List<Map<String, String>> = emptyList(),
private val accounts: List<String> = emptyList(),
private val initialDirection: String = "expense"
private val initialDirection: String = "expense",
private val initialCurrency: String = "CNY"
) {
companion object {
private const val TAG = "FloatingBillView"
private val DEFAULT_CURRENCY_LIST = listOf("CNY", "USD", "HKD", "JPY", "EUR", "GBP")
}
// 从 JS 下发的配置读取颜色与文案(缺失字段回退默认值,保证旧 JS 行为不变)
private val config = FloatingUiConfigStore.load(context)
// 显示币种列表;若 initialCurrency 不在默认列表中则临时插入首位
private val currencyList: List<String> = run {
val list = DEFAULT_CURRENCY_LIST.toMutableList()
if (!list.contains(initialCurrency)) {
list.add(0, initialCurrency)
}
list
}
private var currentCurrency: String = initialCurrency
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var view: View? = null
private val handler = Handler(Looper.getMainLooper())
@ -49,6 +66,20 @@ class FloatingBillView(
private var accountContainer: LinearLayout? = null
private var saveBtn: Button? = null
// 预解析常用颜色parseColorOr 异常时回退默认值)
private val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
private val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
private val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
private val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
private val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
private val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
private val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
private val colorIncome = FloatingUiConfigStore.parseColorOr(config.colors.income, "#FF10B981")
private val colorExpense = FloatingUiConfigStore.parseColorOr(config.colors.expense, "#FFE11D48")
private val colorTransfer = FloatingUiConfigStore.parseColorOr(config.colors.transfer, "#FF10B981")
// 容器边框直接使用 config border不再从 accent 派生 alpha
private fun dp(value: Float): Int {
val density = context.resources.displayMetrics.density
return (value * density).toInt()
@ -74,29 +105,34 @@ class FloatingBillView(
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
val overlayType = if (context is android.accessibilityservice.AccessibilityService) {
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
} else {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
val windowParams = WindowManager.LayoutParams(
dp(310f),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
overlayType,
0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(12f) // 尽量靠下以留出上方账单对照
y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全
}
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
// 2. 实色卡片背景(不再半透明磨砂),圆角 16dp1dp 描边
val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(14f).toFloat()
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
cornerRadius = dp(16f).toFloat()
setColor(colorCardBg)
setStroke(dp(1f), colorBorder) // 使用 config border 颜色
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = containerBg
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
@ -114,9 +150,9 @@ class FloatingBillView(
}
val titleText = TextView(context).apply {
text = "调整交易草稿"
textSize = 12f
setTextColor(0xFF98A2FF.toInt())
text = config.labels.billTitle
textSize = 13f
setTextColor(colorFgPrimary)
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
@ -126,12 +162,12 @@ class FloatingBillView(
val segmentContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
background = GradientDrawable().apply {
cornerRadius = dp(4f).toFloat()
setColor(0x20FFFFFF.toInt()) // 12% white opacity
cornerRadius = dp(6f).toFloat()
setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
}
}
val tabTexts = listOf("支出", "收入", "转账")
val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
val tabDirections = listOf("expense", "income", "transfer")
val tabViews = mutableListOf<TextView>()
@ -139,11 +175,11 @@ class FloatingBillView(
for (i in tabViews.indices) {
val active = tabDirections[i] == currentDirection
tabViews[i].apply {
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
setTextColor(if (active) colorAccentFg else colorFgSecondary)
background = if (active) {
GradientDrawable().apply {
cornerRadius = dp(4f).toFloat()
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
cornerRadius = dp(6f).toFloat()
setColor(colorAccent)
}
} else {
null
@ -173,40 +209,75 @@ class FloatingBillView(
headerLayout.addView(segmentContainer)
container.addView(headerLayout)
// 金额编辑
// 金额行:币种 chip+ 金额输入框(右)
val amountLabel = TextView(context).apply {
text = "金额"
textSize = 9f
text = config.labels.amountLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, dp(4f), 0, dp(1f))
setTextColor(colorFgSecondary)
setPadding(0, dp(8f), 0, dp(1f))
}
container.addView(amountLabel)
val amountInput = EditText(context).apply {
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
background = GradientDrawable().apply {
setColor(0x4012131A.toInt()) // 25% 半透底色
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
val amountRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(amountInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
// 币种 chip
val currencyChip = TextView(context).apply {
text = currentCurrency
textSize = 11f
setTextColor(colorFgSecondary)
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
gravity = Gravity.CENTER
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
setOnClickListener {
val idx = currencyList.indexOf(currentCurrency)
currentCurrency = currencyList[(idx + 1) % currencyList.size]
text = currentCurrency
}
}
amountRow.addView(currencyChip)
val amountInput = EditText(context).apply {
textSize = 13f
setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply {
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
).apply {
leftMargin = dp(8f)
}
}
amountRow.addView(amountInput)
container.addView(amountRow)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 商户编辑
val merchantLabel = TextView(context).apply {
text = "交易对手"
textSize = 9f
text = config.labels.payeeLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(merchantLabel)
@ -214,56 +285,57 @@ class FloatingBillView(
val merchantInput = EditText(context).apply {
setText(merchant)
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
setTextColor(colorFgPrimary)
setHintTextColor(colorFgSecondary)
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(merchantInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 叙述备注编辑
val narrationLabel = TextView(context).apply {
text = "描述/备注"
textSize = 9f
text = config.labels.narrationLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(narrationLabel)
val narrationInput = EditText(context).apply {
hint = "输入交易叙述"
setHintTextColor(0xFF6B7280.toInt())
hint = config.labels.narrationHint
setHintTextColor(colorFgSecondary)
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(narrationInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 1 分类/转入选择
categoryLabel = TextView(context).apply {
text = "交易分类"
textSize = 9f
text = config.labels.categoryExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(categoryLabel)
@ -281,14 +353,14 @@ class FloatingBillView(
)
}
container.addView(categoryScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 2 资金出入账户选择
accountLabel = TextView(context).apply {
text = "资金来源账户"
textSize = 9f
text = config.labels.accountExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(accountLabel)
@ -306,7 +378,7 @@ class FloatingBillView(
)
}
container.addView(accountScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) })
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
// 底部操作栏
val btnContainer = LinearLayout(context).apply {
@ -320,16 +392,16 @@ class FloatingBillView(
// 1) 打开应用按钮
val openAppBtn = Button(context).apply {
text = "打开应用"
setTextColor(0xFFD1D5DB.toInt())
text = config.labels.openApp
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
@ -342,18 +414,17 @@ class FloatingBillView(
}
// 2) 忽略按钮
val cancelBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
}
val cancelBtn = Button(context).apply {
text = "忽略"
setTextColor(0xFFD1D5DB.toInt())
background = cancelBg
text = config.labels.dismiss
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
sendCancelEvent()
dismiss()
@ -361,18 +432,17 @@ class FloatingBillView(
}
// 3) 确认入账按钮
val saveBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF5E6AD2.toInt())
}
saveBtn = Button(context).apply {
text = "确认入账"
setTextColor(0xFFFFFFFF.toInt())
background = saveBg
text = config.labels.confirm
setTextColor(colorAccentFg)
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(colorAccent)
}
textSize = 11f
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
@ -388,21 +458,23 @@ class FloatingBillView(
btnContainer.addView(saveBtn)
container.addView(btnContainer)
// 构建金额安全校验
// 金额安全校验BigDecimal + 正则)
val amountWatcher = object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: android.text.Editable?) {
try {
val text = s?.toString()?.trim() ?: ""
val value = text.toDoubleOrNull()
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
val value = text.toBigDecimalOrNull()
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
saveBtn?.isEnabled = isValid
saveBtn?.background = GradientDrawable().apply {
cornerRadius = dp(8f).toFloat()
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(if (isValid) colorAccent else colorInputBg)
}
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
} catch (e: Exception) {
Log.e(TAG, "金额校验异常", e)
saveBtn?.isEnabled = false
@ -419,7 +491,7 @@ class FloatingBillView(
view = container
windowManager.addView(view, windowParams)
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
@ -438,11 +510,11 @@ class FloatingBillView(
false
}
// 15 秒无操作自动消失(如果用户没有交互的话)
// 30 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({
sendCancelEvent()
dismiss()
}, 15000L)
}, 30000L)
} catch (e: Exception) {
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
@ -459,7 +531,7 @@ class FloatingBillView(
// === 1. 绘制第一行 (分类 / 转入) ===
if (currentDirection == "expense") {
categoryLabel?.text = "交易分类"
categoryLabel?.text = config.labels.categoryExpense
val filteredCats = categories.filter { it["type"] == "expense" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
@ -470,14 +542,14 @@ class FloatingBillView(
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
@ -492,18 +564,18 @@ class FloatingBillView(
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
} else if (currentDirection == "income") {
categoryLabel?.text = "收入分类"
categoryLabel?.text = config.labels.categoryIncome
val filteredCats = categories.filter { it["type"] == "income" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
@ -514,14 +586,14 @@ class FloatingBillView(
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
@ -536,19 +608,19 @@ class FloatingBillView(
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
} else {
// transfer
categoryLabel?.text = "转入账户"
// transfer — 第一行 = 转入账户,选中色 = transfer
categoryLabel?.text = config.labels.transferTarget
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
@ -557,14 +629,14 @@ class FloatingBillView(
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
@ -579,26 +651,29 @@ class FloatingBillView(
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
// === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") {
accountLabel?.text = "资金来源"
accountLabel?.text = config.labels.accountExpense
} else if (currentDirection == "income") {
accountLabel?.text = "存入账户"
accountLabel?.text = config.labels.accountIncome
} else {
accountLabel?.text = "转出账户"
accountLabel?.text = config.labels.accountTransfer
}
// 账户行选中色按方向expense→expense色income→income色transfer→expense色
val accountSelectedColor = if (currentDirection == "income") colorIncome else colorExpense
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
@ -607,15 +682,15 @@ class FloatingBillView(
for (pair in accountChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
setColor(accountSelectedColor)
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
@ -631,14 +706,14 @@ class FloatingBillView(
for (p in categoryChips) {
val act = p.first == selectedCategoryAccount
p.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
if (act) setColor(0xFF10B981.toInt())
cornerRadius = dp(14f).toFloat()
if (act) setColor(colorTransfer)
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
}
}
}
@ -651,15 +726,15 @@ class FloatingBillView(
accountChips.forEach { pair ->
val active = pair.first == selectedSourceAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
setColor(accountSelectedColor)
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
@ -677,6 +752,7 @@ class FloatingBillView(
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
putBoolean("confirmed", true)
putBoolean("editRequested", false)
putBoolean("isManualEdit", true)
@ -703,6 +779,7 @@ class FloatingBillView(
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
@ -736,4 +813,13 @@ class FloatingBillView(
} catch (_: Exception) {}
view = null
}
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
private fun String.toBigDecimalOrNull(): BigDecimal? {
return try {
BigDecimal(this)
} catch (_: Exception) {
null
}
}
}

View File

@ -28,6 +28,9 @@ class FloatingHelper(
) {
companion object {
private const val TAG = "FloatingHelper"
private const val PREFS_NAME = "billing_accessibility_prefs"
private const val KEY_X = "floating_ball_x"
private const val KEY_Y = "floating_ball_y"
private var lastX = 0
private var lastY = 400
}
@ -41,7 +44,7 @@ class FloatingHelper(
private val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
PixelFormat.TRANSLUCENT
).apply {
@ -57,6 +60,25 @@ class FloatingHelper(
try {
val context = service
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
val config = FloatingUiConfigStore.load(service)
// 预解析颜色
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
params.x = prefs.getInt(KEY_X, lastX)
params.y = prefs.getInt(KEY_Y, lastY)
lastX = params.x
lastY = params.y
val density = service.resources.displayMetrics.density
fun dp(value: Float) = (value * density).toInt()
@ -79,25 +101,31 @@ class FloatingHelper(
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
}
// 边缘指示竖线accent 色取 RGB + 固定 0xB0 alpha保持 70% 透明)
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
val indicatorView = View(context).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(1.5f).toFloat() // 高度圆润
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
cornerRadius = dp(1.5f).toFloat()
setColor(indicatorColor)
}
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
gravity = Gravity.CENTER
gravity = Gravity.CENTER_VERTICAL or Gravity.START
}
}
bubbleLayout.addView(indicatorView)
bubbleView = bubbleLayout
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
// 菜单背景:实色 cardBgper plan §T4
val menuBgColor = colorCardBg
// 菜单描边border 色 + 半透明 alpha
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
val menuBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(10f).toFloat()
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border
setColor(menuBgColor)
setStroke(dp(0.8f), menuBorderColor)
}
menuView = LinearLayout(context).apply {
@ -107,30 +135,37 @@ class FloatingHelper(
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
visibility = View.GONE
layoutParams = LinearLayout.LayoutParams(
dp(96f), // ultra-compact width: 96dp
dp(96f),
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = dp(4f) // spacing with indicator line
leftMargin = dp(4f)
}
}
// Vector icons
val sizePx = dp(14f)
val strokePx = dp(1.4f).toFloat()
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx)
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx)
// ScanIconDrawableaccentFg 色(在 accent 底按钮上可见laserColor 保留红色语义
val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx)
// PinIconDrawablefgSecondary 色
val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx)
// Button 1: 识别账单主操作accent 底 accentFg 字)
// 背景常时accent 色取 RGB + 固定 0x1F alpha约 12%
val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt()
// 按下加深accent 色取 RGB + 固定 0x40 alpha约 25%
val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt()
// Button 1: "识别账单"
val btnOcr = TextView(context).apply {
text = "识别账单"
setTextColor(0xFFFFFFFF.toInt())
text = config.labels.ballOcr
setTextColor(colorAccentFg)
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) // translucent indigo background
setColor(btnOcrBgNormal)
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
@ -145,13 +180,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x405E6AD2.toInt())
setColor(btnOcrBgPressed)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt())
setColor(btnOcrBgNormal)
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
@ -165,17 +200,22 @@ class FloatingHelper(
}
}
// Button 2: "记住此页"
// Button 2: 记住此页次要操作inputBg 底 fgPrimary 字per plan §T4
// 背景常时inputBg 色取 RGB + 固定 0x15 alpha
val btnRememberBgNormal = (colorInputBg and 0x00FFFFFF) or 0x15000000.toInt()
// 按下加深inputBg 色取 RGB + 固定 0x30 alpha
val btnRememberBgPressed = (colorInputBg and 0x00FFFFFF) or 0x30000000.toInt()
val btnRemember = TextView(context).apply {
text = "记住此页"
setTextColor(0xFFE5E7EB.toInt())
text = config.labels.ballRemember
setTextColor(colorFgPrimary)
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) // subtle translucent gray
setColor(btnRememberBgNormal)
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
@ -188,13 +228,13 @@ class FloatingHelper(
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x30FFFFFF.toInt())
setColor(btnRememberBgPressed)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt())
setColor(btnRememberBgNormal)
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
@ -206,9 +246,9 @@ class FloatingHelper(
setOnClickListener {
try {
service.rememberCurrentPage()
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show()
FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
} catch (e: Exception) {
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
FloatingTip(service, "${config.labels.rememberFail}: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
}
collapse()
}
@ -246,7 +286,7 @@ class FloatingHelper(
params.x = initialX + dx
params.y = initialY + dy
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
true
@ -255,16 +295,22 @@ class FloatingHelper(
if (!isMoving) {
toggleMenu()
} else {
// 拖动抬起时自动吸附到屏幕边缘
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = 0
params.x = -dp(1.5f)
} else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f)
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
}
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
// 位置持久化:吸附后写入 SharedPreferences
prefs.edit()
.putInt(KEY_X, params.x)
.putInt(KEY_Y, params.y)
.apply()
}
true
}
@ -276,6 +322,10 @@ class FloatingHelper(
Log.i(TAG, "记账助手悬浮窗显示成功")
} catch (e: Exception) {
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
// 修复:失败时清理状态,允许下次重试
containerView = null
bubbleView = null
menuView = null
}
}

View File

@ -6,14 +6,10 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* 浮窗提示plan.md3.8 浮窗账单提示
@ -47,6 +43,10 @@ class FloatingTip(
/** 显示浮窗提示。 */
fun show() {
try {
val config = FloatingUiConfigStore.load(context)
val bgColor = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF000000") or 0xFF000000.toInt() // 强制不透明化
val textColor = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
@ -65,42 +65,20 @@ class FloatingTip(
val container = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setBackgroundColor(0xF0333333.toInt())
setBackgroundColor(bgColor)
setPadding(32, 16, 32, 16)
}
val text = TextView(context).apply {
text = message
textSize = 13f
setTextColor(0xFFFFFFFF.toInt())
}
// 倒计时进度条
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
progress.max = 100
progress.progress = 100
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
setMargins(16, 0, 0, 0)
setTextColor(textColor)
}
container.addView(text)
container.addView(progress)
view = container
windowManager.addView(view, layoutParams)
Log.d(TAG, "FloatingTip 显示: $message")
// 倒计时动画300ms 内 progress 从 100 到 0
val steps = 30
val stepMs = durationMs / steps
var currentStep = 0
handler.post(object : Runnable {
override fun run() {
currentStep++
progress.progress = 100 - (currentStep * 100 / steps)
if (currentStep < steps) {
handler.postDelayed(this, stepMs)
}
}
})
handler.postDelayed({ dismiss() }, durationMs)
} catch (e: Exception) {
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
@ -121,7 +99,7 @@ class FloatingTip(
*/
class RepeatToast(private val context: Context, private val message: String) {
fun show() {
val tip = FloatingTip(context, "$message", FloatingTip.TipPosition.TOP, 2000L)
val tip = FloatingTip(context, message, FloatingTip.TipPosition.TOP, 2000L)
tip.show()
Log.d("RepeatToast", "重复提示: $message")
}

View File

@ -0,0 +1,211 @@
package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.Color
import android.util.Log
import com.facebook.react.bridge.ReadableMap
import org.json.JSONObject
/**
* 浮层 UI 颜色契约docs/ui-redesign-p6-plan.mdFloatingUiConfig 契约
* 字段名camelCase src/services/floatingUiConfig.ts FloatingUiConfig.colors 一一对应
* 默认值 = 现状硬编码颜色JS 未推送时行为不变注释标明出处
*/
data class FloatingUiConfigColors(
/** 按钮/选中态背景。 */ val accent: String = "#FF5E6AD2", // 原 FloatingBillView saveBg / 分段选中态
/** accent 上的文字色。 */ val accentFg: String = "#FFFFFFFF", // 原 FloatingBillView 按钮文字色
/** 卡片底色(原 0x8C050506 半透明,默认实色化到 F0 保证可读)。 */
val cardBg: String = "#F0050506", // 原 FloatingBillView containerBg 0x8C050506
/** 输入框/未选中 chip 底色。 */ val inputBg: String = "#4012131A", // 原 FloatingBillView 输入框底 0x4012131A
val fgPrimary: String = "#FFFFFFFF", // 原 FloatingBillView 输入框文字色
val fgSecondary: String = "#FF9CA3AF", // 原 FloatingBillView 未选中 tab/chip 文字色
val border: String = "#80222433", // 原 FloatingBillView 输入框/chip 描边 0x80222433
/** financial.income。 */ val income: String = "#FF10B981", // 原 FloatingBillView 收入/转账 chip 选中色
/** financial.expense。 */ val expense: String = "#FFE11D48", // 原 FloatingBillView 支出账户 chip 选中色
/** financial.transfer。 */ val transfer: String = "#FF10B981", // 原 FloatingBillView 转账转入 chip 选中色
)
/**
* 浮层 UI 文案契约
* 字段名camelCase src/services/floatingUiConfig.ts FloatingUiConfig.labels 一一对应
* 默认值 = 现状中文硬编码文案 emoji
*/
data class FloatingUiConfigLabels(
/** 浮窗标题。 */ val billTitle: String = "调整交易草稿",
val dirExpense: String = "支出",
val dirIncome: String = "收入",
val dirTransfer: String = "转账",
val amountLabel: String = "金额",
val payeeLabel: String = "交易对手",
val narrationLabel: String = "描述/备注",
val narrationHint: String = "输入交易叙述",
/** 支出方向分类行标签。 */ val categoryExpense: String = "交易分类",
/** 收入方向分类行标签。 */ val categoryIncome: String = "收入分类",
/** 转账方向第一行标签。 */ val transferTarget: String = "转入账户",
/** 支出方向账户行标签。 */ val accountExpense: String = "资金来源",
/** 收入方向账户行标签。 */ val accountIncome: String = "存入账户",
/** 转账方向账户行标签。 */ val accountTransfer: String = "转出账户",
val openApp: String = "打开应用",
val dismiss: String = "忽略",
val confirm: String = "确认入账",
/** 悬浮球「识别账单」。 */ val ballOcr: String = "识别账单",
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
/** 记住页面成功提示(不含 emoji。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
/** 失败提示前缀(原生拼接 ': ' + e.message。 */ val rememberFail: String = "记录失败",
/** BillingAccessibilityService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
)
/** 原生浮层 UI 配置JS 经 AccessibilityBridge.setFloatingUiConfig 下发)。 */
data class FloatingUiConfig(
val colors: FloatingUiConfigColors = FloatingUiConfigColors(),
val labels: FloatingUiConfigLabels = FloatingUiConfigLabels(),
)
/**
* FloatingUiConfig SharedPreferences 持久化plan原生持久化
*
* - 文件名 floating_ui_config单键 config_json 存整个 JSON
* - save 采用合并策略 load 出现存 JSON再覆盖传入键避免部分更新丢字段
* - load 逐字段 optString 缺省回退 data class 默认值任何异常返回全默认
*/
object FloatingUiConfigStore {
private const val TAG = "FloatingUiConfigStore"
private const val PREFS = "floating_ui_config"
private const val KEY = "config_json"
/** 契约内已知 colors 键save 时只写这些键)。 */
private val COLOR_KEYS = listOf(
"accent", "accentFg", "cardBg", "inputBg", "fgPrimary",
"fgSecondary", "border", "income", "expense", "transfer",
)
/** 契约内已知 labels 键save 时只写这些键)。 */
private val LABEL_KEYS = listOf(
"billTitle", "dirExpense", "dirIncome", "dirTransfer", "amountLabel",
"payeeLabel", "narrationLabel", "narrationHint", "categoryExpense",
"categoryIncome", "transferTarget", "accountExpense", "accountIncome",
"accountTransfer", "openApp", "dismiss", "confirm", "ballOcr",
"ballRemember", "rememberSuccess", "rememberFail", "pageRemembered",
"pageSignatureExists",
)
/** 保存(合并):先读出现存 JSON再覆盖传入的已知键。 */
fun save(context: Context, map: ReadableMap) {
val root = readRawJson(context)
if (map.hasKey("colors")) {
val colorsMap = map.getMap("colors")
val colorsJson = root.optJSONObject("colors") ?: JSONObject().also { root.put("colors", it) }
if (colorsMap != null) {
for (key in COLOR_KEYS) {
if (colorsMap.hasKey(key) && !colorsMap.isNull(key)) {
colorsJson.put(key, colorsMap.getString(key))
}
}
}
}
if (map.hasKey("labels")) {
val labelsMap = map.getMap("labels")
val labelsJson = root.optJSONObject("labels") ?: JSONObject().also { root.put("labels", it) }
if (labelsMap != null) {
for (key in LABEL_KEYS) {
if (labelsMap.hasKey(key) && !labelsMap.isNull(key)) {
labelsJson.put(key, labelsMap.getString(key))
}
}
}
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY, root.toString())
.apply()
}
/** 读取配置;缺失字段回退默认值,任何异常返回全默认。 */
fun load(context: Context): FloatingUiConfig {
return try {
val root = readRawJson(context)
val defaultColors = FloatingUiConfigColors()
val defaultLabels = FloatingUiConfigLabels()
val colorsJson = root.optJSONObject("colors")
val labelsJson = root.optJSONObject("labels")
val colors = FloatingUiConfigColors(
accent = colorsJson.optStringOr("accent", defaultColors.accent),
accentFg = colorsJson.optStringOr("accentFg", defaultColors.accentFg),
cardBg = colorsJson.optStringOr("cardBg", defaultColors.cardBg),
inputBg = colorsJson.optStringOr("inputBg", defaultColors.inputBg),
fgPrimary = colorsJson.optStringOr("fgPrimary", defaultColors.fgPrimary),
fgSecondary = colorsJson.optStringOr("fgSecondary", defaultColors.fgSecondary),
border = colorsJson.optStringOr("border", defaultColors.border),
income = colorsJson.optStringOr("income", defaultColors.income),
expense = colorsJson.optStringOr("expense", defaultColors.expense),
transfer = colorsJson.optStringOr("transfer", defaultColors.transfer),
)
val labels = FloatingUiConfigLabels(
billTitle = labelsJson.optStringOr("billTitle", defaultLabels.billTitle),
dirExpense = labelsJson.optStringOr("dirExpense", defaultLabels.dirExpense),
dirIncome = labelsJson.optStringOr("dirIncome", defaultLabels.dirIncome),
dirTransfer = labelsJson.optStringOr("dirTransfer", defaultLabels.dirTransfer),
amountLabel = labelsJson.optStringOr("amountLabel", defaultLabels.amountLabel),
payeeLabel = labelsJson.optStringOr("payeeLabel", defaultLabels.payeeLabel),
narrationLabel = labelsJson.optStringOr("narrationLabel", defaultLabels.narrationLabel),
narrationHint = labelsJson.optStringOr("narrationHint", defaultLabels.narrationHint),
categoryExpense = labelsJson.optStringOr("categoryExpense", defaultLabels.categoryExpense),
categoryIncome = labelsJson.optStringOr("categoryIncome", defaultLabels.categoryIncome),
transferTarget = labelsJson.optStringOr("transferTarget", defaultLabels.transferTarget),
accountExpense = labelsJson.optStringOr("accountExpense", defaultLabels.accountExpense),
accountIncome = labelsJson.optStringOr("accountIncome", defaultLabels.accountIncome),
accountTransfer = labelsJson.optStringOr("accountTransfer", defaultLabels.accountTransfer),
openApp = labelsJson.optStringOr("openApp", defaultLabels.openApp),
dismiss = labelsJson.optStringOr("dismiss", defaultLabels.dismiss),
confirm = labelsJson.optStringOr("confirm", defaultLabels.confirm),
ballOcr = labelsJson.optStringOr("ballOcr", defaultLabels.ballOcr),
ballRemember = labelsJson.optStringOr("ballRemember", defaultLabels.ballRemember),
rememberSuccess = labelsJson.optStringOr("rememberSuccess", defaultLabels.rememberSuccess),
rememberFail = labelsJson.optStringOr("rememberFail", defaultLabels.rememberFail),
pageRemembered = labelsJson.optStringOr("pageRemembered", defaultLabels.pageRemembered),
pageSignatureExists = labelsJson.optStringOr("pageSignatureExists", defaultLabels.pageSignatureExists),
)
FloatingUiConfig(colors, labels)
} catch (e: Exception) {
Log.e(TAG, "读取浮层 UI 配置失败,回退默认值: ${e.message}")
FloatingUiConfig()
}
}
/** 解析颜色字符串;失败/为空时解析 fallbackHexfallback 也不合法则返回 Color.BLACK。 */
fun parseColorOr(value: String, fallbackHex: String): Int {
if (value.isNotBlank()) {
try {
return Color.parseColor(value)
} catch (_: Exception) {}
}
return try {
Color.parseColor(fallbackHex)
} catch (_: Exception) {
Color.BLACK
}
}
/** 读出现存 JSON无数据或解析失败返回空 JSONObject。 */
private fun readRawJson(context: Context): JSONObject {
return try {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null)
if (raw.isNullOrBlank()) JSONObject() else JSONObject(raw)
} catch (_: Exception) {
JSONObject()
}
}
/** optString 包装JSON 为 null / 键缺失 / 空串时回退 default。 */
private fun JSONObject?.optStringOr(key: String, default: String): String {
if (this == null) return default
val v = optString(key, default)
return if (v.isBlank()) default else v
}
}

View File

@ -50,8 +50,8 @@ class OcrTileService : TileService() {
val pi = PendingIntent.getActivity(
this, 0,
Intent().apply {
setClassName(packageName, "com.beancount.mobile.MainActivity")
action = "com.beancount.mobile.TRIGGER_OCR"
setClassName(packageName, "$packageName.MainActivity")
action = "$packageName.TRIGGER_OCR"
flags = Intent.FLAG_ACTIVITY_NEW_TASK
},
PendingIntent.FLAG_IMMUTABLE,

View File

@ -12,7 +12,33 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.accessibility';
/**
* src/domain/constants.ts PAYMENT_PACKAGES 中提取包名列表
* 这是唯一的真相来源Kotlin 端通过此函数自动同步
*/
function readPaymentPackagesFromConstants() {
const constantsPath = path.resolve(__dirname, '../../src/domain/constants.ts');
if (!fs.existsSync(constantsPath)) return null;
const content = fs.readFileSync(constantsPath, 'utf8');
// 匹配 PAYMENT_PACKAGES = { ... } 中的 key冒号前的字符串
const match = content.match(/PAYMENT_PACKAGES:\s*Record<string,\s*string>\s*=\s*\{([\s\S]*?)\n\};/);
if (!match) return null;
const keys = [];
const keyRegex = /^(\s*)'([^']+)'\s*:/gm;
let m;
while ((m = keyRegex.exec(match[1])) !== null) {
keys.push(m[2]);
}
return keys.length > 0 ? keys : null;
}
/**
* 生成 Kotlin setOf("...", "...", ...) 代码
*/
function generateKotlinPaymentPackages(packages) {
const items = packages.map(pkg => ` "${pkg}"`).join(',\n');
return ` val PAYMENT_PACKAGES = setOf(\n${items}\n )`;
}
/** 递归复制目录。 */
function copyDir(src, dest) {
@ -26,19 +52,30 @@ function copyDir(src, dest) {
}
}
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withAccessibilityService(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.accessibility`;
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility');
const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'accessibility');
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(androidDir, f), path.join(ktDest, f));
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
}
}
// res/xml 资源
@ -46,6 +83,23 @@ function withAccessibilityService(config) {
if (fs.existsSync(resSrc)) {
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res'));
}
// 从 constants.ts 读取包名列表,自动同步到 Kotlin
const packages = readPaymentPackagesFromConstants();
if (packages) {
const ktFile = path.join(ktDest, 'BillingAccessibilityService.kt');
if (fs.existsSync(ktFile)) {
let ktContent = fs.readFileSync(ktFile, 'utf8');
// 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... )
const regex = /val PAYMENT_PACKAGES = setOf\([\s\S]*?\)/;
if (regex.test(ktContent)) {
ktContent = ktContent.replace(regex, generateKotlinPaymentPackages(packages));
fs.writeFileSync(ktFile, ktContent, 'utf8');
console.log(`[accessibility plugin] 已同步 ${packages.length} 个支付 App 包名到 Kotlin`);
}
}
}
// 确保有 accessibility_service_description 字符串资源
const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml');
if (fs.existsSync(stringsXmlPath)) {
@ -67,12 +121,11 @@ function withAccessibilityService(config) {
let content = modConfig.modResults.contents;
// 2a. 注入 importAccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
);
}
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
);
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) {
@ -93,16 +146,26 @@ function withAccessibilityService(config) {
return modConfig;
});
// 3. 注册服务到 AndroidManifest
// 3. 注册权限 + 服务到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 0. 添加 SYSTEM_ALERT_WINDOW 权限(悬浮窗必需)
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const overlayPerm = 'android.permission.SYSTEM_ALERT_WINDOW';
const overlayPermExists = manifest['uses-permission'].some(p => p.$['android:name'] === overlayPerm);
if (!overlayPermExists) {
manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } });
}
// 1. 添加无障碍服务声明
const serviceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService',
'android:name': `${PACKAGE}.BillingAccessibilityService`,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '账单识别',
'android:label': '浮记-账单识别',
'android:exported': 'false',
},
'intent-filter': [{
@ -125,7 +188,7 @@ function withAccessibilityService(config) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService'
s => s.$['android:name'] === `${PACKAGE}.BillingAccessibilityService`
);
if (!exists) {
app.service.push(serviceNode);
@ -134,7 +197,7 @@ function withAccessibilityService(config) {
// 3. 添加 OcrTileService 声明快速设置磁贴plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.OcrTileService',
'android:name': `${PACKAGE}.OcrTileService`,
'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
@ -145,7 +208,7 @@ function withAccessibilityService(config) {
}],
};
const tileExists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.OcrTileService'
s => s.$['android:name'] === `${PACKAGE}.OcrTileService`
);
if (!tileExists) {
app.service.push(tileServiceNode);

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-accessibility",
"name": "drift-ledger-plugin-accessibility",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -8,6 +8,7 @@ import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.BillingAccessibilityService
import com.beancount.mobile.accessibility.ReactContextHolder
/**
@ -15,7 +16,7 @@ import com.beancount.mobile.accessibility.ReactContextHolder
*
* 参考 AutoAccounting NotificationListenerService
* - 提取支付 App 通知的 title/text
* - 白名单过滤仅支付类 App
* - 白名单过滤复用 BillingAccessibilityService.PAYMENT_PACKAGES单一数据源
* - 关键词黑白名单JS keywordFilter 进一步过滤
* - MD5 去重JS NotificationChannel 处理避免原生持有状态
* - onListenerDisconnected requestRebind 自动重连
@ -26,26 +27,14 @@ class BillingNotificationListenerService : NotificationListenerService() {
companion object {
private const val TAG = "BillingNotification"
/** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */
private val PAYMENT_PACKAGES = setOf(
"com.eg.android.AlipayGphone",
"com.tencent.mm",
"com.unionpay",
"com.cmbchina",
"com.icbc",
"com.chinamworld.main",
"com.ccbrcb",
"com.bankcomm.Bankcomm",
)
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
runCatching {
val packageName = sbn?.packageName?.toString() ?: return
// 白名单过滤
if (!PAYMENT_PACKAGES.contains(packageName)) return
// 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES
if (!BillingAccessibilityService.PAYMENT_PACKAGES.contains(packageName)) return
val notification = sbn.notification
val extras = notification.extras

View File

@ -11,18 +11,29 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
const fs = require('fs');
const path = require('path');
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withNotificationListener(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.notification`;
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/notification');
const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'notification');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(dest, f), content, 'utf8');
}
}
return modConfig;
@ -36,9 +47,9 @@ function withNotificationListener(config) {
// 1. 添加通知监听服务
const serviceNode = {
$: {
'android:name': 'com.beancount.mobile.notification.BillingNotificationListenerService',
'android:name': `${PACKAGE}.BillingNotificationListenerService`,
'android:permission': 'android.permission.BIND_NOTIFICATION_LISTENER_SERVICE',
'android:exported': 'false',
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.notification.NotificationListenerService' } }],
@ -54,7 +65,7 @@ function withNotificationListener(config) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === 'com.beancount.mobile.notification.BillingNotificationListenerService'
s => s.$['android:name'] === `${PACKAGE}.BillingNotificationListenerService`
);
if (!exists) {
app.service.push(serviceNode);

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-notification-listener",
"name": "drift-ledger-plugin-notification-listener",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -1,9 +1,19 @@
# PP-OCRv5 (ONNX Runtime) Config Plugin
# PP-OCR (ONNX Runtime) Config Plugin
本插件在 `expo prebuild` 时注入 PP-OCRv5 本地 OCR 原生模块plan.md 决策 4
本插件在 `expo prebuild` 时注入 PP-OCR 本地 OCR 原生模块plan.md 决策 4
引擎选用 **ONNX Runtime**跨平台、微软官方、Windows 友好),替代原 NCNN 方案。
## 当前版本
**PP-OCRv6 small**det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
| ------------- | --------------- | -------------- |
| det Hmean | 75.2% | 84.1% |
| rec W-Avg | 73.7% | 81.3% |
| rec ONNX 大小 | ~17 MB | ~21 MB |
## 文件结构
```
@ -13,48 +23,46 @@ plugins/ppocr/
│ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages
└── assets/ # ONNX 模型 + 字典(需自行下载放置)
├── ppocrv5_det.onnx # 文本检测模型
├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维
└── ppocrv5_dict.txt # PP-OCRv5 多语言字典18383 字符,CTC 解码用)
├── ppocrv6_det.onnx # 文本检测模型PP-OCRv6 small
├── ppocrv6_rec.onnx # 文本识别模型PP-OCRv6 small多语言
└── ppocrv6_dict.txt # PP-OCRv6 多语言字典(CTC 解码用)
```
## 模型获取(一键下载)
社区已转好的 ONNX 版本(来自官方 Paddle 权重,无质量损失)
PP-OCRv6 官方 ONNX 模型来自 [PaddlePaddle/PP-OCRv6 系列](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6)
```bash
# 在项目根目录执行
mkdir -p plugins/ppocr/assets
cd plugins/ppocr/assets
# det 模型4.8 MB
curl -L -o ppocrv5_det.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx
# det 模型PP-OCRv6 small
curl -L -o ppocrv6_det.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx
# rec 模型16.6 MB
curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx
# rec 模型PP-OCRv6 small
curl -L -o ppocrv6_rec.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx
# PP-OCRv5 多语言字典74 KB必须与上面的 rec 模型配套)
curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt
# PP-OCRv6 多语言字典(必须与上面的 rec 模型配套)
curl -L -o ppocrv6_dict.txt \
https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv6_dict.txt
```
或用 HuggingFace CLI首次下载原生模型再转 ONNX 的方式,参见历史 git log
> ⚠️ **字典必须与 rec 模型配套**ppocrv5_rec.onnx 输出 18385 维(= 18383 字符 + blank + 特殊位),
> 必须使用 `ppocrv5_dict.txt`18383 行)。若错用旧版 `ppocr_keys_v1.txt`(仅 6623 行),
> CTC 解码会把真实字符的高索引全部丢弃,只输出形如 `'消'青'露'仰'` 的单引号穿插单字符乱码。
> 来源说明:[ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 是社区维护的 PP-OCRv5 mobile ONNX 镜像,基于官方 [PaddlePaddle/PP-OCRv5_mobile_det](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det) 与 [_rec](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_rec) 转换而来。
> ⚠️ **字典必须与 rec 模型配套**v6 字典字符集与 v5 完全不同,混用会导致 CTC 解码乱码。
> 若之前使用过 v5 模型,务必删除旧文件(`ppocrv5_det.onnx`、`ppocrv5_rec.onnx`、`ppocrv5_dict.txt`)。
## 性能配置(参考 AutoAccounting OcrProcessor.kt
| 优化项 | 配置 |
|--------|------|
| 引擎 | ONNX Runtime Android 1.20.1 |
| 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 |
| det 图像 | 最大边 960px短边压缩 720px |
| rec 图像 | 固定高度 48px |
| ABI | arm64-v8a主流设备 |
| 优化项 | 配置 |
| -------- | -------------------------------------- |
| 引擎 | ONNX Runtime Android |
| 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 |
| det 图像 | 最大边 960px短边压缩 720px |
| rec 图像 | 固定高度 48px |
| ABI | arm64-v8a主流设备 |
## 使用
@ -81,4 +89,14 @@ JS 层通过 `src/services/ocrBridge.ts` 的 `NativeOcrBridge` 调用,桥接
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧字典(如 `ppocr_keys_v1.txt`),导致新代码找不到配套字典。
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧模型/字典。
## v5 → v6 迁移说明
若从 PP-OCRv5 升级,需完成以下步骤:
1. **下载新模型**:按上述「模型获取」章节下载 v6 模型和字典
2. **删除旧文件**:移除 `ppocrv5_det.onnx`、`ppocrv5_rec.onnx`、`ppocrv5_dict.txt`
3. **代码已自动适配**`OcrModule.kt` 中的常量已更新为 v6 文件名
4. **重新 prebuild**`npx expo prebuild --clean` 确保旧资产被清理
5. **验证 tensor 名称**v6 ONNX 模型的输入 tensor 名可能与 v5 不同,若推理报错需用 Netron 检查并调整 `OcrModule.kt` 中的 `detInputs`/`recInputs` map key

View File

@ -29,11 +29,11 @@ import kotlin.math.max
import kotlin.math.min
/**
* PP-OCRv5 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin
* PP-OCRv6 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin
*
* 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线
* 模型ppocrv5_det.onnx + ppocrv5_rec.onnx ilaylow/PP_OCRv5_mobile_onnx 下载
* 字典ppocrv5_dict.txtPP-OCRv5 多语言字典18383 字符rec 模型 18385 维输出 = 字典 + blank + 特殊位
* 模型ppocrv6_det.onnx + ppocrv6_rec.onnxPP-OCRv6 small PaddlePaddle 官方 HuggingFace 下载
* 字典ppocrv6_dict.txtPP-OCRv6 多语言字典18708 字符rec 模型 18710 维输出 = 字典 + blank + 特殊位
*
* 流水线
* 1. det文本检测bitmap DB 后处理得到文本框
@ -62,6 +62,9 @@ class OcrModule(private val context: ReactApplicationContext) :
@Volatile private var initialized = false
@Volatile private var initFailed = false
/** 模型文件目录filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
@Volatile private var modelDir: String? = null
override fun getName(): String = OCR_MODULE_NAME
override fun initialize() {
@ -70,7 +73,7 @@ class OcrModule(private val context: ReactApplicationContext) :
scope.launch { initEngine() }
}
/** 从 assets 加载 det/rec ONNX 模型与字典。 */
/** 从 assets 或 filesystem 加载 det/rec ONNX 模型与字典。 */
private fun initEngine() {
lock.lock()
try {
@ -83,22 +86,45 @@ class OcrModule(private val context: ReactApplicationContext) :
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
}
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
val det = env.createSession(detBytes, opts)
val rec = env.createSession(recBytes, opts)
val dict = loadDictionary()
val dir = modelDir
val (det, rec, dict) = if (dir != null) {
// 从 filesystem 加载P8模型按需下载到本地目录
val detPath = dir + java.io.File.separator + ASSET_DET_MODEL
val recPath = dir + java.io.File.separator + ASSET_REC_MODEL
val dictPath = dir + java.io.File.separator + ASSET_DICT
Log.i(OCR_MODULE_NAME, "从 filesystem 加载模型: det=$detPath, rec=$recPath")
Triple(
env.createSession(detPath, opts),
env.createSession(recPath, opts),
loadDictionaryFromFile(dictPath)
)
} else {
// 回退:从 assets 加载(兼容未迁移的老用户)
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
Log.i(OCR_MODULE_NAME, "从 assets 加载模型(兼容模式)")
Triple(
env.createSession(detBytes, opts),
env.createSession(recBytes, opts),
loadDictionaryFromAssets()
)
}
detSession = det
recSession = rec
ortEnv = env
dictionary = dict
initialized = true
Log.i(OCR_MODULE_NAME, "PP-OCRv5 ONNX 模型加载成功det+rec, dict=${dict.size}")
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功det+rec, dict=${dict.size}")
} catch (e: Exception) {
initFailed = true
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
val dir = modelDir
if (dir != null) {
Log.e(OCR_MODULE_NAME, "请确认 $dir 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
} else {
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
}
} finally {
lock.unlock()
}
@ -108,13 +134,13 @@ class OcrModule(private val context: ReactApplicationContext) :
private val recEnv: OrtEnvironment? get() = ortEnv
/**
* 加载 ppocrv5_dict.txt 字典
* assets 加载 ppocrv6_dict.txt 字典
*
* PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始
* 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1
* 字典本身不含 blank运行时固定用 index 0 blank ctcGreedyDecode
*/
private fun loadDictionary(): List<String> {
private fun loadDictionaryFromAssets(): List<String> {
val words = mutableListOf<String>()
context.assets.open(ASSET_DICT).use { stream ->
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
@ -127,6 +153,17 @@ class OcrModule(private val context: ReactApplicationContext) :
return words
}
/** 从 filesystem 路径加载字典文件。 */
private fun loadDictionaryFromFile(path: String): List<String> {
val words = mutableListOf<String>()
java.io.File(path).bufferedReader(Charsets.UTF_8).useLines { lines ->
lines.forEach { line ->
words.add(line.trimEnd('\r', '\n'))
}
}
return words
}
/**
* 识别图片文本
* @param imageBase64 base64 编码的图片JPEG/PNG
@ -209,6 +246,18 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.resolve(initialized)
}
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
@ReactMethod
fun setModelDir(dir: String, promise: Promise) {
modelDir = dir
if (initialized) {
release()
initFailed = false
scope.launch { initEngine() }
}
promise.resolve(true)
}
// ============== 推理流水线 ==============
private fun ensureReady() {
@ -509,8 +558,9 @@ class OcrModule(private val context: ReactApplicationContext) :
*
* PaddleOCR 约定logits index 0 固定是 blank字符从 index 1
* dictionary[i] 对应模型输出 index i+1因此 dictIdx = argmaxIdx - 1
* 已用 onnxruntime 实证argmax 序列中 0 占多数 blank真实字符索引
* 90'支' idx-1 映射到 dictionary 即可正确还原中文
*
* PP-OCRv6 模型输出已经是概率分布值域 [0,1]无需额外 softmax
* 直接取 argmax 对应的值作为该时间步的置信度
*/
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
if (logits.isEmpty()) return "" to 0f
@ -527,16 +577,12 @@ class OcrModule(private val context: ReactApplicationContext) :
for (i in 1 until numClasses) {
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
}
// softmax 概率(用于置信度统计)
var expSum = 0.0
for (i in 0 until numClasses) expSum += Math.exp(logits[t][i].toDouble())
val prob = Math.exp(maxVal.toDouble()) / expSum
if (maxIdx != blankIdx && maxIdx != lastIdx) {
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
if (dictIdx in 0 until dictionary.size) {
sb.append(dictionary[dictIdx])
confSum += prob.toFloat()
confSum += maxVal // v6 输出已是概率,直接用
confCount++
}
}
@ -650,10 +696,10 @@ class OcrModule(private val context: ReactApplicationContext) :
/** rec 单行最大宽度。 */
private const val REC_MAX_WIDTH = 320
/** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx"
// PP-OCRv5 多语言识别模型的配套字典18383 字符 + 运行时 1 blank = 18385 维输出)。
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt6623会导致 CTC 解码乱码
private const val ASSET_DICT = "ppocrv5_dict.txt"
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
// PP-OCRv6 多语言识别模型的配套字典18708 字符 + 运行时 1 blank = 18710 维输出)。
// 注意:必须与 rec 模型配套,错用 v5 字典18383会导致 CTC 解码丢字符
private const val ASSET_DICT = "ppocrv6_dict.txt"
}
}

View File

@ -22,9 +22,33 @@ const {
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.ppocr';
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/** 递归复制目录prebuild 阶段执行一次。 */
/** 递归复制目录prebuild 阶段执行并替换包名。 */
function copyAndReplaceDir(src, dest, appId) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dest, entry);
if (fs.statSync(s).isDirectory()) {
copyAndReplaceDir(s, d, appId);
} else {
if (entry.endsWith('.kt') || entry.endsWith('.java')) {
let content = fs.readFileSync(s, 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(d, content, 'utf8');
} else {
fs.copyFileSync(s, d);
}
}
}
}
/** 递归复制目录(仅用于模型资源复制,不替换包名)。 */
function copyDir(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
@ -37,17 +61,24 @@ function copyDir(src, dest) {
}
function withPpOcr(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.ppocr`;
// 1+2. 复制 Kotlin 源码与 ONNX 模型/字典到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
// platformProjectRoot 在 modRequest 里(项目根的 android/ 子目录)
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码OcrModule.kt + OcrPackage.kt
copyDir(
const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'ppocr');
// Kotlin 源码并自动重命名包名
copyAndReplaceDir(
path.join(__dirname, 'android'),
path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/ppocr'),
ktDest,
appId,
);
// ONNX 模型 + 字典(若已下载)
const assetsSrc = path.join(__dirname, 'assets');
if (fs.existsSync(assetsSrc)) {
@ -65,12 +96,11 @@ function withPpOcr(config) {
let content = modConfig.modResults.contents;
// 3a. 注入 import在 package 声明行后插入)
if (!content.includes(`import ${PACKAGE}.OcrPackage`)) {
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.OcrPackage`,
);
}
content = content.replace(/^import\s+[\w.]+\.OcrPackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.OcrPackage`,
);
// 3b. 在 getPackages() 的 .apply {} 块里注入 add(OcrPackage())
if (!content.includes('add(OcrPackage())')) {

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-ppocr",
"name": "drift-ledger-plugin-ppocr",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -12,37 +12,45 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.screenshot';
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withScreenshotMonitor(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.screenshot`;
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/screenshot');
const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'screenshot');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(dest, f), content, 'utf8');
}
}
return modConfig;
},
]);
// 2. 注册 ScreenshotPackage 到 MainApplication(复制 ppocr 模式)
// 2. 注册 ScreenshotPackage 到 MainApplication
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 import
if (!content.includes(`import ${PACKAGE}.ScreenshotPackage`)) {
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
);
}
content = content.replace(/^import\s+[\w.]+\.ScreenshotPackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ScreenshotPackage`,
);
// 2b. 在 getPackages() 的 .apply {} 块里注入 add(ScreenshotPackage())
if (!content.includes('add(ScreenshotPackage())')) {

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-screenshot-monitor",
"name": "drift-ledger-plugin-screenshot-monitor",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -3,7 +3,7 @@ const fs = require('fs');
const path = require('path');
function withSizeOptimization(config) {
// 1. 在 prebuild 时修改 gradle.properties
// 1. 在 prebuild 时修改 gradle.properties (开启 R8/ProGuard 代码混淆 + 资源裁剪 + .so 库压缩)
config = withDangerousMod(config, [
'android',
async (modConfig) => {
@ -11,18 +11,42 @@ function withSizeOptimization(config) {
const propertiesPath = path.join(projectRoot, 'gradle.properties');
if (fs.existsSync(propertiesPath)) {
let content = fs.readFileSync(propertiesPath, 'utf8');
// 限制打包架构为 arm64-v8a
if (content.includes('reactNativeArchitectures=')) {
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
} else {
content += '\nreactNativeArchitectures=arm64-v8a\n';
}
// 1. 开启 R8 代码混淆与摇树裁剪
if (!content.includes('android.enableMinifyInReleaseBuilds=')) {
content += 'android.enableMinifyInReleaseBuilds=true\n';
} else {
content = content.replace(/android\.enableMinifyInReleaseBuilds=.*/, 'android.enableMinifyInReleaseBuilds=true');
}
// 1. 开启无用资源裁剪
if (!content.includes('android.enableShrinkResourcesInReleaseBuilds=')) {
content += 'android.enableShrinkResourcesInReleaseBuilds=true\n';
} else {
content = content.replace(/android\.enableShrinkResourcesInReleaseBuilds=.*/, 'android.enableShrinkResourcesInReleaseBuilds=true');
}
// 2. 开启 .so 动态库在 APK 内的高倍率压缩 (useLegacyPackaging=true)
if (!content.includes('expo.useLegacyPackaging=')) {
content += 'expo.useLegacyPackaging=true\n';
} else {
content = content.replace(/expo\.useLegacyPackaging=.*/, 'expo.useLegacyPackaging=true');
}
fs.writeFileSync(propertiesPath, content, 'utf8');
}
return modConfig;
}
]);
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
config = withDangerousMod(config, [
'android',
async (modConfig) => {
@ -30,12 +54,98 @@ function withSizeOptimization(config) {
const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
if (fs.existsSync(buildGradlePath)) {
let content = fs.readFileSync(buildGradlePath, 'utf8');
// ABI Splits 分包配置
if (!content.includes('splits {')) {
content = content.replace(
/android\s*\{/,
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
);
fs.writeFileSync(buildGradlePath, content, 'utf8');
}
// 3. 裁剪未使用的 @expo/vector-icons 矢量字体
if (!content.includes('variant.mergeAssetsProvider.configure')) {
content += `
android.applicationVariants.all { variant ->
if (variant.buildType.name == "release") {
variant.mergeAssetsProvider.configure {
doLast {
delete fileTree(dir: outputDir, includes: [
"**/fonts/AntDesign.ttf",
"**/fonts/Entypo.ttf",
"**/fonts/EvilIcons.ttf",
"**/fonts/Feather.ttf",
"**/fonts/FontAwesome*.ttf",
"**/fonts/Fontisto.ttf",
"**/fonts/Foundation.ttf",
"**/fonts/MaterialCommunityIcons.ttf",
"**/fonts/MaterialIcons.ttf",
"**/fonts/Octicons.ttf",
"**/fonts/SimpleLineIcons.ttf",
"**/fonts/Zocial.ttf"
])
}
}
}
}
`;
}
fs.writeFileSync(buildGradlePath, content, 'utf8');
}
return modConfig;
}
]);
// 3. 在 prebuild 时修改 proguard-rules.pro 保护原生插件与 ONNX 模块
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const proguardPath = path.join(projectRoot, 'app/proguard-rules.pro');
if (fs.existsSync(proguardPath)) {
let content = fs.readFileSync(proguardPath, 'utf8');
if (!content.includes('com.beancount.mobile')) {
content += `
# Size optimization: keep custom native packages & ONNX
-keep class com.beancount.mobile.** { *; }
-keep class com.example.driftledger.** { *; }
-keep class ai.onnxruntime.** { *; }
`;
fs.writeFileSync(proguardPath, content, 'utf8');
}
}
return modConfig;
}
]);
// 4. 在 prebuild 时修改 root build.gradle 强制指定所有模块的 NDK 版本以匹配 27.1.12297006
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const rootBuildGradlePath = path.join(projectRoot, 'build.gradle');
if (fs.existsSync(rootBuildGradlePath)) {
let content = fs.readFileSync(rootBuildGradlePath, 'utf8');
if (!content.includes('ndkVersion = "27.1.12297006"')) {
content += `
subprojects { project ->
def configureProject = { proj ->
if (proj.hasProperty("android")) {
proj.android {
ndkVersion = "27.1.12297006"
}
}
}
if (project.state.executed) {
configureProject(project)
} else {
project.afterEvaluate {
configureProject(project)
}
}
}
`;
fs.writeFileSync(rootBuildGradlePath, content, 'utf8');
}
}
return modConfig;

View File

@ -1,4 +1,4 @@
{
"name": "size-optimization",
"name": "drift-ledger-plugin-size-optimization",
"main": "app.plugin.js"
}

View File

@ -11,18 +11,29 @@ const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins'
const fs = require('fs');
const path = require('path');
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withSmsReceiver(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.sms`;
// 1. 复制 Kotlin 源码到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const dest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/sms');
const pkgPath = appId.replace(/\./g, '/');
const dest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'sms');
fs.mkdirSync(dest, { recursive: true });
const srcDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(srcDir, f), path.join(dest, f));
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
fs.writeFileSync(path.join(dest, f), content, 'utf8');
}
}
return modConfig;
@ -48,7 +59,7 @@ function withSmsReceiver(config) {
// 2. 添加短信 Receiver
const receiverNode = {
$: {
'android:name': 'com.beancount.mobile.sms.BillingSmsReceiver',
'android:name': `${PACKAGE}.BillingSmsReceiver`,
'android:exported': 'true',
},
'intent-filter': [{
@ -65,7 +76,7 @@ function withSmsReceiver(config) {
app.receiver = [];
}
const exists = app.receiver.some(
r => r.$['android:name'] === 'com.beancount.mobile.sms.BillingSmsReceiver'
r => r.$['android:name'] === `${PACKAGE}.BillingSmsReceiver`
);
if (!exists) {
app.receiver.push(receiverNode);

View File

@ -1,5 +1,5 @@
{
"name": "beancount-mobile-plugin-sms-receiver",
"name": "drift-ledger-plugin-sms-receiver",
"version": "0.1.0",
"private": true,
"main": "app.plugin.js"

View File

@ -39,6 +39,8 @@ export function isTransactionIntent(input: string): boolean {
return hasAmount && hasKeyword;
}
import { logger } from '../utils/logger';
/**
*
*/
@ -47,11 +49,14 @@ export async function processChatMessage(
provider: AiProvider,
conversation?: ChatConversation,
): Promise<ChatResponse> {
logger.info('aiChat', `收到 AI 助手对话请求 [文本: "${input}"]`);
try {
// 意图判定
if (isTransactionIntent(input)) {
logger.info('aiChat', '检测到记账意图,触发自然语言识别流水线');
const result = await processNaturalLanguage(input, provider);
if (result.type === 'draft') {
logger.info('aiChat', `AI 识别生成交易草稿: [时间: ${result.draft.time}, 金额: ${result.draft.amount} ${result.draft.currency}, 叙述: "${result.draft.narration}"]`);
return { type: 'bill_card', billCards: [result.draft] };
}
// 非 draft 但有意图 → 返回 AI 文本
@ -59,10 +64,13 @@ export async function processChatMessage(
}
// 自由对话
logger.info('aiChat', '未命中记账模式,发起 AI 自由问答');
const messages = buildChatContext(input, conversation);
const response = await provider.chat(messages);
logger.info('aiChat', 'AI 自由问答回复成功');
return { type: 'text', text: removeThink(response) };
} catch (e) {
logger.error('aiChat', 'AI 对话助手响应失败', e);
return { type: 'error', error: e instanceof Error ? e.message : String(e) };
}
}

View File

@ -50,6 +50,8 @@ export function calculateMonthlyStats(
};
}
import { logger } from '../utils/logger';
/**
* AI
*/
@ -59,33 +61,40 @@ export async function generateMonthlySummary(
month: number,
provider: AiProvider,
): Promise<MonthlySummaryResult> {
const stats = calculateMonthlyStats(transactions, year, month);
logger.info('aiSummary', `开始生成 AI 月度财务总结 [${year}${month}月]`);
try {
const stats = calculateMonthlyStats(transactions, year, month);
const messages = buildMonthlySummaryPrompt({
totalExpense: stats.totalExpense,
totalIncome: stats.totalIncome,
transactionCount: stats.transactionCount,
topCategories: stats.topCategories,
});
const messages = buildMonthlySummaryPrompt({
totalExpense: stats.totalExpense,
totalIncome: stats.totalIncome,
transactionCount: stats.transactionCount,
topCategories: stats.topCategories,
});
const response = await provider.chat(messages);
return {
stats,
summary: removeThink(response),
};
const response = await provider.chat(messages);
logger.info('aiSummary', `AI 月度财务总结生成成功 [${year}${month}月]`);
return {
stats,
summary: removeThink(response),
};
} catch (e) {
logger.error('aiSummary', `生成 AI 月度总结失败 [${year}${month}月]`, e);
throw e;
}
}
/** 无 AI 的纯文本总结(降级,不调 AI。 */
export function generatePlainTextSummary(stats: MonthlyStats): string {
const lines: string[] = [];
lines.push(`📊 ${stats.year}${stats.month}月财务总结`);
lines.push(`${stats.year}${stats.month}月财务总结`);
lines.push('');
lines.push(`💰 总收入:${stats.totalIncome}`);
lines.push(`💸 总支出:${stats.totalExpense}`);
lines.push(`📝 交易笔数:${stats.transactionCount}`);
lines.push(`总收入:${stats.totalIncome}`);
lines.push(`总支出:${stats.totalExpense}`);
lines.push(`交易笔数:${stats.transactionCount}`);
lines.push('');
if (stats.topCategories.length > 0) {
lines.push('🏷️ 主要支出分类:');
lines.push('主要支出分类:');
for (const cat of stats.topCategories) {
lines.push(` ${cat.category}: ${cat.amount}`);
}

View File

@ -1,53 +1,32 @@
import React from 'react';
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { AppTabBar } from '../../components/AppTabBar';
/** 底部 Tab 导航:首页/交易/报表/导入/规则/设置。 */
/** 底部 Tab 导航:首页/交易/报表/设置 + 中央AppTabBar。 */
export default function TabsLayout() {
const { theme } = useTheme();
const t = useT();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: theme.colors.accent,
tabBarInactiveTintColor: theme.colors.fgSecondary,
tabBarStyle: {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
},
}}
screenOptions={{ headerShown: false }}
tabBar={(props) => <AppTabBar {...props} />}
>
<Tabs.Screen
name="index"
options={{
title: t('tab.home'),
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
}}
options={{ title: t('tab.home') }}
/>
<Tabs.Screen
name="transactions"
options={{
title: t('tab.transactions'),
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
}}
options={{ title: t('tab.transactions') }}
/>
<Tabs.Screen
name="report"
options={{
title: t('tab.report'),
tabBarIcon: ({ color, size }) => <Ionicons name="pie-chart-outline" size={size} color={color} />,
}}
options={{ title: t('tab.report') }}
/>
<Tabs.Screen
name="settings"
options={{
title: t('tab.settings'),
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
options={{ title: t('tab.settings') }}
/>
</Tabs>
);

View File

@ -1,5 +1,10 @@
/**
* spec §7.1
* header / 5
* /account
*/
import React, { useEffect, useMemo, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore';
@ -7,44 +12,35 @@ import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { SpeedDial } from '../../components/SpeedDial';
import { TransactionCard } from '../../components/TransactionCard';
import { AccountTree } from '../../components/AccountTree';
import { TodoStrip } from '../../components/TodoStrip';
import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/netWorth';
import { groupByMonth } from '../../domain/chartStats';
import { buildAccountTree } from '../../domain/accountTree';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
import { calculateBudgetProgress } from '../../domain/budgets';
import { addDecimals, toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
export default function HomeScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const budgets = useMetadataStore(s => s.budgets);
const categories = useMetadataStore(s => s.categories);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
const currentMonth = useMemo(() => {
if (monthlyData.length === 0) return null;
return monthlyData[monthlyData.length - 1];
}, [monthlyData]);
// 1. 账户树计算
const accountNodes = useMemo(() => {
if (!ledger || !ledger.accounts) return [];
return buildAccountTree(ledger.accounts, transactions, ledger);
}, [ledger, transactions]);
const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
// 2. 周期性记账到期计算
const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []);
const dueRecurring = useMemo(() => {
return getDueRecurring(recurringTransactions, todayStr);
}, [recurringTransactions, todayStr]);
const todayStr = useMemo(() => toDateString(new Date()), []);
const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
const autoConfirmed = useRef(false);
@ -70,19 +66,34 @@ export default function HomeScreen() {
return () => { isMounted = false; };
}, [dueRecurring, addTransaction, updateRecurringTransaction]);
const handleConfirmRecurring = async (rec: any) => {
try {
const draft = instantiateRecurring(rec);
await addTransaction(draft);
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
updateRecurringTransaction(rec.id, { nextDueDate });
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
} catch (e) {
Alert.alert(t('home.recurringFail'), String(e));
}
// 月度预算剩余合计(无月度预算则不显示)
const budgetRemaining = useMemo(() => {
const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
if (monthlyBudgets.length === 0) return null;
const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
return remainings.reduce((a, b) => addDecimals([a, b]), '0');
}, [budgets, transactions, todayStr]);
// 最近 5 条(按日期+序号倒序)
const recentTxs = useMemo(() =>
transactions.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
.slice(0, 5)
.map(item => item.tx),
[transactions]);
// 分类匹配TransactionCard 图标)
const categoryIdFor = (tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
};
// 3. 千分位格式化金额
// 问候语 + 日期行
const hour = new Date().getHours();
const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
const dateLine = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
// 千分位格式化金额
const fmtAmount = (s: string) => {
if (!s || s === '0') return '¥0.00';
const num = parseFloat(s);
@ -94,105 +105,41 @@ export default function HomeScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h1.fontFamily }]}>
{t('app.name')}
</Text>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 净资产 Hero 卡片 */}
<Card
style={{
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accentLight,
shadowColor: theme.colors.accent,
shadowOpacity: 0.15,
shadowRadius: 10,
}}
>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontSize: 13, fontWeight: '600', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.netWorthTitle')}
</Text>
<Text style={[theme.typography.h1, { color: '#FFFFFF', fontSize: 32, fontWeight: '800', marginVertical: 8, fontFamily: 'monospace' }]}>
{/* 净资产白卡 */}
<Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
{fmtAmount(netWorth.netWorth)}
</Text>
<View style={styles.heroFooter}>
<View>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.assets')}
</Text>
<Text style={[theme.typography.h3, { color: '#FFFFFF', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.assets)}
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.liabilities')}
</Text>
<Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.liabilities)}
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
</Text>
</View>
{budgetRemaining !== null && (
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
{fmtAmount(budgetRemaining)}
</Text>
</View>
)}
</View>
</Card>
{/* Bento 双格排列 */}
{(dueRecurring.length > 0 || currentMonth) && (
<View style={styles.bentoRow}>
{dueRecurring.length > 0 ? (
<Card title={t('home.dueRecurring')} style={styles.bentoCol}>
<View style={styles.recurringList}>
{dueRecurring.slice(0, 2).map(rec => (
<View key={rec.id} style={[styles.recurringItem, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.caption.fontFamily }]} numberOfLines={1}>
{rec.name}
</Text>
<Pressable
onPress={() => handleConfirmRecurring(rec)}
style={({ pressed }) => [
styles.confirmBtnMini,
{
backgroundColor: theme.colors.bgTertiary,
borderColor: theme.colors.border,
borderRadius: theme.radii.sm,
opacity: pressed ? 0.7 : 1,
},
]}
>
<Text style={{ color: theme.colors.accent, fontSize: 10, fontWeight: '800', textAlign: 'center' }}>
{rec.draft.postings[0]?.amount} CNY
</Text>
</Pressable>
</View>
))}
</View>
</Card>
) : null}
{currentMonth ? (
<Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}>
<View style={styles.bentoStats}>
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.income')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
+{fmtAmount(currentMonth.income.toString())}
</Text>
</View>
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.expense')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
-{fmtAmount(currentMonth.expense.toString())}
</Text>
</View>
</View>
</Card>
) : null}
</View>
)}
<TodoStrip />
{/* 月度趋势 */}
{monthlyData.length > 0 && (
@ -201,18 +148,18 @@ export default function HomeScreen() {
</Card>
)}
{/* 账户与资产余额树 */}
{accountNodes.length > 0 && (
<Card title={t('home.accountTree')}>
<AccountTree nodes={accountNodes} />
{/* 最近 5 条交易 */}
{recentTxs.length > 0 && (
<Card title={t('home.recentTransactions')}>
{recentTxs.map(tx => (
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
))}
<Pressable onPress={() => router.push('/(tabs)/transactions')} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} </Text>
</Pressable>
</Card>
)}
</ScrollView>
<SpeedDial actions={[
{ key: 'new', icon: 'create-outline', label: t('home.newTransaction'), onPress: () => router.push('/transaction/new') },
{ key: 'ocr', icon: 'camera-outline', label: t('home.ocrScan'), onPress: () => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }) },
{ key: 'import', icon: 'download-outline', label: t('tab.import'), onPress: () => router.push('/import') },
]} />
</SafeAreaView>
);
}
@ -220,12 +167,8 @@ export default function HomeScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 96 },
heroFooter: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)', paddingTop: 10, marginTop: 4 },
bentoRow: { flexDirection: 'row', gap: 12 },
bentoCol: { flex: 1 },
recurringList: { gap: 8 },
recurringItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 6, borderBottomWidth: StyleSheet.hairlineWidth },
confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth },
bentoStats: { gap: 8 },
content: { padding: 16, paddingBottom: 40 },
heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
heroStat: { gap: 2 },
viewAll: { alignItems: 'center', paddingVertical: 6 },
});

View File

@ -1,11 +1,10 @@
/**
* plan.md5.2 +5.3
* plan.md5.2 +5.3 P4 spec §7.3
*
*
* - /
* - ////
* - react-native-view-shot
* - AI generatePlainTextSummary / generateMonthlySummary
* - anchor + //periodNav
* - ////
* - AI react-native-view-shot
*/
import React, { useMemo, useRef, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
@ -20,7 +19,7 @@ import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { MonthlyReport } from '../../components/charts/MonthlyReport';
import { BottomSheet } from '../../components/BottomSheet';
import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarView } from '../../components/CalendarView';
import { TransactionCard } from '../../components/TransactionCard';
@ -28,10 +27,12 @@ import { NetWorthChart } from '../../components/charts/NetWorthChart';
import { AnnualReport } from '../../components/charts/AnnualReport';
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
import { BaseOpenAIProvider } from '../../domain/ai';
import { addDecimals, negateDecimal, toDateString } from '../../domain/decimal';
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/periodNav';
export default function ReportScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
@ -40,16 +41,12 @@ export default function ReportScreen() {
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
const aiModel = useSettingsStore(s => s.aiModel);
// Tab 状态
const [activeTab, setActiveTab] = useState<'weekly' | 'monthly' | 'annual'>('monthly');
// 周期 + 单一 anchor 日期P4替代旧版年/月/周三套独立切换状态)
const [period, setPeriod] = useState<ReportPeriod>('monthly');
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
// 月份切换状态
const now = new Date();
const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
// 周切换状态
const [viewWeekDate, setViewWeekDate] = useState(new Date());
// ⋯ 菜单
const [menuOpen, setMenuOpen] = useState(false);
// 日历选中日期用于明细展开
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
@ -63,111 +60,60 @@ export default function ReportScreen() {
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
// 周报表的时间范围:计算周一和周日
const weeklyRange = useMemo(() => {
const d = new Date(viewWeekDate);
const day = d.getDay(); // 0 is Sunday, 1-6 is Mon-Sat
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diffToMonday);
monday.setHours(0,0,0,0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23,59,59,999);
// 计算 ISO 周数
const date = new Date(monday.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
const week1 = new Date(date.getFullYear(), 0, 4);
const weekNum = 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
// anchor 所在周期的起止日期与周期内交易
const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
const rangeTxs = useMemo(
() => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
[transactions, range],
);
return {
startStr: monday.toISOString().slice(0, 10),
endStr: sunday.toISOString().slice(0, 10),
label: `${monday.getFullYear()}年第${weekNum}周 (${monday.getMonth()+1}/${monday.getDate()} ~ ${sunday.getMonth()+1}/${sunday.getDate()})`
};
}, [viewWeekDate]);
// 周期收支统计Income 腿取负、Expenses 腿原值,与旧 weeklyStats/monthlyStats 同算法)
const rangeStats = useMemo(() => {
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
for (const tx of rangeTxs) {
for (const p of tx.postings) {
if (!p.amount) continue;
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
}
}
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
return { income, expense, net: income - expense, count: rangeTxs.length };
}, [rangeTxs]);
const changeWeek = (days: number) => {
setSelectedReportDate(null);
const next = new Date(viewWeekDate);
next.setDate(next.getDate() + days);
setViewWeekDate(next);
};
// 周期切换器 label
const periodLabel = useMemo(() => {
if (period === 'weekly') {
const monday = range.start;
const sunday = range.end;
return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5, 7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5, 7))}/${Number(sunday.slice(8))})`;
}
if (period === 'monthly') return anchor.slice(0, 7);
return `${anchor.slice(0, 4)}`;
}, [period, anchor, range]);
// 净资产趋势的日期序列(基于选中月份,往前 6 个月)
// 净资产趋势的日期序列(基于 anchor 所在月,往前 6 个月)
const netWorthDates = useMemo(() => {
const dates: string[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(viewYear, viewMonth - 1 - i, 1);
dates.push(d.toISOString().slice(0, 10));
const d = new Date(Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1 - i, 1);
dates.push(toDateString(d));
}
return dates;
}, [viewYear, viewMonth]);
}, [anchor]);
const prevMonth = () => {
const shift = (delta: number) => {
setSelectedReportDate(null);
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); }
else setViewMonth(m => m - 1);
setAnchor(a => shiftAnchor(a, period, delta));
};
const nextMonth = () => {
setSelectedReportDate(null);
if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 1); }
else setViewMonth(m => m + 1);
};
// 过滤后的交易
const weeklyTxs = useMemo(() => {
return transactions.filter(t => t.date >= weeklyRange.startStr && t.date <= weeklyRange.endStr);
}, [transactions, weeklyRange]);
const monthlyTxs = useMemo(() => {
const prefix = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
return transactions.filter(t => t.date.startsWith(prefix));
}, [transactions, viewYear, viewMonth]);
const annualTxs = useMemo(() => {
return transactions.filter(t => t.date.startsWith(String(viewYear)));
}, [transactions, viewYear]);
// 周收支统计
const weeklyStats = useMemo(() => {
let income = 0;
let expense = 0;
for (const t of weeklyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
}
}
return { income, expense, net: income - expense, count: weeklyTxs.length };
}, [weeklyTxs]);
// 月度收支统计
const monthlyStats = useMemo(() => {
let income = 0;
let expense = 0;
for (const t of monthlyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
const amt = parseFloat(p.amount);
if (p.account.startsWith('Income')) income += -amt;
if (p.account.startsWith('Expenses')) expense += amt;
}
}
return { income, expense, net: income - expense, count: monthlyTxs.length };
}, [monthlyTxs]);
// 选中日期的交易明细
const selectedDayTxs = useMemo(() => {
if (!selectedReportDate) return [];
return monthlyTxs.filter(t => t.date.slice(0, 10) === selectedReportDate);
}, [monthlyTxs, selectedReportDate]);
return rangeTxs.filter(tx => tx.date.slice(0, 10) === selectedReportDate);
}, [rangeTxs, selectedReportDate]);
// 千分位格式化金额
const fmtAmount = (num: number) => {
@ -190,9 +136,11 @@ export default function ReportScreen() {
}
};
// AI / 纯文本月度总结
// AI / 纯文本月度总结(基于 anchor 所在月)
const handleMonthlySummary = async () => {
const stats = calculateMonthlyStats(transactions, viewYear, viewMonth);
const year = Number(anchor.slice(0, 4));
const month = Number(anchor.slice(5, 7));
const stats = calculateMonthlyStats(transactions, year, month);
if (aiEnabled && aiApiKey && aiBaseUrl) {
try {
const provider = new (class extends BaseOpenAIProvider {})({
@ -202,7 +150,7 @@ export default function ReportScreen() {
baseUrl: aiBaseUrl,
model: aiModel || 'glm-4-flash',
});
const result = await generateMonthlySummary(transactions, viewYear, viewMonth, provider);
const result = await generateMonthlySummary(transactions, year, month, provider);
setSummaryText(result.summary || generatePlainTextSummary(stats));
} catch {
setSummaryText(generatePlainTextSummary(stats));
@ -213,20 +161,22 @@ export default function ReportScreen() {
setSummaryModal(true);
};
const monthLabel = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
const monthLabel = anchor.slice(0, 7);
const anchorYear = Number(anchor.slice(0, 4));
const anchorMonth = Number(anchor.slice(5, 7));
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
<View style={styles.headerActions}>
<Pressable onPress={handleMonthlySummary} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="sparkles-outline" size={22} color={theme.colors.accent} />
</Pressable>
<Pressable onPress={handleExportImage} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
</Pressable>
</View>
<Pressable
onPress={() => setMenuOpen(true)}
style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
accessibilityRole="button"
accessibilityLabel={t('report.menu')}
>
<Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
</Pressable>
</View>
{/* Tab 选择切换栏 */}
@ -239,20 +189,20 @@ export default function ReportScreen() {
<Pressable
key={tab.key}
onPress={() => {
setActiveTab(tab.key);
setPeriod(tab.key);
setSelectedReportDate(null);
}}
style={[
styles.tabBtn,
activeTab === tab.key && { borderBottomColor: theme.colors.accent }
period === tab.key && { borderBottomColor: theme.colors.accent }
]}
>
<Text
style={[
theme.typography.body,
{
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: activeTab === tab.key ? '700' : '400',
color: period === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: period === tab.key ? '700' : '400',
paddingVertical: 10
}
]}
@ -263,57 +213,25 @@ export default function ReportScreen() {
))}
</View>
{/* 日期选择切换器 */}
{activeTab === 'weekly' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={() => changeWeek(-7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{weeklyRange.label}
</Text>
<Pressable onPress={() => changeWeek(7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{activeTab === 'monthly' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={prevMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{monthLabel}
</Text>
<Pressable onPress={nextMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{activeTab === 'annual' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={() => setViewYear(y => y - 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{viewYear}
</Text>
<Pressable onPress={() => setViewYear(y => y + 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{/* 统一周期切换器 */}
<View style={styles.monthSwitcher}>
<Pressable onPress={() => shift(-1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{periodLabel}
</Text>
<Pressable onPress={() => shift(1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
<ScrollView
ref={scrollRef}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
>
{/* 数据空值判断 */}
{((activeTab === 'weekly' && weeklyTxs.length === 0) ||
(activeTab === 'monthly' && monthlyTxs.length === 0) ||
(activeTab === 'annual' && annualTxs.length === 0)) ? (
{rangeTxs.length === 0 ? (
<Card title={t('report.title')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('report.empty')}
@ -321,94 +239,94 @@ export default function ReportScreen() {
</Card>
) : (
<>
{/* 周报表看板 */}
{activeTab === 'weekly' && (
{/* 周/月收支看板 */}
{period !== 'annual' && (
<>
<Card title={t('report.weeklyTitle')}>
<Card title={t(period === 'weekly' ? 'report.weeklyTitle' : 'report.monthlyTitle')}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(weeklyStats.expense)}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyExpense' : 'report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(-rangeStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyNet' : 'report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.weeklyStats', { count: weeklyStats.count })}
{t(period === 'weekly' ? 'report.weeklyStats' : 'report.monthlyStats', { count: rangeStats.count })}
</Text>
</Card>
<CategoryPie transactions={weeklyTxs} />
</>
)}
<CategoryPie transactions={rangeTxs} />
{/* 月度报表看板 */}
{activeTab === 'monthly' && (
<>
<Card title={`${monthLabel} 收支报告`}>
<View style={styles.statsRow}>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.income)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>-{fmtAmount(monthlyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.monthlyStats', { count: monthlyStats.count })}
</Text>
</Card>
{/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
{period === 'monthly' && (
<>
<CalendarView
transactions={transactions}
year={anchorYear}
month={anchorMonth}
onDayPress={setSelectedReportDate}
/>
<CategoryPie transactions={monthlyTxs} />
<CalendarView
transactions={transactions}
year={viewYear}
month={viewMonth}
onDayPress={setSelectedReportDate}
/>
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
{selectedDayTxs.map(t => (
<TransactionCard
key={t.id}
transaction={t}
onPress={() => router.push(`/transaction/${t.id}`)}
/>
))}
{selectedDayTxs.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
{t('calendar.noDayTx')}
</Text>
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
{selectedDayTxs.map(tx => (
<TransactionCard
key={tx.id}
transaction={tx}
onPress={() => router.push(`/transaction/${tx.id}`)}
/>
))}
{selectedDayTxs.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
{t('calendar.noDayTx')}
</Text>
)}
</Card>
)}
</Card>
)}
<NetWorthChart transactions={transactions} dates={netWorthDates} />
<NetWorthChart transactions={transactions} dates={netWorthDates} />
</>
)}
</>
)}
{/* 年度报表看板 */}
{activeTab === 'annual' && (
<AnnualReport transactions={transactions} year={viewYear} />
{period === 'annual' && (
<AnnualReport transactions={transactions} year={anchorYear} />
)}
</>
)}
</ScrollView>
{/* ⋯ 菜单 */}
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
<Pressable
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}
accessibilityRole="button"
accessibilityLabel={t('report.aiSummary')}
>
<Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.aiSummary')}</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
onPress={() => { setMenuOpen(false); handleExportImage(); }}
accessibilityRole="button"
accessibilityLabel={t('report.exportImage')}
>
<Ionicons name="share-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.exportImage')}</Text>
</Pressable>
</BottomSheet>
{/* AI 总结弹窗 */}
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
<View style={commonStyles.modalOverlay}>
@ -434,7 +352,6 @@ export default function ReportScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
headerActions: { flexDirection: 'row', gap: 16 },
iconBtn: { padding: 4 },
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
@ -443,4 +360,5 @@ const styles = StyleSheet.create({
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 },
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
});

View File

@ -1,11 +1,12 @@
import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, type Href } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Touchable } from '../../components/Touchable';
export default function SettingsScreen() {
const { theme } = useTheme();
@ -13,55 +14,69 @@ export default function SettingsScreen() {
const router = useRouter();
const renderNavLink = (icon: keyof typeof Ionicons.glyphMap, label: string, path: Href) => (
<Pressable
<Touchable
onPress={() => router.push(path)}
accessibilityRole="link"
accessibilityLabel={label}
style={({ pressed }) => [
style={[
styles.navRow,
{
borderBottomColor: theme.colors.divider,
opacity: pressed ? 0.6 : 1,
},
]}
>
<Ionicons name={icon} size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12, fontFamily: theme.typography.body.fontFamily }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12 }]}>
{label}
</Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable>
</Touchable>
);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('settings.title')}</Text>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.settings')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 数据与基础配置跳转 */}
<Card title={t('settings.dataManagement')}>
{/* 账户与分类 */}
<Card title={t('settings.groupAccount')}>
<View style={styles.listGroup}>
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('wallet-outline', t('account.title'), '/account')}
{renderNavLink('list', t('settings.categories'), '/category')}
{renderNavLink('pricetags', t('settings.tags'), '/tag')}
{renderNavLink('pie-chart', t('settings.budgets'), '/budget')}
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)}
{renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)}
</View>
</Card>
{/* 系统及高级配置跳转 */}
<Card title={t('settings.systemFeaturesTitle')}>
{/* 记账自动化 */}
<Card title={t('settings.groupAutomation')}>
<View style={styles.listGroup}>
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
{renderNavLink('cloud-upload-outline', t('settings.syncSettingsTitle'), '/settings/sync')}
{renderNavLink('phone-portrait-outline', t('settings.preferencesTitle'), '/settings/preferences')}
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
{renderNavLink('download-outline', t('settings.import'), '/import' as Href)}
</View>
</Card>
{/* 数据 */}
<Card title={t('settings.groupData')}>
<View style={styles.listGroup}>
{renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')}
{renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)}
{renderNavLink('document-text-outline', t('settings.appLogs'), '/settings/logs' as Href)}
</View>
</Card>
{/* 偏好 */}
<Card title={t('settings.groupPreferences')}>
<View style={styles.listGroup}>
{renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')}
{renderNavLink('cog-outline', t('settings.llmTitle'), '/settings/ai')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
</View>
</Card>

View File

@ -1,71 +1,105 @@
/**
* plan.md1.4
*
* :
* - SearchBar (narration/payee)
* - (///)
* - useSearch
* - TransactionCard +
* -
* spec §7.2 + 线
* chip FilterSheet//
* // + /
* /settings/diagnostics
*/
import React, { useMemo, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { Alert, Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { SearchBar } from '../../components/SearchBar';
import { TransactionCard } from '../../components/TransactionCard';
import { Ionicons } from '@expo/vector-icons';
import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
import { groupTransactionsByDate } from '../../components/transactionGroups';
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
import { toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
export default function TransactionsScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
const categories = useMetadataStore(s => s.categories);
const [keyword, setKeyword] = useState('');
const [direction, setDirection] = useState<DirectionFilter>('all');
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [accountFilter, setAccountFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
const allTransactions = useMemo(() => {
if (!ledger?.transactions) return [];
return ledger.transactions
.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => {
const dateCompare = b.tx.date.localeCompare(a.tx.date);
if (dateCompare !== 0) return dateCompare;
return b.idx - a.idx;
})
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
.map(item => item.tx);
}, [ledger]);
const allAccounts = useMemo(() => {
const accts = new Set<string>();
for (const tx of allTransactions) {
for (const p of tx.postings) accts.add(p.account);
}
return Array.from(accts).sort();
}, [allTransactions]);
const filters: SearchFilters = useMemo(() => ({
keyword: keyword || undefined,
direction: direction === 'all' ? undefined : direction,
account: accountFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
}), [keyword, direction, accountFilter, dateFrom, dateTo]);
account: advanced.account || undefined,
dateFrom: advanced.dateFrom || undefined,
dateTo: advanced.dateTo || undefined,
amountMin: advanced.amountMin || undefined,
amountMax: advanced.amountMax || undefined,
}), [keyword, direction, advanced]);
const filtered = useSearch(allTransactions, filters);
const sections = useMemo(() =>
groupTransactionsByDate(filtered).map(g => ({ ...g, data: g.items })),
[filtered]);
const advancedActive = advanced.account !== '' || advanced.dateFrom !== '' || advanced.dateTo !== '' || advanced.amountMin !== '' || advanced.amountMax !== '';
const todayStr = toDateString(new Date());
const yesterdayStr = toDateString(new Date(Date.now() - 86400000));
const dateLabel = (date: string) =>
date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;
const categoryIdFor = (tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
};
// 左滑:复制(预填今天的录入页)
const handleDuplicate = (tx: Transaction) => {
const draftJson = JSON.stringify({
date: todayStr,
payee: tx.payee,
narration: tx.narration,
tags: tx.tags,
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
});
useNumpadUiStore.getState().open({ draftJson });
};
// 左滑:删除(确认后从 main.bean 移除)
const handleDelete = (tx: Transaction) => {
Alert.alert(
t('transactions.deleteTitle'),
t('transactions.deleteMessage', { name: tx.narration || tx.payee || tx.date.slice(0, 10) }),
[
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => { deleteTransaction(tx.raw).catch(e => Alert.alert(t('common.error'), String(e))); } },
],
);
};
const directionTabs: { key: DirectionFilter; label: string }[] = [
{ key: 'all', label: t('transactions.filterAll') },
@ -80,150 +114,79 @@ export default function TransactionsScreen() {
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
</View>
<SearchBar
value={keyword}
onChangeText={setKeyword}
placeholder={t('transactions.searchPlaceholder')}
/>
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} />
{/* 方向筛选 */}
{/* 方向筛选 + 高级筛选入口 */}
<View style={styles.filterRow}>
{directionTabs.map(tab => (
<Pressable
key={tab.key}
onPress={() => setDirection(tab.key)}
style={[
commonStyles.chip,
direction === tab.key && commonStyles.chipActive,
]}
>
<Text style={[
commonStyles.chipText,
direction === tab.key && commonStyles.chipTextActive,
]}>
{tab.label}
</Text>
<Pressable key={tab.key} onPress={() => setDirection(tab.key)}
style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
<Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
</Pressable>
))}
</View>
{/* 高级筛选切换 */}
<View style={[styles.filterRow, { paddingBottom: 0 }]}>
<View style={styles.flex1} />
<Pressable
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)}
style={({ pressed }) => [
commonStyles.chip,
showAdvancedFilters && commonStyles.chipActive,
{ opacity: pressed ? 0.8 : 1 },
]}
onPress={() => setFilterSheetOpen(true)}
style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
accessibilityRole="button"
accessibilityLabel={t('transactions.filter')}
>
<Text style={[
commonStyles.chipText,
showAdvancedFilters && commonStyles.chipTextActive,
{ fontSize: 12 },
]}>
{t('transactions.advancedFilter')}
</Text>
<Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
<Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
</Pressable>
</View>
{/* 高级筛选面板 */}
{showAdvancedFilters && (
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 6 }}>
{/* 账户筛选 */}
<TextInput
value={accountFilter}
onChangeText={setAccountFilter}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 日期范围 */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={dateFrom}
onChangeText={setDateFrom}
placeholder={t('transactions.dateFrom')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
<TextInput
value={dateTo}
onChangeText={setDateTo}
placeholder={t('transactions.dateTo')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
</View>
</View>
)}
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
<SectionList
sections={sections}
keyExtractor={(item, index) => `${item.id}-${index}`}
renderItem={({ item: tx }) => (
<TransactionCard
<SwipeableTransactionCard
transaction={tx}
categoryId={categoryIdFor(tx)}
showDate={false}
onPress={() => router.push(`/transaction/${tx.id}`)}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/>
)}
ListHeaderComponent={
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
renderSectionHeader={({ section }) => (
<View style={styles.sectionHeader}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{dateLabel(section.date)}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
</Text>
</View>
)}
ListHeaderComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
</Text>
}
ListEmptyComponent={
<Card title={t('transactions.noMatchTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{allTransactions.length === 0
? t('transactions.noMatchEmpty')
: t('transactions.noMatchFiltered')}
{allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
</Text>
</Card>
}
ListFooterComponent={
<View>
{/* 诊断(折叠) */}
<Pressable
onPress={() => setShowDiagnostics(!showDiagnostics)}
style={[styles.diagToggle, { flexDirection: 'row', alignItems: 'center', gap: 4 }]}
>
<Ionicons
name={showDiagnostics ? 'chevron-down-outline' : 'chevron-forward-outline'}
size={14}
color={theme.colors.fgSecondary}
/>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('diagnostics.title')}
</Text>
</Pressable>
{showDiagnostics && (
<Card title={t('diagnostics.title')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('diagnostics.hint')}
</Text>
</Card>
)}
</View>
}
contentContainerStyle={styles.content}
stickySectionHeadersEnabled={false}
initialNumToRender={10}
maxToRenderPerBatch={10}
windowSize={5}
/>
<FilterSheet
visible={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)}
values={advanced}
onChange={setAdvanced}
onReset={() => setAdvanced(EMPTY_FILTERS)}
/>
</SafeAreaView>
);
}
@ -231,7 +194,9 @@ export default function TransactionsScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8, alignItems: 'center' },
filterBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
flex1: { flex: 1 },
content: { padding: 16, paddingTop: 8 },
diagToggle: { paddingVertical: 12, alignItems: 'center' },
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
});

View File

@ -67,6 +67,7 @@ export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
</Text>
{data.stack && (
<View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
{/* monospace 有意保留堆栈文本需要等宽对齐P5 审计允许项) */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}>
{data.stack}
</Text>

View File

@ -3,47 +3,80 @@ import { StatusBar } from 'expo-status-bar';
import { Stack } from 'expo-router';
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider, useTheme } from '../theme';
import { useLedgerStore } from '../store/ledgerStore';
import { useImportStore } from '../store/importStore';
import { useSettingsStore } from '../store/settingsStore';
import { useMetadataStore } from '../store/metadataStore';
import { parseDecimal } from '../domain/decimal';
import { useAutomationStore } from '../store/automationStore';
import { FileSystemBackend } from '../services/fileSystemBackend';
import { useNumpadUiStore } from '../store/numpadUiStore';
import { useT } from '../i18n';
import { LockScreen } from '../components/LockScreen';
import { NumpadSheetHost } from '../components/NumpadSheetHost';
import { OnboardingScreen } from './_onboarding';
import { setupDeepLinking } from '../services/deepLink';
import { initPersistence } from '../store/storePersistence';
import { initPersistence, flushPersistence } from '../store/storePersistence';
import { buildPostings } from '../domain/transactionBuilder';
import * as FileSystem from 'expo-file-system/legacy';
import { useRouter } from 'expo-router';
import { parseNotification } from '../services/notification';
import { parseSms } from '../services/sms';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts } from '../services/automationPipeline';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline';
import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { pushFloatingUiConfig } from '../services/floatingUiConfig';
import { ensureOcrModels } from '../services/modelDownloader';
import { logger } from '../utils/logger';
import {
useFonts,
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
} from '@expo-google-fonts/quicksand';
import {
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
} from '@expo-google-fonts/caveat';
import { expoLogBackend } from '../services/logBackend';
let lastSignature = '';
let lastProcessedTime = 0;
/**
* UI P6 FloatingUiConfig
* / / colors + labels
* ThemeProvider null
*/
function FloatingUiConfigSyncer() {
const { theme } = useTheme();
const locale = useSettingsStore(s => s.locale);
const t = useT();
useEffect(() => {
// useT() 每次渲染返回新 bind故依赖用 locale 而非 t
// locale 变化时 useT 内部已将 i18n.locale 指向新语言
void pushFloatingUiConfig(theme, t);
}, [theme, locale]);
return null;
}
/** 原生悬浮窗跳转 App 事件类型 */
interface NativeOpenAppEvent {
draftId?: string;
confirmed?: boolean;
account?: string;
category?: string;
amount?: string;
time?: string;
direction?: string;
merchant?: string;
narration?: string;
currency?: string;
}
function sanitizeLogText(text: string): string {
if (!text) return '';
// 脱敏验证码 (4-6位) 及长卡号/身份证 (8位以上数字)
return text
.replace(/\b(\d{4,6})\b(?=.*(?:验证码|动态码|校验码|code|Code))/gi, '***')
.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1****$2');
}
/** 示例账本(后续由文件选择器导入,当前 demo 用内置)。 */
const SAMPLE_LEDGER = {
path: 'main.bean',
content: `option "operating_currency" "CNY"
include "mobile.bean"
`,
};
@ -53,16 +86,6 @@ function AppShell() {
const t = useT();
const router = useRouter();
const [fontsLoaded, fontError] = useFonts({
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
});
const loadLedger = useLedgerStore(s => s.loadLedger);
const setContext = useImportStore(s => s.setContext);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
@ -72,8 +95,15 @@ function AppShell() {
const [privacyOverlay, setPrivacyOverlay] = useState(false);
const lastMainFileRef = useRef<{ modificationTime?: number; size?: number } | null>(null);
const persistenceInitRef = useRef(false);
const lastCheckTimeRef = useRef(0);
const checkAndReloadLedger = useCallback(async () => {
// 节流2 秒内不重复检查
const now = Date.now();
if (now - lastCheckTimeRef.current < 2000) return;
lastCheckTimeRef.current = now;
const mainPath = FileSystem.documentDirectory + 'main.bean';
try {
const info = await FileSystem.getInfoAsync(mainPath);
@ -105,31 +135,27 @@ function AppShell() {
}, [loadLedger, setContext]);
useEffect(() => {
if (persistenceInitRef.current) return;
persistenceInitRef.current = true;
// 1. 初始化持久化并还原数据
initPersistence().then(async () => {
const mainPath = FileSystem.documentDirectory + 'main.bean';
const mobilePath = FileSystem.documentDirectory + 'mobile.bean';
// 初始化磁盘日志持久化系统
logger.initFileBackend(expoLogBackend).catch(e => {
logger.warn('layout', '磁盘日志初始化失败', e);
});
try {
// 一次性合并迁移:将旧的 mobile.bean 内容追加到 main.bean 并物理删除它
const mobileInfo = await FileSystem.getInfoAsync(mobilePath);
if (mobileInfo.exists) {
const mobileContent = await FileSystem.readAsStringAsync(mobilePath);
if (mobileContent.trim()) {
const mainInfo = await FileSystem.getInfoAsync(mainPath);
let mainContent = '';
if (mainInfo.exists) {
mainContent = await FileSystem.readAsStringAsync(mainPath);
}
const mergedContent = `${mainContent.trimEnd()}\n\n${mobileContent.trim()}\n`.trim();
await FileSystem.writeAsStringAsync(mainPath, mergedContent);
logger.info('layout', '已成功将旧 mobile.bean 交易追加合并至 main.bean');
}
await FileSystem.deleteAsync(mobilePath, { idempotent: true });
}
} catch (e) {
logger.error('layout', '合并旧 mobile.bean 数据失败', e);
}
// 加载已处理的交易键(跨会话去重)
await loadProcessedTxKeys();
// P8确保 OCR 模型文件可用(首次启动从 APK assets 拷贝到 filesDir
ensureOcrModels().catch(e => {
logger.warn('layout', 'OCR 模型初始化失败(非关键路径,截图 OCR 将降级)', e);
});
const mainPath = FileSystem.documentDirectory + 'main.bean';
// 架构说明main.bean 作为主账本文件,包含所有 Beancount 指令open/close/option/include 等),
// App 直接对其进行读写与解析。
// 2. 检查并读取本地真实主账本
FileSystem.getInfoAsync(mainPath).then(async (info) => {
@ -147,6 +173,14 @@ function AppShell() {
const completed = useSettingsStore.getState().onboardingCompleted;
const locked = useSettingsStore.getState().appLockEnabled;
const floatingEnabled = useSettingsStore.getState().floatingBallEnabled;
// 开启/初始化时同步悬浮球开关至原生无障碍服务
const bridge = getAccessibilityBridge();
if (bridge) {
bridge.setFloatingBallEnabled(floatingEnabled).catch(() => {});
}
if (!completed) {
setPhase('onboarding');
} else if (locked) {
@ -156,7 +190,7 @@ function AppShell() {
}
}).catch((e) => {
// 加载失败时仍进入引导流程(而非跳过),让用户有机会重新导入账本
console.warn('[layout] Ledger load failed:', e);
logger.warn('layout', `Ledger load failed: ${e instanceof Error ? e.message : String(e)}`);
setPhase('onboarding');
});
});
@ -168,7 +202,7 @@ function AppShell() {
if (phase === 'ready') {
const cleanup = setupDeepLinking((action) => {
if (action.type === 'add-transaction') {
router.push('/transaction/new');
useNumpadUiStore.getState().open();
} else if (action.type === 'open-tab') {
if (action.tab === 'home') router.push('/(tabs)');
else if (action.tab === 'transactions') router.push('/(tabs)/transactions');
@ -178,7 +212,7 @@ function AppShell() {
} else if (action.type === 'import-csv') {
router.push('/import');
} else if (action.type === 'ocr-camera' || action.type === 'ocr-image') {
router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } });
useNumpadUiStore.getState().open({ autoOcr: true });
} else if (action.type === 'voice-input') {
router.push('/(tabs)/settings');
}
@ -196,13 +230,14 @@ function AppShell() {
const subscriptions = [
DeviceEventEmitter.addListener('billingNotification', (event) => {
try {
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${event.text}`);
const safeText = sanitizeLogText(event.text || '');
logger.debug('layout', `收到原生通知, 包名: ${event.packageName}, 标题: ${event.title}, 内容: ${safeText}`);
const bill = parseNotification(event);
if (bill) {
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${event.text}`);
logger.debug('layout', `通知解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始通知: [${event.title}] ${safeText}`);
handleIncomingBillEvent('notification', bill, `[${event.title}] ${event.text}`);
} else {
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${event.text}`);
logger.debug('layout', `通知未匹配为账单: [${event.title}] ${safeText}`);
}
} catch (e) {
logger.error('layout', '通知解析失败', e);
@ -210,7 +245,8 @@ function AppShell() {
}),
DeviceEventEmitter.addListener('billingSms', (event) => {
try {
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${event.body}`);
const safeBody = sanitizeLogText(event.body || '');
logger.debug('layout', `收到原生短信, 发送者: ${event.address || event.sender || '未知'}, 内容: ${safeBody}`);
const smsEvent = {
sender: event.sender || event.address || '',
body: event.body,
@ -218,75 +254,74 @@ function AppShell() {
};
const bill = parseSms(smsEvent);
if (bill) {
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${event.body}`);
logger.debug('layout', `短信解析成功 [时间: ${bill.occurredAt}, 方向: ${bill.direction === 'income' ? '收入' : '支出'}, 金额: ${bill.amount} ${bill.currency}, 商户: ${bill.counterparty}, 备注: ${bill.memo}] | 原始短信: ${safeBody}`);
handleIncomingBillEvent('sms', bill, event.body);
} else {
logger.debug('layout', `短信未匹配为账单: ${event.body}`);
logger.debug('layout', `短信未匹配为账单: ${safeBody}`);
}
} catch (e) {
logger.error('layout', '短信解析失败', e);
}
}),
DeviceEventEmitter.addListener('billingScreenshot', (event) => {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
processScreenshotEvent(event).catch((e: unknown) => {
logger.error('layout', '截图 OCR 处理失败', e);
});
}),
DeviceEventEmitter.addListener('billingOpenApp', (res) => {
DeviceEventEmitter.addListener('billingScreenshot', async (event) => {
try {
logger.debug('layout', `收到原生截图/无障碍截图, 包名: ${event.packageName}`);
await processScreenshotEvent(event);
} catch (e) {
logger.error('layout', `截图 OCR 处理失败 (包名: ${event.packageName}, 是否有Base64: ${Boolean(event.imageBase64)})`, e instanceof Error ? { message: e.message, stack: e.stack } : String(e));
}
}),
DeviceEventEmitter.addListener('billingOpenApp', async (event) => {
try {
const res = event as NativeOpenAppEvent;
logger.info('layout', `收到原生悬浮窗跳转 App 请求: ${JSON.stringify(res)}`);
if (res.draftId) {
try {
const { pendingDrafts } = require('../services/automationPipeline');
pendingDrafts.delete(res.draftId);
} catch (err) {
// 忽略
if (res.confirmed) {
// 已由原生悬浮窗确认入账,前端仅记录日志或提示
return;
}
if (!res.amount && !res.merchant) {
logger.warn('layout', 'billingOpenApp 数据不完整,跳过', JSON.stringify(res));
return;
}
const amt = res.amount ?? '0';
const validAmount = parseDecimal(amt);
if (!validAmount) {
if (amt.length > 0) {
logger.warn('layout', 'billingOpenApp 金额格式错误', amt);
} else {
logger.warn('layout', 'billingOpenApp 金额无效', amt);
}
return;
}
const amt = res.amount || '0';
const currency = 'CNY';
let postings: any[] = [];
if (res.direction === 'expense') {
postings = [
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
} else if (res.direction === 'income') {
postings = [
{ account: res.account, amount: amt, currency },
{ account: res.category, amount: `-${amt}`, currency }
];
} else {
postings = [
{ account: res.account, amount: `-${amt}`, currency },
{ account: res.category, amount: amt, currency }
];
}
const direction = res.direction ?? 'expense';
const categoryAccount = res.category ?? 'Expenses:未分类';
const sourceAccount = res.account ?? 'Assets:未知';
const currency = res.currency ?? 'CNY';
const postings = [
{ account: sourceAccount, amount: direction === 'expense' ? `-${validAmount}` : validAmount, currency },
{ account: categoryAccount, amount: direction === 'expense' ? validAmount : `-${validAmount}`, currency }
];
const draft = {
date: res.time ? res.time.split(' ')[0] : new Date().toISOString().split('T')[0],
payee: res.merchant || undefined,
narration: res.narration || '',
postings
};
router.push({
pathname: '/transaction/new',
params: { draftJson: JSON.stringify(draft) }
});
useNumpadUiStore.getState().open({ draftJson: JSON.stringify(draft) });
} catch (e) {
logger.error('layout', '处理悬浮窗跳转 App 失败', e);
}
}),
DeviceEventEmitter.addListener('billingPageRemembered', (event) => {
logger.info('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
logger.info('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
logger.debug('layout', `[无障碍调试] 成功记住页面签名: ${event.signature} (包名: ${event.package}, 类名: ${event.activity})`);
logger.debug('layout', `[无障碍调试] 记住该页面时捕获的所有文本内容: ${JSON.stringify(event.texts)}`);
}),
DeviceEventEmitter.addListener('billingDebugNodes', async (event) => {
const { package: pkg, activity, texts, isManual } = event;
const sigKey = `${pkg}|${activity}`;
logger.info('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
logger.info('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts)}`);
logger.debug('layout', `[无障碍监听] 页面特征信号: ${sigKey}`);
logger.debug('layout', `[无障碍监听] 页面提取到的文本内容: ${JSON.stringify(texts?.slice(0, 5))}`);
try {
const bridge = getAccessibilityBridge();
@ -295,7 +330,7 @@ function AppShell() {
const signatures = await bridge.getPageSignatures();
const isWhitelisted = signatures.some(s => s.signature === sigKey);
if (!isWhitelisted) {
logger.info('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
logger.debug('layout', `[无障碍监听] 页面 ${sigKey} 未在自动记账白名单中,拒绝弹窗`);
return;
}
}
@ -314,11 +349,12 @@ function AppShell() {
if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) {
logger.info('layout', '[无障碍] 微信详情页直接文本解析未成功,降级触发 OCR 识别');
const textSnippet = texts.slice(0, 5).join(' | ');
logger.info('layout', `[无障碍] 微信详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
bridge?.triggerManualOcr();
}
} else {
logger.info('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
logger.debug('layout', '[无障碍] 微信非详情页面,不触发记账与截图');
}
} else if (pkg === 'com.eg.android.AlipayGphone') {
// 支付宝:识别是否是详情页
@ -326,11 +362,12 @@ function AppShell() {
if (isDetail) {
const success = await parseAndProcessAccessibilityTexts(texts, pkg);
if (!success) {
logger.info('layout', '[无障碍] 支付宝详情页直接文本解析未成功,降级触发 OCR 识别');
const textSnippet = texts.slice(0, 5).join(' | ');
logger.info('layout', `[无障碍] 支付宝详情页直接文本解析未成功 (截获文本前5句: [${textSnippet}]),降级触发 OCR 识别`);
bridge?.triggerManualOcr();
}
} else {
logger.info('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
logger.debug('layout', '[无障碍] 支付宝非详情页面,不触发记账与截图');
}
} else {
// 其他白名单应用如各大银行、QQ、TIM等直接通过截图 + OCR 识别
@ -349,8 +386,11 @@ function AppShell() {
// 5. 隐私模糊plan.md「0.4 隐私模糊」App 进入后台/非活跃时遮盖内容
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
// 切到后台/非活跃时立即遮盖,回到前台时移除
// 切到后台/非活跃时立即遮盖并同步持久化,回到前台时移除
setPrivacyOverlay(state !== 'active');
if (state !== 'active') {
flushPersistence().catch(e => logger.error('layout', 'Failed to flush persistence on background', e));
}
if (state === 'active' && phase === 'ready') {
checkAndReloadLedger();
}
@ -359,7 +399,7 @@ function AppShell() {
}, [phase, checkAndReloadLedger]);
// loading 状态
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
if (phase === 'loading') {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
@ -390,7 +430,6 @@ function AppShell() {
<StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="transaction/new" options={{ headerShown: true, title: t('home.newTransaction'), headerTintColor: theme.colors.fgPrimary, headerStyle: { backgroundColor: theme.colors.bgPrimary } }} />
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
<Stack.Screen name="category/index" options={{ headerShown: false }} />
<Stack.Screen name="tag/index" options={{ headerShown: false }} />
@ -401,25 +440,34 @@ function AppShell() {
<Stack.Screen name="settings/ai" options={{ headerShown: false }} />
<Stack.Screen name="settings/sync" options={{ headerShown: false }} />
<Stack.Screen name="settings/preferences" options={{ headerShown: false }} />
<Stack.Screen name="settings/diagnostics" options={{ headerShown: false }} />
<Stack.Screen name="recurring/index" options={{ headerShown: false }} />
<Stack.Screen name="ai/chat" options={{ headerShown: false }} />
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
<Stack.Screen name="automation/index" options={{ headerShown: false }} />
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
</Stack>
{/* 隐私遮罩plan.md「0.4」App 切后台时遮盖内容 */}
<NumpadSheetHost />
{/* 隐私遮罩plan.md「0.4」App 切后台时遮盖内容并拦截所有手势 */}
{privacyOverlay && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary }} />
<View
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: theme.colors.bgPrimary, zIndex: 99999 }}
pointerEvents="auto"
accessible={false}
/>
)}
</View>
);
}
/** 根布局:包裹 ThemeProvider。 */
/** 根布局:GestureHandlerRootView左滑手势包裹 ThemeProvider。 */
export default function RootLayout() {
return (
<ThemeProvider>
<AppShell />
</ThemeProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<ThemeProvider>
<FloatingUiConfigSyncer />
<AppShell />
</ThemeProvider>
</GestureHandlerRootView>
);
}

View File

@ -12,14 +12,16 @@
* onboarding
*/
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import React, { useState, useEffect } from 'react';
import { AppState, Platform, PermissionsAndroid, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../store/settingsStore';
import { Button } from '../components/Button';
import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { Card } from '../components/Card';
export interface OnboardingStep {
key: string;
@ -52,6 +54,68 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
const current = steps[step];
const isLast = step === steps.length - 1;
// ── 权限状态管理P7 Task 2 ──
const [permStates, setPermStates] = useState<Record<string, boolean | null>>({
accessibility: null, notification: null, sms: null, storage: null, overlay: null,
});
const permsChecked = !Object.values(permStates).some(v => v === null);
const allGranted = permsChecked && Object.values(permStates).every(Boolean);
useEffect(() => {
if (Platform.OS !== 'android') return;
const checkAllPermissions = async () => {
if (current.key !== 'permissions') return;
const bridge = getAccessibilityBridge();
let a = false, n = false, o = false, s = false, st = false;
if (bridge) {
try { a = await bridge.isServiceRunning(); } catch { /* false */ }
try { n = await bridge.isNotificationListenerEnabled(); } catch { /* false */ }
try { o = await bridge.canDrawOverlays(); } catch { /* false */ }
}
try { s = await PermissionsAndroid.check('android.permission.RECEIVE_SMS') as boolean; } catch { /* false */ }
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
try { st = await PermissionsAndroid.check(storagePerm) as boolean; } catch { /* false */ }
setPermStates({ accessibility: a, notification: n, overlay: o, sms: s, storage: st });
};
checkAllPermissions();
// 从系统设置返回后重新检查
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') checkAllPermissions();
});
return () => sub.remove();
}, [current.key]);
const handlePermAction = async (key: string) => {
const storagePerm = parseInt(String(Platform.Version), 10) >= 33
? 'android.permission.READ_MEDIA_IMAGES'
: 'android.permission.READ_EXTERNAL_STORAGE';
switch (key) {
case 'accessibility':
await Linking.sendIntent('android.settings.ACCESSIBILITY_SETTINGS');
break;
case 'notification':
await Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS');
break;
case 'sms': {
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
setPermStates(p => ({ ...p, sms: result === PermissionsAndroid.RESULTS.GRANTED }));
break;
}
case 'storage': {
const result = await PermissionsAndroid.request(storagePerm);
setPermStates(p => ({ ...p, storage: result === PermissionsAndroid.RESULTS.GRANTED }));
break;
}
case 'overlay':
await Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION');
break;
}
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<ScrollView contentContainerStyle={styles.content}>
@ -97,6 +161,55 @@ export function OnboardingScreen({ onComplete }: OnboardingProps) {
</View>
)}
{current.key === 'permissions' && Platform.OS === 'android' && (
<View style={{ width: '100%', marginTop: 24 }}>
<Card>
{[
{ key: 'accessibility', icon: 'accessibility-outline' as const, label: t('onboarding.permAccessibility') },
{ key: 'notification', icon: 'notifications-outline' as const, label: t('onboarding.permNotification') },
{ key: 'sms', icon: 'chatbubble-outline' as const, label: t('onboarding.permSms') },
{ key: 'storage', icon: 'images-outline' as const, label: t('onboarding.permStorage') },
{ key: 'overlay', icon: 'tablet-landscape-outline' as const, label: t('onboarding.permOverlay') },
].map(item => {
const status = permStates[item.key];
const granted = status === true;
const statusColor = granted ? theme.colors.success : theme.colors.fgSecondary;
const statusText = status === null ? '...' : granted ? t('onboarding.permGranted') : t('onboarding.permNotGranted');
return (
<View key={item.key} style={styles.permRow}>
<View style={styles.permLeft}>
<Ionicons name={item.icon} size={20} color={granted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, marginLeft: 12 }]}>
{item.label}
</Text>
</View>
<View style={styles.permRight}>
<Text style={[theme.typography.caption, { color: statusColor, marginRight: 8 }]}>
{statusText}
</Text>
{!granted && !allGranted && (
<Button
label={item.key === 'sms' || item.key === 'storage' ? t('onboarding.permRequest') : t('onboarding.permOpenSettings')}
onPress={() => handlePermAction(item.key)}
variant="secondary"
/>
)}
</View>
</View>
);
})}
</Card>
{allGranted && (
<View style={styles.allGrantedWrap}>
<Ionicons name="checkmark-circle" size={48} color={theme.colors.success} />
<Text style={[theme.typography.body, { color: theme.colors.success, marginTop: 8 }]}>
{t('onboarding.permAllDone')}
</Text>
</View>
)}
</View>
)}
{current.key === 'security' && (
<Pressable
onPress={() => setAppLockEnabled(!appLockEnabled)}
@ -146,4 +259,9 @@ const styles = StyleSheet.create({
dots: { flexDirection: 'row', justifyContent: 'center', gap: 6, marginBottom: 12 },
dot: { width: 8, height: 8, borderRadius: 4 },
actions: { flexDirection: 'row', gap: 12, justifyContent: 'flex-end' },
// 权限清单P7 Task 2
permRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
permLeft: { flexDirection: 'row', alignItems: 'center', flex: 1 },
permRight: { flexDirection: 'row', alignItems: 'center' },
allGrantedWrap: { alignItems: 'center', marginTop: 24 },
});

View File

@ -1,14 +1,15 @@
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { AccountCreateModal } from '../../components/AccountCreateModal';
import { FormModal, type FormField } from '../../components/FormModal';
import { addDecimals } from '../../domain/decimal';
import { ScreenHeader } from '../../components/ScreenHeader';
import { toDateString } from '../../domain/decimal';
import { computeAccountBalances } from '../../domain/ledger';
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
@ -16,7 +17,6 @@ type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
export default function AccountScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
@ -111,7 +111,7 @@ export default function AccountScreen() {
const handleConfirmAdjust = async (values: Record<string, string>) => {
if (!adjustingAccount) return;
const balanceVal = values.balance?.trim();
const dateVal = values.date?.trim() || new Date().toISOString().slice(0, 10);
const dateVal = values.date?.trim() || toDateString(new Date());
if (!balanceVal) {
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
@ -134,18 +134,29 @@ export default function AccountScreen() {
{ key: 'income', label: t('account.tabIncome') },
];
const rootTypeOptions = [
{ label: t('account.rootTypes.Assets'), value: 'Assets', subLabel: 'Assets' },
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities', subLabel: 'Liabilities' },
{ label: t('account.rootTypes.Expenses'), value: 'Expenses', subLabel: 'Expenses' },
{ label: t('account.rootTypes.Income'), value: 'Income', subLabel: 'Income' },
{ label: t('account.rootTypes.Equity'), value: 'Equity', subLabel: 'Equity' },
];
const fields: FormField[] = [
{
key: 'type',
label: t('account.fieldType'),
placeholder: 'Assets / Liabilities / Expenses / Income',
type: 'dropdown',
options: rootTypeOptions,
defaultValue: tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income',
flex: 1,
},
{
key: 'name',
label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '',
flex: 1.5,
},
{
key: 'currency',
@ -157,15 +168,7 @@ export default function AccountScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
{/* 头部导航 */}
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('account.title')}
</Text>
</View>
<ScreenHeader title={t('account.title')} />
{/* Tab 选项卡 */}
<View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}>
@ -207,6 +210,8 @@ export default function AccountScreen() {
{filteredAccounts.map(account => {
const shortName = account.slice(prefix.length);
const rootType = account.split(':')[0] as 'Assets' | 'Liabilities' | 'Expenses' | 'Income' | 'Equity';
const localizedRoot = t(`account.rootLabels.${rootType}`);
const balance = balances.get(account) ?? '0.00';
const currency = ledger?.accounts.get(account)?.currencies[0] || 'CNY';
@ -217,7 +222,7 @@ export default function AccountScreen() {
{t('account.fullName')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{account}
{account} {localizedRoot ? `(${localizedRoot})` : ''}
</Text>
</View>
<View style={styles.infoRow}>
@ -266,10 +271,9 @@ export default function AccountScreen() {
</ScrollView>
{/* 新开户对话框 */}
<FormModal
<AccountCreateModal
visible={isAdding}
title={t('account.addModalTitle')}
fields={fields}
defaultType={tab === 'assets' ? 'Assets' : tab === 'liabilities' ? 'Liabilities' : tab === 'expenses' ? 'Expenses' : 'Income'}
onConfirm={handleAddAccount}
onCancel={() => setIsAdding(false)}
/>
@ -289,7 +293,7 @@ export default function AccountScreen() {
key: 'date',
label: t('account.fieldDate'),
placeholder: 'YYYY-MM-DD',
defaultValue: new Date().toISOString().slice(0, 10),
defaultValue: toDateString(new Date()),
},
]}
onConfirm={handleConfirmAdjust}
@ -301,7 +305,6 @@ export default function AccountScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
content: { padding: 16, gap: 12 },

View File

@ -17,8 +17,10 @@ import { useRouter } from 'expo-router';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
import { ScreenHeader } from '../../components/ScreenHeader';
interface UiMessage {
role: 'user' | 'assistant';
@ -113,26 +115,33 @@ export default function AIChatScreen() {
{item.content}
</Text>
{/* 账单卡片 */}
{item.billCards && item.billCards.map((card, i) => (
{item.billCards && item.billCards.map((card, i) => {
// 方向符号 + 金额上色与全局方向色约定一致TransactionCardincome 绿 / expense 红 / transfer 蓝)
const dirColor = card.type === 'income' ? theme.colors.financial.income
: card.type === 'transfer' ? theme.colors.financial.transfer
: theme.colors.financial.expense;
const dirSign = card.type === 'income' ? '' : card.type === 'transfer' ? '⇄' : '';
return (
<Pressable
key={i}
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })}
onPress={() => useNumpadUiStore.getState().open({ autoOcr: true })}
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{card.type === 'income' ? '💰' : card.type === 'transfer' ? '🔄' : '💸'} {card.amount} {card.currency}
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
{dirSign} {card.amount} {card.currency}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{card.counterparty} · {card.narration}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
{t('ai.tapToRecord')}
</Text>
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
</View>
</Pressable>
))}
);
})}
</View>
</View>
);
@ -140,14 +149,7 @@ export default function AIChatScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('ai.chatTitle')}
</Text>
</View>
<ScreenHeader title={t('ai.chatTitle')} />
<FlatList
ref={listRef}
@ -192,7 +194,6 @@ export default function AIChatScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1 },
msgRow: { flexDirection: 'row', maxWidth: '100%' },
bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 },
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 },

View File

@ -5,7 +5,7 @@
* - 5 ///OCR/
* -
* - "处理全部" BillPipeline 稿
* - /稿 mobile.bean
* - /稿 main.bean
* - ScreenshotMonitor
* -
* - /
@ -15,47 +15,54 @@
* -
* - App
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Alert, AppState, Linking, NativeModules, PermissionsAndroid, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { createCommonStyles } from '../../theme/commonStyles';
import { useT } from '../../i18n';
import { useAutomationStore } from '../../store/automationStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useRouter } from 'expo-router';
import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import type { AutomationSource } from '../../store/automationStore';
import { ScreenHeader } from '../../components/ScreenHeader';
import {
getAccessibilityBridge,
getPackageLabel,
type PageSignature,
} from '../../services/accessibilityBridge';
const SOURCE_LABELS: Record<AutomationSource, string> = {
notification: 'automation.sourceNotification',
sms: 'automation.sourceSms',
screenshot: 'automation.sourceScreenshot',
ocr: 'automation.sourceOcr',
manual: 'automation.sourceManual',
};
import { ensureOcrModels, downloadOcrModels } from '../../services/modelDownloader';
export default function AutomationScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
const detected = useAutomationStore(s => s.detected);
const drafts = useAutomationStore(s => s.drafts);
const stats = useAutomationStore(s => s.stats);
const processAll = useAutomationStore(s => s.processAll);
const confirmDraft = useAutomationStore(s => s.confirmDraft);
const rejectDraft = useAutomationStore(s => s.rejectDraft);
const floatingBallEnabled = useSettingsStore(s => s.floatingBallEnabled);
const setFloatingBallEnabled = useSettingsStore(s => s.setFloatingBallEnabled);
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
// 自动记账层级
const layer1RuleEnabled = useSettingsStore(s => s.layer1RuleEnabled);
const setLayer1RuleEnabled = useSettingsStore(s => s.setLayer1RuleEnabled);
const layer2OcrEnabled = useSettingsStore(s => s.layer2OcrEnabled);
const setLayer2OcrEnabled = useSettingsStore(s => s.setLayer2OcrEnabled);
const layer3AiEnabled = useSettingsStore(s => s.layer3AiEnabled);
const setLayer3AiEnabled = useSettingsStore(s => s.setLayer3AiEnabled);
const ocrModelVersion = useSettingsStore(s => s.ocrModelVersion);
const setOcrModelVersion = useSettingsStore(s => s.setOcrModelVersion);
const setOcrModelDir = useSettingsStore(s => s.setOcrModelDir);
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiProviderId = useSettingsStore(s => s.aiProviderId);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
const aiModel = useSettingsStore(s => s.aiModel) || '';
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
// 无障碍服务状态
const [serviceRunning, setServiceRunning] = useState(false);
@ -64,7 +71,74 @@ export default function AutomationScreen() {
const [screenshotActive, setScreenshotActive] = useState(false);
const [topApp, setTopApp] = useState<{ package: string; activity: string } | null>(null);
const sourceEntries = useMemo(() => Object.entries(stats) as [AutomationSource, number][], [stats]);
// 权限状态
const [notifEnabled, setNotifEnabled] = useState<boolean | null>(null);
const [smsGranted, setSmsGranted] = useState<boolean | null>(null);
const [storageGranted, setStorageGranted] = useState<boolean | null>(null);
const [overlayGranted, setOverlayGranted] = useState<boolean | null>(null);
// OCR 模型安装/下载状态
const [modelStatus, setModelStatus] = useState<'idle' | 'installing' | 'verifying' | 'done' | 'error'>('idle');
const [modelProgress, setModelProgress] = useState(0);
const handleInstallModel = async () => {
setModelStatus('installing');
try {
await ensureOcrModels((p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('common.error'), String(e));
}
};
const handleRedownloadModel = async () => {
setModelStatus('installing');
setModelProgress(0);
try {
await downloadOcrModels(undefined, (p) => setModelProgress(p));
setModelStatus('done');
} catch (e) {
setModelStatus('error');
Alert.alert(t('automation.ocrModelDownloadFail'), String(e));
}
};
const handleL2Toggle = (val: boolean) => {
if (val && !ocrModelVersion) {
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
]);
return;
}
setLayer2OcrEnabled(val);
};
const scrollRef = useRef<ScrollView>(null);
const handleL3Toggle = (val: boolean) => {
if (val && (!aiEnabled || !aiApiKey)) {
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.confirm'), onPress: () => router.push('/settings/ai') },
]);
return;
}
setLayer3AiEnabled(val);
};
const providerDefaultUrls: Record<string, string> = {
openai: 'https://api.openai.com/v1',
gemini: 'https://generativelanguage.googleapis.com/v1beta',
deepseek: 'https://api.deepseek.com/v1',
};
const providerDefaultModels: Record<string, string> = {
openai: 'gpt-4o-mini',
gemini: 'gemini-2.0-flash',
deepseek: 'deepseek-chat',
};
// 刷新无障碍状态
const refreshAccessibilityState = useCallback(async () => {
@ -100,27 +174,56 @@ export default function AutomationScreen() {
};
}, [refreshAccessibilityState]);
const handleProcess = async () => {
if (!ledger) return;
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const history = ledger.transactions;
try {
await processAll(ledger, rules, categories, history);
} catch (e) {
Alert.alert(String(e));
}
// 检查权限状态(挂载 + 从系统设置返回前台时重新检查)
useEffect(() => {
if (Platform.OS !== 'android') return;
const checkPermissions = () => {
const bridge = getAccessibilityBridge();
if (bridge && typeof bridge.isNotificationListenerEnabled === 'function') {
bridge.isNotificationListenerEnabled().then(setNotifEnabled).catch(() => setNotifEnabled(false));
}
if (bridge && typeof bridge.canDrawOverlays === 'function') {
bridge.canDrawOverlays().then(setOverlayGranted).catch(() => setOverlayGranted(false));
}
const smsPerm = 'android.permission.RECEIVE_SMS';
PermissionsAndroid.check(smsPerm).then(async (granted) => {
if (granted) { setSmsGranted(true); return; }
try {
const result = await PermissionsAndroid.request(smsPerm);
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
} catch { setSmsGranted(false); }
}).catch(() => setSmsGranted(false));
const storagePerm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
PermissionsAndroid.check(storagePerm).then(g => setStorageGranted(g as boolean)).catch(() => setStorageGranted(false));
};
checkPermissions();
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') checkPermissions();
});
return () => sub.remove();
}, []);
const handleOpenNotificationSettings = () => {
if (Platform.OS !== 'android') return;
Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS').catch(() => {});
};
const handleConfirm = async (index: number) => {
const draft = confirmDraft(index);
if (!draft) return;
try {
await addTransaction(draft.draft);
Alert.alert(t('automation.confirmed'));
} catch (e) {
Alert.alert(String(e));
}
const handleRequestSms = async () => {
const result = await PermissionsAndroid.request('android.permission.RECEIVE_SMS');
setSmsGranted(result === PermissionsAndroid.RESULTS.GRANTED);
};
const handleOpenOverlaySettings = () => {
Linking.sendIntent('android.settings.action.MANAGE_OVERLAY_PERMISSION').catch(() => {});
};
const handleRequestStorage = async () => {
const perm = Number(Platform.Version) >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE';
const result = await PermissionsAndroid.request(perm);
setStorageGranted(result === PermissionsAndroid.RESULTS.GRANTED);
};
const handleScreenshotToggle = () => {
@ -246,46 +349,10 @@ export default function AutomationScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('automation.title')}
</Text>
</View>
<ScreenHeader title={t('automation.title')} />
<FlatList
data={drafts}
keyExtractor={(item, index) => item.draft.sourceEventId ?? `${item.draft.date}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.draft.narration}`}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{item.draft.postings.map(p => `${p.account} ${p.amount} ${p.currency ?? ''}`).join('\n')}
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 8 }}>
<Button label={t('automation.confirmDraft')} onPress={() => handleConfirm(index)} />
<Button label={t('automation.rejectDraft')} onPress={() => rejectDraft(index)} variant="secondary" />
</View>
</Card>
)}
ListHeaderComponent={
<ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
<View style={{ gap: 12, marginBottom: 12 }}>
{/* 通道统计 */}
<Card title={t('automation.channelStats')}>
<View style={styles.statsRow}>
{sourceEntries.map(([source, count]) => (
<View key={source} style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t(SOURCE_LABELS[source])}
</Text>
<Text style={[theme.typography.h3, { color: count > 0 ? theme.colors.accent : theme.colors.fgSecondary }]}>
{count}
</Text>
</View>
))}
</View>
</Card>
{/* 无障碍服务状态与控制 */}
{Platform.OS === 'android' && (
@ -303,6 +370,25 @@ export default function AutomationScreen() {
</Text>
)}
{/* 悬浮球开关 */}
<View style={[styles.switchRow, { marginBottom: 8 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('automation.floatingBall')}
</Text>
<Switch
value={floatingBallEnabled}
onValueChange={async (val) => {
setFloatingBallEnabled(val);
const bridge = getAccessibilityBridge();
if (bridge) {
try { await bridge.setFloatingBallEnabled(val); } catch {}
}
}}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* 操作按钮 */}
<View style={{ gap: 8 }}>
{!serviceRunning && (
@ -382,51 +468,202 @@ export default function AutomationScreen() {
</Card>
)}
{/* 检测到的事件 */}
<Card title={t('automation.detectedEvents', { count: detected.length })}>
{detected.length === 0 ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.noEvents')}
{/* --- Card A: 自动记账层级 --- */}
<Card title={t('automation.autoBookkeeping')}>
{/* L1 规则匹配 */}
<View style={[styles.layerRow, { marginBottom: 8 }]}>
<Ionicons name="flash-outline" size={20} color={layer1RuleEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer1Rule')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer1RuleDesc')}
</Text>
</View>
<Switch
value={layer1RuleEnabled}
onValueChange={setLayer1RuleEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L2 OCR 识别 */}
<View style={[styles.layerRow, { marginBottom: 8 }]}>
<Ionicons name="scan-outline" size={20} color={layer2OcrEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer2Ocr')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer2OcrDesc')}
</Text>
</View>
<Switch
value={layer2OcrEnabled}
onValueChange={handleL2Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* L3 AI 视觉 */}
<View style={styles.layerRow}>
<Ionicons name="sparkles-outline" size={20} color={layer3AiEnabled ? theme.colors.accent : theme.colors.fgSecondary} />
<View style={{ flex: 1, marginLeft: 10 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('automation.layer3Ai')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('automation.layer3AiDesc')}
</Text>
</View>
<Switch
value={layer3AiEnabled}
onValueChange={handleL3Toggle}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* --- Card B: OCR 模型 --- */}
<Card title={t('automation.ocrModelTitle')}>
{/* 状态行 */}
<View style={styles.layerRow}>
<Ionicons
name="hardware-chip-outline"
size={20}
color={ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary}
/>
<Text style={[theme.typography.body, { flex: 1, marginLeft: 10, color: ocrModelVersion ? theme.colors.success : theme.colors.fgSecondary }]}>
{ocrModelVersion
? t('automation.ocrModelInstalled', { version: ocrModelVersion })
: t('automation.ocrModelNotInstalled')}
</Text>
) : (
<View style={{ gap: 4 }}>
{detected.map(ev => (
<Text key={ev.id} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
[{t(SOURCE_LABELS[ev.source])}] {ev.event.counterparty} · {ev.event.amount} {ev.event.currency}
</View>
{/* 下载进度条 */}
{modelStatus === 'installing' && (
<View style={{ marginTop: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ActivityIndicator size="small" color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, flex: 1 }]}>
{modelProgress > 0
? t('automation.ocrModelDownloading', { progress: String(modelProgress) })
: t('automation.ocrModelCopying')}
</Text>
))}
<View style={{ marginTop: 8 }}>
<Button label={t('automation.process')} onPress={handleProcess} />
</View>
{modelProgress > 0 && (
<View style={[styles.progressBar, { backgroundColor: theme.colors.progressBg, marginTop: 6 }]}>
<View style={[styles.progressFill, { backgroundColor: theme.colors.accent, width: `${modelProgress}%` }]} />
</View>
)}
</View>
)}
{/* 操作按钮行 */}
{modelStatus !== 'installing' && (
<View style={[styles.layerRow, { marginTop: 8 }]}>
{!ocrModelVersion || modelStatus === 'error' ? (
<Button label={t('automation.ocrModelDownload')} onPress={handleInstallModel} variant="secondary" />
) : (
<Button label={t('automation.ocrModelRedownload')} onPress={handleRedownloadModel} variant="secondary" />
)}
</View>
)}
</Card>
{/* 草稿标题 */}
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('automation.drafts', { count: drafts.length })}
</Text>
{/* --- Card C: 管道设置 --- */}
<Card title={t('settings.algorithmConfig')}>
<View style={[styles.switchRow, { marginBottom: 8 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text>
<Switch
value={dedupEnabled}
onValueChange={setDedupEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* 其他权限 */}
{Platform.OS === 'android' && (
<Card title={t('automation.otherPermissions')}>
{/* 通知监听 */}
<View style={styles.permRow}>
<Ionicons name="notifications-outline" size={20} color={notifEnabled ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.notificationTitle')}</Text>
<Text style={{ color: notifEnabled ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{notifEnabled ? t('automation.notificationEnabled') : t('automation.notificationDisabled')}
</Text>
{!notifEnabled && <Button label={t('automation.notificationOpenSettings')} onPress={handleOpenNotificationSettings} variant="secondary" />}
</View>
{/* 短信 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="chatbubble-outline" size={20} color={smsGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.smsPermissionTitle')}</Text>
<Text style={{ color: smsGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.smsPermGranted')}
</Text>
{!smsGranted && <Button label={t('automation.smsPermRequest')} onPress={handleRequestSms} variant="secondary" />}
</View>
{/* 存储 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="images-outline" size={20} color={storageGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.storagePermTitle')}</Text>
<Text style={{ color: storageGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{t('automation.storagePermGranted')}
</Text>
{!storageGranted && <Button label={t('automation.storagePermRequest')} onPress={handleRequestStorage} variant="secondary" />}
</View>
{/* 悬浮窗 */}
<View style={[styles.permRow, { marginTop: 8 }]}>
<Ionicons name="tablet-landscape-outline" size={20} color={overlayGranted ? theme.colors.success : theme.colors.fgSecondary} />
<Text style={{ flex: 1, marginLeft: 8, color: theme.colors.fgPrimary }}>{t('automation.overlayPermTitle')}</Text>
<Text style={{ color: overlayGranted ? theme.colors.success : theme.colors.fgSecondary, marginRight: 8 }}>
{overlayGranted ? t('automation.overlayPermGranted') : t('automation.overlayPermNotGranted')}
</Text>
{!overlayGranted && <Button label={t('automation.overlayPermOpenSettings')} onPress={handleOpenOverlaySettings} variant="secondary" />}
</View>
</Card>
)}
</View>
}
ListEmptyComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('automation.noDrafts')}
</Text>
}
contentContainerStyle={styles.content}
/>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
statusDot: { width: 8, height: 8, borderRadius: 4 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
layerRow: { flexDirection: 'row', alignItems: 'center' },
chip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4 },
progressBar: { height: 4, borderRadius: 2, overflow: 'hidden' },
progressFill: { height: '100%', borderRadius: 2 },
sigRow: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 4 },
permRow: { flexDirection: 'row', alignItems: 'center' },
});

View File

@ -3,27 +3,23 @@
*
* () + //
* calculateBudgetProgress ,//
* P5 ManagementScreen //
*/
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { ManagementScreen } from '../../components/ManagementScreen';
import { calculateBudgetProgress } from '../../domain/budgets';
import { toDateString } from '../../domain/decimal';
import type { Budget } from '../../domain/budgets';
type ModalMode = { type: 'add' } | { type: 'edit'; budget: Budget } | null;
export default function BudgetScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const budgets = useMetadataStore(s => s.budgets);
const addBudget = useMetadataStore(s => s.addBudget);
@ -31,130 +27,94 @@ export default function BudgetScreen() {
const removeBudget = useMetadataStore(s => s.removeBudget);
const ledger = useLedgerStore(s => s.ledger);
const [modal, setModal] = useState<ModalMode>(null);
const transactions = ledger?.transactions ?? [];
const today = new Date().toISOString().slice(0, 10);
const handleDelete = (budget: Budget) => {
Alert.alert(t('budget.deleteTitle'), t('budget.deleteConfirm', { name: budget.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeBudget(budget.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() || t('budget.unnamed');
const amount = values.amount?.trim() || '0';
const period = (values.period as Budget['period']) || 'monthly';
const categoryAccount = values.categoryAccount?.trim() || undefined;
const startDate = values.startDate?.trim() || today;
const amtNum = parseFloat(amount);
if (isNaN(amtNum) || amtNum <= 0) {
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
return;
}
if (modal?.type === 'add') {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
} else if (modal?.type === 'edit') {
updateBudget(modal.budget.id, { name, amount, period, categoryAccount, startDate });
}
setModal(null);
};
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: modal.budget.name },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: modal.budget.amount, keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: modal.budget.period },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: modal.budget.categoryAccount ?? '' },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: modal.budget.startDate },
] : [];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('budget.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('budget.add')}</Text>
</Pressable>
{budgets.map(budget => {
const progress = calculateBudgetProgress(budget, transactions, today);
return (
<Pressable
key={budget.id}
onPress={() => setModal({ type: 'edit', budget })}
onLongPress={() => handleDelete(budget)}
>
<Card title={budget.name}>
<View style={styles.budgetRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
</Text>
<Text style={[theme.typography.caption, {
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
}]}>
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
</Text>
</View>
{/* 进度条 */}
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<View style={[
styles.bar,
{
width: `${Math.min(progress.percentage, 100)}%`,
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
},
]} />
</View>
{budget.categoryAccount && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{budget.categoryAccount}
</Text>
)}
</Card>
</Pressable>
);
})}
{budgets.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('budget.empty')}
</Text>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
<ManagementScreen<Budget>
title={t('budget.title')}
items={budgets}
keyExtractor={budget => budget.id}
addLabel={t('budget.add')}
emptyText={t('budget.empty')}
renderItem={(budget, { openEdit, confirmDelete }) => {
const progress = calculateBudgetProgress(budget, transactions, today);
return (
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
<Card title={budget.name}>
<View style={styles.budgetRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
</Text>
<Text style={[theme.typography.caption, {
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
}]}>
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
</Text>
</View>
{/* 进度条 */}
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<View style={[
styles.bar,
{
width: `${Math.min(progress.percentage, 100)}%`,
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
},
]} />
</View>
{budget.categoryAccount && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{budget.categoryAccount}
</Text>
)}
</Card>
</Pressable>
);
}}
formTitle={editing => (editing ? t('budget.editTitle') : t('budget.add'))}
formFields={editing => [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: editing?.name },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: editing?.amount, keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: editing?.period },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: editing?.categoryAccount ?? '' },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: editing?.startDate },
]}
onSubmit={(values, editing) => {
const name = values.name?.trim() || t('budget.unnamed');
const amount = values.amount?.trim() || '0';
const period = (values.period as Budget['period']) || 'monthly';
const categoryAccount = values.categoryAccount?.trim() || undefined;
const startDate = values.startDate?.trim() || today;
const amtNum = parseFloat(amount);
if (isNaN(amtNum) || amtNum <= 0) {
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
return false;
}
if (editing) {
updateBudget(editing.id, { name, amount, period, categoryAccount, startDate });
} else {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
}
return true;
}}
onDelete={budget => removeBudget(budget.id)}
deleteConfirmText={budget => t('budget.deleteConfirm', { name: budget.name })}
deleteConfirmTitle={t('budget.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('budget.editTitle') : t('budget.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder') },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder') },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
}
/>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
bar: { height: '100%', borderRadius: 4 },

View File

@ -1,135 +1,102 @@
/**
* plan.md1.1 category/index+ 1
*
* /+ //
* / chips + //
* metadataStorelinkedAccount Beancount
* P5 ManagementScreen headerContent / chips
*/
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme, createCommonStyles } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { ManagementScreen } from '../../components/ManagementScreen';
import { Touchable } from '../../components/Touchable';
import type { Category } from '../../domain/categories';
type ModalMode = { type: 'add'; catType: 'expense' | 'income' } | { type: 'edit'; category: Category } | null;
export default function CategoryScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const categories = useMetadataStore(s => s.categories);
const addCategory = useMetadataStore(s => s.addCategory);
const updateCategory = useMetadataStore(s => s.updateCategory);
const removeCategory = useMetadataStore(s => s.removeCategory);
const [modal, setModal] = useState<ModalMode>(null);
const expenseCats = categories.filter(c => c.type === 'expense');
const incomeCats = categories.filter(c => c.type === 'income');
const handleDelete = (cat: Category) => {
Alert.alert(t('category.deleteTitle'), t('category.deleteConfirm', { name: cat.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCategory(cat.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
if (modal?.type === 'add') {
addCategory({
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: modal.catType,
linkedAccount: values.linkedAccount || 'Expenses:Uncategorized',
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else if (modal?.type === 'edit') {
updateCategory(modal.category.id, {
name: values.name || modal.category.name,
linkedAccount: values.linkedAccount || modal.category.linkedAccount,
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
}
setModal(null);
};
const renderRow = (cat: Category) => (
<Pressable
key={cat.id}
onPress={() => setModal({ type: 'edit', category: cat })}
onLongPress={() => handleDelete(cat)}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{cat.name}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text>
)}
</View>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable>
);
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: modal.category.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: modal.category.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: modal.category.keywords.join(', ') },
] : [];
/** 当前展示的分类类型(支出/收入),新增分支按此落库。 */
const [catType, setCatType] = useState<'expense' | 'income'>('expense');
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('category.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('category.expense')}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'expense' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addExpense')}</Text>
</Pressable>
{expenseCats.map(renderRow)}
<ManagementScreen<Category>
title={t('category.title')}
items={categories.filter(c => c.type === catType)}
keyExtractor={cat => cat.id}
addLabel={t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })}
headerContent={
<View style={styles.chipRow}>
{(['expense', 'income'] as const).map(tp => (
<Touchable
key={tp}
onPress={() => setCatType(tp)}
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
>
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
{tp === 'expense' ? t('category.expense') : t('category.income')}
</Text>
</Touchable>
))}
</View>
}
renderItem={(cat, { openEdit, confirmDelete }) => (
<Card title={cat.name} onPress={openEdit} onLongPress={confirmDelete}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text>
)}
</Card>
<Card title={t('category.income')}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'income' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addIncome')}</Text>
</Pressable>
{incomeCats.map(renderRow)}
</Card>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
)}
formTitle={editing => (editing
? t('category.editTitle')
: t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') }))}
formFields={editing => [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: editing?.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: editing?.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: editing?.keywords.join(', ') },
]}
onSubmit={(values, editing) => {
if (editing) {
updateCategory(editing.id, {
name: values.name || editing.name,
linkedAccount: values.linkedAccount || editing.linkedAccount,
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else {
addCategory({
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: catType,
linkedAccount: values.linkedAccount || (catType === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'),
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
}
return true;
}}
onDelete={cat => removeCategory(cat.id)}
deleteConfirmText={cat => t('category.deleteConfirm', { name: cat.name })}
deleteConfirmTitle={t('category.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('category.editTitle') : t('category.addTitle', { type: modal?.catType === 'income' ? t('category.income') : t('category.expense') })}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder') },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder') },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder') },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
}
/>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
row: { flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, paddingTop: 10 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 10 },
chipRow: { flexDirection: 'row', gap: 8 },
});

View File

@ -1,29 +1,26 @@
/**
* plan.md1.1 credit-card/index+ 1
*
* (////) + //
* (/////) + //
* linkedAccount Beancount Liabilities
* P5 ManagementScreen renderItem
*/
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { ManagementScreen } from '../../components/ManagementScreen';
import { AccountCreateModal } from '../../components/AccountCreateModal';
import { Touchable } from '../../components/Touchable';
import type { CreditCard } from '../../domain/creditCards';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
type ModalMode = { type: 'add' } | { type: 'edit'; card: CreditCard } | null;
export default function CreditCardScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const creditCards = useMetadataStore(s => s.creditCards);
const addCreditCard = useMetadataStore(s => s.addCreditCard);
@ -31,150 +28,208 @@ export default function CreditCardScreen() {
const removeCreditCard = useMetadataStore(s => s.removeCreditCard);
const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const transactions = ledger?.transactions ?? [];
const [modal, setModal] = useState<ModalMode>(null);
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
/** 计算某账户的当前余额(从交易过账汇总)。 */
const getAccountBalance = (account: string): string => {
let balance = 0;
/** 过滤出所有以 Liabilities 开头的信用负债账户。 */
const liabilityAccounts = useMemo(() => {
if (!ledger?.accounts) return [];
const accountNames = Array.from(ledger.accounts.keys());
return accountNames.filter((name: string) => name.startsWith('Liabilities'));
}, [ledger?.accounts]);
const liabilityOptions = useMemo(() => {
return liabilityAccounts.map((name: string) => ({
label: name.replace(/^Liabilities:/, ''),
value: name,
}));
}, [liabilityAccounts]);
/** 预计算所有账户余额 Map避免在渲染循环中重复遍历。 */
const accountBalances = useMemo(() => {
const map = new Map<string, string>();
for (const tx of transactions) {
for (const p of tx.postings) {
if (p.account === account && p.amount) {
balance += parseFloat(p.amount);
if (p.account && p.amount) {
const prev = parseFloat(map.get(p.account) ?? '0');
map.set(p.account, (prev + parseFloat(p.amount)).toFixed(2));
}
}
}
return String(balance.toFixed(2));
};
return map;
}, [transactions]);
const handleDelete = (card: CreditCard) => {
Alert.alert(t('creditCard.deleteTitle'), t('creditCard.deleteConfirm', { name: card.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCreditCard(card.id) },
]);
};
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
const handleConfirm = (values: Record<string, string>) => {
const card: Omit<CreditCard, 'id'> = {
name: values.name?.trim() || t('creditCard.unnamed'),
lastFour: values.lastFour?.trim() || '0000',
bankName: values.bankName?.trim() || 'UNKNOWN',
billingDay: parseInt(values.billingDay, 10) || 1,
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
};
if (modal?.type === 'add') {
addCreditCard({ ...card, id: generateId('cc') });
} else if (modal?.type === 'edit') {
updateCreditCard(modal.card.id, card);
}
setModal(null);
const handleQuickAddAccount = (values: Record<string, string>) => {
const type = values.type || 'Liabilities';
const name = values.name?.trim() || 'CreditCard';
const fullAccountName = `${type}:${name}`;
autoOpenAccounts([fullAccountName]);
setIsQuickAddingAccount(false);
};
const fieldsFor = (card?: CreditCard): FormField[] => [
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: card?.name },
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: card?.bankName },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: card?.lastFour, keyboardType: 'numeric' },
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: card ? String(card.billingDay) : '', keyboardType: 'numeric' },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: card ? String(card.paymentDay) : '', keyboardType: 'numeric' },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: card?.creditLimit, keyboardType: 'decimal-pad' },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: card?.currency },
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: card?.linkedAccount },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('creditCard.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('creditCard.add')}</Text>
</Pressable>
{creditCards.map(card => {
<>
<ManagementScreen<CreditCard>
title={t('creditCard.title')}
items={creditCards}
keyExtractor={card => card.id}
addLabel={t('creditCard.add')}
emptyText={t('creditCard.empty')}
renderItem={(card, { openEdit, confirmDelete }) => {
const period = calculateBillingPeriod(card, new Date());
const statementAmount = calculateStatementAmount(transactions, card, period);
const currentBalance = getAccountBalance(card.linkedAccount);
const availableCredit = calculateAvailableCredit(card, currentBalance);
return (
<Pressable
key={card.id}
onPress={() => setModal({ type: 'edit', card })}
onLongPress={() => handleDelete(card)}
>
<Card title={card.name}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
<Card title={card.name} onPress={openEdit} onLongPress={confirmDelete}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
{/* 账单周期与应还 */}
<View style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
</View>
{/* 账单周期与应还 */}
<View style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text>
</View>
</Card>
</Pressable>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
</View>
</View>
</Card>
);
})}
{creditCards.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('creditCard.empty')}
</Text>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
}}
formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
onValuesChange={(changedKey: string, newValue: string, currentValues: Record<string, string>) => {
if (changedKey === 'linkedAccount' && newValue) {
const shortName = newValue.includes(':') ? newValue.slice(newValue.lastIndexOf(':') + 1) : newValue;
const updates: Record<string, string> = {};
if (shortName) {
updates.name = shortName;
if (shortName.includes('招商') || shortName.toUpperCase().includes('CMB')) updates.bankName = '招商银行';
else if (shortName.includes('支付宝') || shortName.includes('花呗')) updates.bankName = '支付宝';
else if (shortName.includes('微信') || shortName.includes('微粒贷')) updates.bankName = '微信';
else if (shortName.includes('建设') || shortName.toUpperCase().includes('CCB')) updates.bankName = '建设银行';
else if (shortName.includes('工商') || shortName.toUpperCase().includes('ICBC')) updates.bankName = '工商银行';
else if (shortName.includes('中国银行') || shortName.toUpperCase().includes('BOC')) updates.bankName = '中国银行';
else if (shortName.includes('农业') || shortName.toUpperCase().includes('ABC')) updates.bankName = '农业银行';
else if (shortName.includes('交通') || shortName.toUpperCase().includes('BOCOM')) updates.bankName = '交通银行';
else if (!currentValues.bankName) updates.bankName = shortName;
}
return updates;
}
}}
formFields={editing => [
// Row 1: linkedAccount (1.5) + name (1.0)
liabilityOptions.length > 0
? {
key: 'linkedAccount',
label: t('creditCard.fieldLinkedAccount'),
type: 'dropdown',
options: liabilityOptions,
placeholder: '请选择关联信用账户',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
}
: {
key: 'linkedAccount',
label: t('creditCard.fieldLinkedAccount'),
placeholder: 'Liabilities:CreditCard:CMB',
defaultValue: editing?.linkedAccount ?? '',
flex: 1.5,
row: 1,
},
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name, flex: 1.0, row: 1 },
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('creditCard.editTitle') : t('creditCard.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.card : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
// Row 2: bankName (1.0) + lastFour (1.0) + currency (1.0)
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: '招商银行', defaultValue: editing?.bankName, flex: 1.0, row: 2 },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric', flex: 1.0, row: 2 },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency ?? 'CNY', flex: 1.0, row: 2 },
// Row 3: billingDay (1.0) + paymentDay (1.0) + creditLimit (1.2)
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '5', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '25', keyboardType: 'numeric', flex: 1.0, row: 3 },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit ?? '10000', keyboardType: 'decimal-pad', flex: 1.2, row: 3 },
]}
formExtra={() => (
<Touchable
onPress={() => setIsQuickAddingAccount(true)}
style={styles.quickAddBtn}
>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '600' }]}>
</Text>
</Touchable>
)}
onSubmit={(values, editing) => {
const card: Omit<CreditCard, 'id'> = {
name: values.name?.trim() || t('creditCard.unnamed'),
lastFour: values.lastFour?.trim() || '0000',
bankName: values.bankName?.trim() || 'UNKNOWN',
billingDay: parseInt(values.billingDay, 10) || 1,
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
};
if (editing) {
updateCreditCard(editing.id, card);
} else {
addCreditCard({ ...card, id: generateId('cc') });
}
return true;
}}
onDelete={card => removeCreditCard(card.id)}
deleteConfirmText={card => t('creditCard.deleteConfirm', { name: card.name })}
deleteConfirmTitle={t('creditCard.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
</SafeAreaView>
{/* 快捷新建负债账户弹窗 */}
<AccountCreateModal
visible={isQuickAddingAccount}
defaultType="Liabilities"
onConfirm={handleQuickAddAccount}
onCancel={() => setIsQuickAddingAccount(false)}
/>
</>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
quickAddBtn: { paddingVertical: 6, alignItems: 'center', marginTop: 4 },
});

View File

@ -1,28 +1,29 @@
import React, { useState } from 'react';
import type { DuplicateDetail } from '../../domain/dedup';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as XLSX from 'xlsx';
// @ts-ignore
// 实例化纯 JS GBK 解码器Hermes 原生 TextDecoder 仅支持 UTF-8需强迫 text-encoding-gbk 返回其纯 JS 实现)
const GbkTextDecoder = (() => {
// @ts-ignore
const origDecoder = global.TextDecoder;
// @ts-ignore
const origEncoder = global.TextEncoder;
// @ts-ignore
global.TextDecoder = undefined;
// @ts-ignore
global.TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder;
// @ts-ignore
global.TextDecoder = origDecoder;
// @ts-ignore
global.TextEncoder = origEncoder;
return dec;
const origDecoder = (globalThis as any).TextDecoder;
const origEncoder = (globalThis as any).TextEncoder;
try {
(globalThis as any).TextDecoder = undefined;
(globalThis as any).TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder;
return dec;
} catch {
return origDecoder;
} finally {
(globalThis as any).TextDecoder = origDecoder;
(globalThis as any).TextEncoder = origEncoder;
}
})();
import { useLedgerStore } from '../../store/ledgerStore';
import { useImportStore } from '../../store/importStore';
import { useMetadataStore } from '../../store/metadataStore';
@ -31,6 +32,7 @@ import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { DedupBanner } from '../../components/DedupBanner';
import { ScreenHeader } from '../../components/ScreenHeader';
import { classifyWithCategories } from '../../domain/rules';
import { validateTransaction } from '../../domain/ledger';
import type { ImportedEvent } from '../../domain/types';
@ -67,7 +69,6 @@ function base64ToBytes(base64: string): Uint8Array {
export default function ImportScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
@ -143,7 +144,7 @@ export default function ImportScreen() {
// 6. 自动执行 Pipeline用户无需手动查找运行按钮
if (ledger) {
setStatus(t('importFlow.pipelineRunning'));
processEvents(ledger, []).then(result => {
processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.pipelineDone', { drafts: result.drafts.length, duplicates: result.duplicates.length }));
}).catch(e => {
@ -158,7 +159,7 @@ export default function ImportScreen() {
const runPipeline = () => {
if (!ledger) return;
processEvents(ledger, []).then(result => {
processEvents(ledger, ledger.transactions || []).then(result => {
setManualDuplicates(result.duplicates);
setStatus(t('importFlow.processed', { drafts: result.drafts.length, duplicates: result.duplicates.length, transfers: result.transferCount }));
}).catch(e => setStatus(String(e)));
@ -245,7 +246,7 @@ export default function ImportScreen() {
confirmDrafts(indices);
const successCount = drafts.length;
const failCount = pendingDrafts.length - successCount;
const failCount = failedPayees.length;
if (failCount === 0) {
setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
@ -269,11 +270,59 @@ export default function ImportScreen() {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
await addTransaction(classification.draft);
await addTransaction(classification.draft, undefined, true);
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(t('importFlow.forcedImport'));
Alert.alert('记账成功', `已成功将「${event.counterparty || event.memo || '交易'}」单独记入账本`);
} catch (err) {
setStatus(String(err));
Alert.alert('记账失败', String(err));
}
};
const handleLinkDuplicate = async (event: ImportedEvent) => {
if (!ledger) return;
try {
const rules = useMetadataStore.getState().rules;
const categories = useMetadataStore.getState().categories;
const classification = classifyWithCategories(event, rules, categories, ledger);
// 寻找对应的历史匹配交易
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === event.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
let linkTag = matchedTx?.links?.[0] || 'lnk-' + Math.random().toString(36).slice(2, 8);
// 为新草稿添加链接
const newDraft = {
...classification.draft,
links: Array.from(new Set([...(classification.draft.links || []), linkTag])),
};
await addTransaction(newDraft, undefined, true);
// 如果有历史匹配交易且它还没有此 linkTag同步写回历史交易 raw
if (matchedTx && !matchedTx.links.includes(linkTag)) {
const currentRaw = matchedTx.raw;
const firstLineEnd = currentRaw.indexOf('\n');
const header = firstLineEnd !== -1 ? currentRaw.slice(0, firstLineEnd) : currentRaw;
const rest = firstLineEnd !== -1 ? currentRaw.slice(firstLineEnd) : '';
const updatedHeader = `${header} ^${linkTag}`;
const updatedRaw = `${updatedHeader}${rest}`;
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
if (mobileBean.includes(currentRaw)) {
const newContent = mobileBean.replace(currentRaw, updatedRaw);
await replaceMobileBean(newContent);
}
}
setManualDuplicates(prev => prev.filter(ev => ev.id !== event.id));
setStatus(`已成功关联交易 (标签 ^${linkTag})`);
Alert.alert('关联成功', `已成功将「${event.counterparty || event.memo || '交易'}」与历史账单关联(关联标签 ^${linkTag}`);
} catch (err) {
setStatus(String(err));
Alert.alert('关联失败', String(err));
}
};
@ -284,15 +333,10 @@ export default function ImportScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.import')}</Text>
</View>
<ScreenHeader title={t('tab.import')} />
<FlatList
data={pendingDrafts}
keyExtractor={(item, index) => `${item.draft.sourceEventId || index}-${index}`}
keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`}
renderItem={({ item, index }) => (
<Card title={`${item.draft.date} · ${item.isTransfer ? t('importFlow.transfer') : t('importFlow.trade')} · ${item.draft.postings[0]?.amount ?? ''}`}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{item.draft.narration}</Text>
@ -349,23 +393,45 @@ export default function ImportScreen() {
{showDuplicates && (
<View style={styles.duplicatesList}>
{manualDuplicates.map((item) => {
const detail = lastResult?.duplicateDetails?.find((d: DuplicateDetail) => d.event.id === item.id);
const matchedTx = detail?.matchedWith?.rawTransaction;
const reason = detail?.reason || t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') });
const result = {
isDuplicate: true,
confidence: 'medium' as const,
reason: t('importFlow.duplicateReason', { date: item.occurredAt, payee: item.counterparty || t('importFlow.unknownPayee') })
confidence: (detail?.confidence ?? 'medium') as any,
reason,
};
return (
<Card key={item.id} title={`${item.occurredAt} · ${item.counterparty}`}>
<Card
key={item.id}
title={`${item.occurredAt} · ${item.counterparty || '未知来源'}`}
onPress={() => handleLinkDuplicate(item)}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{item.memo || t('importFlow.noDesc')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary }]}>
{t('importFlow.duplicateAmount', { amount: item.amount, currency: item.currency })}
</Text>
<DedupBanner
result={result}
onAccept={() => handleAcceptDuplicate(item)}
onReject={() => handleRejectDuplicate(item)}
{matchedTx && (
<View style={{ backgroundColor: `${theme.colors.accent}12`, padding: 8, borderRadius: 8, marginVertical: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontWeight: '700' }]}>
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, marginTop: 2 }]}>
{matchedTx.date} {matchedTx.payee ? matchedTx.payee + ' - ' : ''}{matchedTx.narration} ({matchedTx.postings[0]?.amount} {matchedTx.postings[0]?.currency})
</Text>
</View>
)}
<DedupBanner
result={result}
onAccept={() => handleAcceptDuplicate(item)}
onReject={() => handleRejectDuplicate(item)}
onLink={() => handleLinkDuplicate(item)}
/>
</Card>
);
@ -390,10 +456,9 @@ export default function ImportScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 },
buttonCol: { gap: 8, marginTop: 4 },
mono: { fontFamily: 'monospace', lineHeight: 20, marginTop: 8 },
mono: { fontVariant: ['tabular-nums'], lineHeight: 20, marginTop: 8 },
collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
duplicatesList: { gap: 12, marginTop: 8 },
});

View File

@ -1,191 +1,155 @@
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
/**
*
*
* (///) + //
* P5 ManagementScreen /
* handlers.openEdit/confirmDelete
*/
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { ManagementScreen } from '../../components/ManagementScreen';
import { toDateString } from '../../domain/decimal';
import type { RecurringTransaction } from '../../domain/recurring';
type ModalMode = { type: 'add' } | { type: 'edit'; item: RecurringTransaction } | null;
export default function RecurringScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
const [modal, setModal] = useState<ModalMode>(null);
const handleDelete = (item: RecurringTransaction) => {
Alert.alert(t('recurring.deleteTitle'), t('recurring.deleteConfirm', { name: item.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: () => removeRecurring(item.id),
},
]);
};
const handleConfirm = (values: Record<string, string>) => {
const nameVal = values.name?.trim();
const fromAccount = values.fromAccount?.trim();
const toAccount = values.toAccount?.trim();
const amountVal = values.amount?.trim();
const freq = (values.frequency?.trim() || 'monthly') as any;
const dateVal = values.nextDueDate?.trim() || new Date().toISOString().slice(0, 10);
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
return;
}
const draft = {
date: dateVal,
narration: nameVal,
postings: [
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
{ account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
if (modal?.type === 'add') {
addRecurring({
id: 'rec_' + generateId(),
name: nameVal,
draft,
frequency: freq,
interval: 1,
nextDueDate: dateVal,
enabled: true,
});
setModal(null);
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
} else if (modal?.type === 'edit') {
updateRecurring(modal.item.id, {
name: nameVal,
draft,
frequency: freq,
nextDueDate: dateVal,
});
setModal(null);
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
}
};
const fieldsFor = (item?: RecurringTransaction): FormField[] => [
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: item?.name },
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: item?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: item?.draft.postings[1]?.account || 'Expenses:Shopping' },
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: item?.draft.postings[1]?.amount },
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: item?.frequency || 'monthly' },
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: item?.nextDueDate || new Date().toISOString().slice(0, 10) },
];
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('recurring.title')}
</Text>
</View>
<ManagementScreen<RecurringTransaction>
title={t('recurring.title')}
items={recurringTransactions}
keyExtractor={item => item.id}
addLabel={t('recurring.add')}
emptyText={t('recurring.empty')}
renderItem={(item, { openEdit, confirmDelete }) => {
const amount = item.draft.postings[1]?.amount || '0.00';
const fromAcc = item.draft.postings[0]?.account || '';
const toAcc = item.draft.postings[1]?.account || '';
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
{t('recurring.add')}
</Text>
</Pressable>
return (
<Card title={item.name}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{fromAcc.split(':').pop()} {toAcc.split(':').pop()}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View>
{recurringTransactions.map(item => {
const amount = item.draft.postings[1]?.amount || '0.00';
const fromAcc = item.draft.postings[0]?.account || '';
const toAcc = item.draft.postings[1]?.account || '';
return (
<Card key={item.id} title={item.name}>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
{/* 显式编辑/删除按钮(本页不用长按手势,交互更显式) */}
<View style={styles.cardActions}>
<Pressable
onPress={openEdit}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
>
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
{t('recurring.edit')}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{fromAcc.split(':').pop()} {toAcc.split(':').pop()}
</Pressable>
<Pressable
onPress={confirmDelete}
style={[styles.actionBtn, { borderColor: theme.colors.border }]}
>
<Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('recurring.delete')}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View>
</Pressable>
</View>
</Card>
);
}}
formTitle={editing => (editing ? t('recurring.editTitle') : t('recurring.addTitle'))}
formFields={editing => [
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: editing?.name },
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: editing?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: editing?.draft.postings[1]?.account || 'Expenses:Shopping' },
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: editing?.draft.postings[1]?.amount },
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: editing?.frequency || 'monthly' },
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: editing?.nextDueDate || today },
]}
onSubmit={(values, editing) => {
const nameVal = values.name?.trim();
const fromAccount = values.fromAccount?.trim();
const toAccount = values.toAccount?.trim();
const amountVal = values.amount?.trim();
const freq = (values.frequency?.trim() || 'monthly') as RecurringTransaction['frequency'];
const dateVal = values.nextDueDate?.trim() || today;
<View style={styles.cardActions}>
<Pressable
onPress={() => setModal({ type: 'edit', item })}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
>
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
{t('recurring.edit')}
</Text>
</Pressable>
<Pressable
onPress={() => handleDelete(item)}
style={[styles.closeBtn, { borderColor: theme.colors.border }]}
>
<Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('recurring.delete')}
</Text>
</Pressable>
</View>
</Card>
);
})}
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
return false;
}
{recurringTransactions.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
{t('recurring.empty')}
</Text>
)}
</ScrollView>
const draft = {
date: dateVal,
narration: nameVal,
postings: [
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
{ account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('recurring.editTitle') : t('recurring.addTitle')}
fields={fieldsFor(modal?.type === 'edit' ? modal.item : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
if (editing) {
updateRecurring(editing.id, {
name: nameVal,
draft,
frequency: freq,
nextDueDate: dateVal,
});
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
} else {
addRecurring({
id: 'rec_' + generateId(),
name: nameVal,
draft,
frequency: freq,
interval: 1,
nextDueDate: dateVal,
enabled: true,
});
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
}
// 弹窗关闭由模板接管(返回 true
return true;
}}
onDelete={item => removeRecurring(item.id)}
deleteConfirmText={item => t('recurring.deleteConfirm', { name: item.name })}
deleteConfirmTitle={t('recurring.deleteTitle')}
/>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
});

View File

@ -1,106 +1,70 @@
/**
*
* ${placeholder}
* P5 ManagementScreen //
*/
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import React from 'react';
import { Pressable, Text } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { ManagementScreen } from '../../components/ManagementScreen';
type ModalMode = { type: 'add' } | { type: 'edit'; tpl: { id: string; name: string; template: string } } | null;
interface RemarkTemplate {
id: string;
name: string;
template: string;
}
export default function RemarkTemplateScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const templates = useMetadataStore(s => s.remarkTemplates);
const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate);
const [modal, setModal] = useState<ModalMode>(null);
const handleDelete = (tpl: { id: string; name: string }) => {
Alert.alert(t('remark.deleteTitle'), t('remark.deleteConfirm', { name: tpl.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTemplate(tpl.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() || t('common.untitled');
const template = values.template?.trim() || '';
if (modal?.type === 'add') {
addTemplate({ id: generateId('tpl'), name, template });
} else if (modal?.type === 'edit') {
updateTemplate(modal.tpl.id, { name, template });
}
setModal(null);
};
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: modal.tpl.name },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: modal.tpl.template },
] : [];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('remark.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('remark.add')}</Text>
<ManagementScreen<RemarkTemplate>
title={t('remark.title')}
items={templates}
keyExtractor={tpl => tpl.id}
addLabel={t('remark.add')}
emptyText={t('remark.empty')}
renderItem={(tpl, { openEdit, confirmDelete }) => (
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
<Card title={tpl.name}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{tpl.template}
</Text>
</Card>
</Pressable>
{templates.map(tpl => (
<Pressable
key={tpl.id}
onPress={() => setModal({ type: 'edit', tpl })}
onLongPress={() => handleDelete(tpl)}
>
<Card title={tpl.name}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]}>
{tpl.template}
</Text>
</Card>
</Pressable>
))}
{templates.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
{t('remark.empty')}
</Text>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
)}
formTitle={editing => (editing ? t('remark.editTitle') : t('remark.add'))}
formFields={editing => [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: editing?.name },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: editing?.template },
]}
onSubmit={(values, editing) => {
const name = values.name?.trim() || t('common.untitled');
const template = values.template?.trim() || '';
if (editing) {
updateTemplate(editing.id, { name, template });
} else {
addTemplate({ id: generateId('tpl'), name, template });
}
return true;
}}
onDelete={tpl => removeTemplate(tpl.id)}
deleteConfirmText={tpl => t('remark.deleteConfirm', { name: tpl.name })}
deleteConfirmTitle={t('remark.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('remark.editTitle') : t('remark.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮' },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
}
/>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
});

View File

@ -3,71 +3,27 @@
*
* ( ) + //
* BillPipeline ( AutoAccounting RuleGenerator)
* P5 ManagementScreen //
*/
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { ManagementScreen } from '../../components/ManagementScreen';
import type { Rule } from '../../domain/types';
type ModalMode = { type: 'add' } | { type: 'edit'; rule: Rule } | null;
export default function RulesScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const rules = useMetadataStore(s => s.rules);
const addRule = useMetadataStore(s => s.addRule);
const updateRule = useMetadataStore(s => s.updateRule);
const removeRule = useMetadataStore(s => s.removeRule);
const [modal, setModal] = useState<ModalMode>(null);
// 按 priority 降序排列
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
const handleDelete = (rule: Rule) => {
Alert.alert(t('rules.deleteTitle'), t('rules.deleteConfirm', { name: rule.id }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeRule(rule.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const rule: Omit<Rule, 'id' | 'hits'> = {
priority: parseInt(values.priority, 10) || 0,
counterpartyContains: values.counterpartyContains?.trim() || undefined,
memoContains: values.memoContains?.trim() || undefined,
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
};
if (modal?.type === 'add') {
addRule({ ...rule, id: generateId('rule'), hits: 0 });
} else if (modal?.type === 'edit') {
updateRule(modal.rule.id, rule);
}
setModal(null);
};
const fieldsFor = (rule?: Rule): FormField[] => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: rule ? String(rule.priority) : '', keyboardType: 'numeric' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' },
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: rule?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: rule?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: rule?.tags?.join(', ') ?? '' },
];
const formatCondition = (rule: Rule): string => {
const parts: string[] = [];
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
@ -76,68 +32,74 @@ export default function RulesScreen() {
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.rules')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('rules.add')}</Text>
</Pressable>
<Card title={t('rules.title')}>
{sorted.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('rules.empty')}</Text>
)}
{sorted.map(rule => (
<Pressable
key={rule.id}
onPress={() => setModal({ type: 'edit', rule })}
onLongPress={() => handleDelete(rule)}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
>
<View style={{ flex: 1 }}>
<View style={styles.ruleHeader}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{formatCondition(rule)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text>
</View>
</View>
</Pressable>
))}
<ManagementScreen<Rule>
title={t('tab.rules')}
items={[...rules].sort((a, b) => b.priority - a.priority)}
keyExtractor={rule => rule.id}
addLabel={t('rules.add')}
emptyText={t('rules.empty')}
renderItem={(rule, { openEdit, confirmDelete }) => (
<Card>
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
>
<View style={styles.ruleHeader}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{formatCondition(rule)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text>
</View>
</Pressable>
</Card>
)}
formTitle={editing => (editing ? t('rules.editTitle') : t('rules.add'))}
formFields={editing => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: editing ? String(editing.priority) : '', keyboardType: 'numeric' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: editing?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: editing?.memoContains ?? '' },
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: editing?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: editing?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: editing?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: editing?.tags?.join(', ') ?? '' },
]}
onSubmit={(values, editing) => {
const rule: Omit<Rule, 'id' | 'hits'> = {
priority: parseInt(values.priority, 10) || 0,
counterpartyContains: values.counterpartyContains?.trim() || undefined,
memoContains: values.memoContains?.trim() || undefined,
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
};
if (editing) {
updateRule(editing.id, rule);
} else {
addRule({ ...rule, id: generateId('rule'), hits: 0 });
}
return true;
}}
onDelete={rule => removeRule(rule.id)}
deleteConfirmText={rule => t('rules.deleteConfirm', { name: rule.id })}
deleteConfirmTitle={t('rules.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('rules.editTitle') : t('rules.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.rule : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
}
/>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
row: { borderTopWidth: 1, paddingTop: 10, gap: 3 },
ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
});

View File

@ -1,156 +1,156 @@
/**
* LLM / AI P8
*
*
* - AI openai / gemini / deepseek
* - API Key
* - Base URL
* -
* - AI
*/
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native';
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { createCommonStyles } from '../../theme/commonStyles';
import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function AISettingsScreen() {
const PROVIDERS: { key: string; label: string }[] = [
{ key: 'openai', label: 'OpenAI' },
{ key: 'gemini', label: 'Gemini' },
{ key: 'deepseek', label: 'DeepSeek' },
];
const PROVIDER_DEFAULT_URLS: Record<string, string> = {
openai: 'https://api.openai.com/v1',
gemini: 'https://generativelanguage.googleapis.com/v1beta',
deepseek: 'https://api.deepseek.com/v1',
};
const PROVIDER_DEFAULT_MODELS: Record<string, string> = {
openai: 'gpt-4o-mini',
gemini: 'gemini-2.0-flash',
deepseek: 'deepseek-chat',
};
export default function AiSettingsScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
// Settings State
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
const setDedupEnabled = useSettingsStore(s => s.setDedupEnabled);
const transferRecognitionEnabled = useSettingsStore(s => s.transferRecognitionEnabled);
const setTransferRecognitionEnabled = useSettingsStore(s => s.setTransferRecognitionEnabled);
const aiEnabled = useSettingsStore(s => s.aiEnabled);
const aiProviderId = useSettingsStore(s => s.aiProviderId);
const aiApiKey = useSettingsStore(s => s.aiApiKey) || '';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || 'https://api.openai.com/v1';
const aiModel = useSettingsStore(s => s.aiModel) || 'gpt-4o-mini';
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl) || '';
const aiModel = useSettingsStore(s => s.aiModel) || '';
const updateAiConfig = useSettingsStore(s => s.updateAiConfig);
const [aiModalVisible, setAiModalVisible] = useState(false);
const [localKey, setLocalKey] = useState(aiApiKey);
const [localUrl, setLocalUrl] = useState(aiBaseUrl);
const [localModel, setLocalModel] = useState(aiModel);
const aiFields: FormField[] = [
{ key: 'apiKey', label: t('settings.aiFieldApiKey'), placeholder: 'sk-xxxxxx', defaultValue: aiApiKey },
{ key: 'baseUrl', label: t('settings.aiFieldBaseUrl'), placeholder: 'https://api.openai.com/v1', defaultValue: aiBaseUrl },
{ key: 'model', label: t('settings.aiFieldModel'), placeholder: 'gpt-4o-mini', defaultValue: aiModel },
];
const saveAI = (values: Record<string, string>) => {
updateAiConfig({
aiApiKey: values.apiKey,
aiBaseUrl: values.baseUrl,
aiModel: values.model,
});
setAiModalVisible(false);
const inputStyle = {
backgroundColor: theme.colors.bgTertiary,
color: theme.colors.fgPrimary,
borderRadius: theme.radii.sm,
padding: 12,
fontSize: 14,
marginTop: 6,
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('settings.aiSettingsTitle')}
</Text>
</View>
<ScreenHeader title={t('settings.llmTitle')} />
<ScrollView contentContainerStyle={styles.content}>
{/* 自动记账与算法开关 */}
<Card title={t('settings.algorithmConfig')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.dedupLabel')}
</Text>
<Switch
value={dedupEnabled}
onValueChange={setDedupEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<View style={[styles.switchRow, { marginTop: 12 }]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.transferLabel')}
</Text>
<Switch
value={transferRecognitionEnabled}
onValueChange={setTransferRecognitionEnabled}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</Card>
{/* AI 智能助理配置 */}
<Card title={t('settings.aiAssistantTitle')}>
<View style={styles.switchRow}>
{/* AI 总开关 */}
<Card>
<View style={styles.row}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.enableAiHint')}
</Text>
<Switch
value={aiEnabled}
onValueChange={(val) => updateAiConfig({ aiEnabled: val })}
onValueChange={val => updateAiConfig({ aiEnabled: val })}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{aiEnabled && (
<View style={{ marginTop: 12, gap: 8 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('settings.aiProvider')}
</Text>
<View style={styles.optionRow}>
{['openai', 'gemini', 'deepseek'].map((provider) => {
const active = aiProviderId === provider;
return (
<Pressable
key={provider}
onPress={() => updateAiConfig({ aiProviderId: provider as any })}
style={[
styles.option,
{
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
{provider.toUpperCase()}
</Text>
</Pressable>
);
})}
</View>
<View style={{ marginTop: 4 }}>
<Button label={t('settings.aiConfigBtn')} onPress={() => setAiModalVisible(true)} variant="secondary" />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{t('settings.aiCurrentModel', { model: aiModel, url: aiBaseUrl })}
</Text>
</View>
)}
</Card>
{/* 服务商选择 */}
<Card title={t('automation.aiProviderLabel')}>
<View style={styles.chipRow}>
{PROVIDERS.map(p => (
<Pressable
key={p.key}
onPress={() => updateAiConfig({ aiProviderId: p.key as 'openai' | 'gemini' | 'deepseek' })}
style={[
commonStyles.chip,
aiProviderId === p.key && commonStyles.chipActive,
]}
>
<Text style={[commonStyles.chipText, aiProviderId === p.key && commonStyles.chipTextActive]}>
{p.label}
</Text>
</Pressable>
))}
</View>
</Card>
{/* API Key */}
<Card title={t('settings.aiFieldApiKey')}>
<TextInput
style={inputStyle}
value={localKey}
onChangeText={setLocalKey}
onBlur={() => updateAiConfig({ aiApiKey: localKey })}
placeholder="sk-..."
placeholderTextColor={theme.colors.fgSecondary}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
/>
</Card>
{/* Base URL */}
<Card title={t('settings.aiFieldBaseUrl')}>
<TextInput
style={inputStyle}
value={localUrl}
onChangeText={setLocalUrl}
onBlur={() => updateAiConfig({ aiBaseUrl: localUrl })}
placeholder={PROVIDER_DEFAULT_URLS[aiProviderId] || PROVIDER_DEFAULT_URLS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</Card>
{/* 模型 */}
<Card title={t('settings.aiFieldModel')}>
<TextInput
style={inputStyle}
value={localModel}
onChangeText={setLocalModel}
onBlur={() => updateAiConfig({ aiModel: localModel })}
placeholder={PROVIDER_DEFAULT_MODELS[aiProviderId] || PROVIDER_DEFAULT_MODELS.openai}
placeholderTextColor={theme.colors.fgSecondary}
autoCapitalize="none"
autoCorrect={false}
/>
</Card>
</ScrollView>
{/* AI 配置模态框 */}
<FormModal
visible={aiModalVisible}
title={t('settings.aiConfigTitle')}
fields={aiFields}
onConfirm={saveAI}
onCancel={() => setAiModalVisible(false)}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
content: { padding: 16, paddingBottom: 64, gap: 12 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
chipRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' },
});

View File

@ -0,0 +1,42 @@
/** 解析诊断自交易页迁入spec §7.2/§7.4 数据组)。 */
import React from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLedgerStore } from '../../store/ledgerStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function DiagnosticsScreen() {
const { theme } = useTheme();
const t = useT();
const ledger = useLedgerStore(s => s.ledger);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader title={t('settings.diagnostics')} />
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('diagnostics.title')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('diagnostics.hint')}
</Text>
</Card>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16 },
});

448
src/app/settings/logs.tsx Normal file
View File

@ -0,0 +1,448 @@
/**
* -> ->
*
*
* 1. +
* 2. 4 DEBUG/INFO/WARN/ERROR/Tag
* 3. JSON data Stack Trace
* 4. /
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
FlatList,
Modal,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { ScreenHeader } from '../../components/ScreenHeader';
import { useT } from '../../i18n';
import { useTheme } from '../../theme';
import { LogEntry, LogFileInfo, LogLevel, LEVEL_LABELS, logger } from '../../utils/logger';
export default function LogsScreen() {
const { theme } = useTheme();
const t = useT();
const [selectedFileDate, setSelectedFileDate] = useState<string | null>(null); // null 表示“今天(实时)”
const [logFiles, setLogFiles] = useState<LogFileInfo[]>([]);
const [fileLogs, setFileLogs] = useState<LogEntry[]>([]);
const [liveLogs, setLiveLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
const [dateModalOpen, setDateModalOpen] = useState(false);
const selectedFile = useMemo(() => {
return logFiles.find(f => f.date === selectedFileDate);
}, [logFiles, selectedFileDate]);
const [searchQuery, setSearchQuery] = useState<string>('');
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
// 刷新日志文件列表
const refreshFileList = useCallback(async () => {
const files = await logger.getLogFiles();
setLogFiles(files);
}, []);
// 刷新实时内存日志与历史日志
const refreshLogs = useCallback(async () => {
// 强制刷新日志写入队列
await logger.flushQueue();
await refreshFileList();
if (selectedFileDate === null) {
const todayLogs = await logger.getTodayLogs();
setLiveLogs(todayLogs);
} else {
const loaded = await logger.loadLogsFromFile(selectedFileDate);
setFileLogs(loaded);
}
}, [selectedFileDate, refreshFileList]);
useEffect(() => {
void refreshLogs();
}, [refreshLogs]);
// 当前激活显示的原始日志列表
const rawLogs = selectedFileDate === null ? liveLogs : fileLogs;
// 过滤后的日志列表(按时间倒序排列:最新的日志在前面)
const filteredLogs = useMemo(() => {
const list = rawLogs.filter(e => {
// 级别过滤
if (levelFilter !== 'ALL' && e.level !== levelFilter) {
return false;
}
// 搜索过滤
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
const msgMatch = e.message.toLowerCase().includes(q);
const tagMatch = e.tag.toLowerCase().includes(q);
const dataMatch = e.data ? JSON.stringify(e.data).toLowerCase().includes(q) : false;
if (!msgMatch && !tagMatch && !dataMatch) {
return false;
}
}
return true;
});
// 倒序排列(最新在前)
return list.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}, [rawLogs, levelFilter, searchQuery]);
// 统计各级别数量
const counts = useMemo(() => {
const res = { ALL: rawLogs.length, [LogLevel.DEBUG]: 0, [LogLevel.INFO]: 0, [LogLevel.WARN]: 0, [LogLevel.ERROR]: 0 };
for (const item of rawLogs) {
if (res[item.level] !== undefined) {
res[item.level]++;
}
}
return res;
}, [rawLogs]);
// 导出日志文件/文本
const handleExport = async () => {
try {
const exportText = logger.exportAsFormattedText(filteredLogs);
if (!exportText || filteredLogs.length === 0) {
Alert.alert(t('common.error'), t('logs.empty'));
return;
}
if (await Sharing.isAvailableAsync()) {
const fileDateStr = selectedFileDate ?? new Date().toISOString().slice(0, 10);
const tempPath = `${FileSystem.cacheDirectory}app-log-${fileDateStr}-${Date.now()}.txt`;
await FileSystem.writeAsStringAsync(tempPath, exportText);
await Sharing.shareAsync(tempPath, {
mimeType: 'text/plain',
dialogTitle: t('logs.export'),
});
} else {
await Share.share({
message: exportText,
title: t('logs.title'),
});
}
} catch (e) {
Alert.alert(t('error.exportFail'), e instanceof Error ? e.message : String(e));
}
};
// 清空所有日志
const handleClear = () => {
Alert.alert(
t('logs.clearConfirmTitle'),
t('logs.clearConfirmDesc'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('logs.clear'),
style: 'destructive',
onPress: async () => {
await logger.clearAllLogs();
setSelectedFileDate(null);
await refreshLogs();
Alert.alert(t('common.confirm'), t('logs.clearSuccess'));
},
},
],
);
};
// 获取级别对应的色彩
const getLevelStyle = (level: LogLevel) => {
switch (level) {
case LogLevel.DEBUG:
return { bg: theme.colors.bgTertiary, fg: theme.colors.fgSecondary };
case LogLevel.INFO:
return { bg: theme.colors.accent + '22', fg: theme.colors.accent };
case LogLevel.WARN:
return { bg: '#E6A23C22', fg: '#E6A23C' };
case LogLevel.ERROR:
return { bg: theme.colors.error + '22', fg: theme.colors.error };
}
};
// 格式化时间戳 (HH:mm:ss.SSS)
const formatTime = (isoString: string) => {
try {
const d = new Date(isoString);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${h}:${m}:${s}.${ms}`;
} catch {
return isoString.slice(11, 23);
}
};
const renderHeaderRight = (
<View style={styles.headerRight}>
<Pressable onPress={handleExport} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
</Pressable>
<Pressable onPress={handleClear} hitSlop={8} style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="trash-outline" size={22} color={theme.colors.error} />
</Pressable>
</View>
);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader title={t('logs.title')} right={renderHeaderRight} />
{/* 日志日期 Dropdown 触发按钮 */}
<View style={[styles.fileSelectorBar, { borderBottomColor: theme.colors.divider }]}>
<Pressable
onPress={() => setDateModalOpen(true)}
style={[
styles.dropdownTriggerBtn,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
>
<Ionicons name="calendar-outline" size={16} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '600', flex: 1, marginLeft: 8 }]}>
{selectedFileDate === null
? t('logs.todayLive')
: `${selectedFileDate} (${Math.round((selectedFile?.size ?? 0) / 1024)}KB)`}
</Text>
<Ionicons name="chevron-down" size={16} color={theme.colors.fgSecondary} />
</Pressable>
</View>
{/* 日志日期 Dropdown 弹出菜单 Modal */}
<Modal
visible={dateModalOpen}
transparent
animationType="fade"
onRequestClose={() => setDateModalOpen(false)}
>
<Pressable
style={[styles.modalOverlay, { backgroundColor: theme.colors.overlay }]}
onPress={() => setDateModalOpen(false)}
>
<Pressable
style={[
styles.dropdownMenu,
theme.shadows.lg,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
onPress={e => e.stopPropagation()}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontWeight: '600' }]}>
</Text>
<ScrollView style={{ maxHeight: 300 }}>
{/* 今天(实时) */}
<Pressable
onPress={() => {
setSelectedFileDate(null);
setDateModalOpen(false);
}}
style={[
styles.menuItem,
{
backgroundColor: selectedFileDate === null ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: selectedFileDate === null ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={[theme.typography.bodySmall, { color: selectedFileDate === null ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
{t('logs.todayLive')}
</Text>
{selectedFileDate === null && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
)}
</Pressable>
{/* 历史日志文件 */}
{logFiles.map(file => {
const isSelected = selectedFileDate === file.date;
return (
<Pressable
key={file.name}
onPress={() => {
setSelectedFileDate(file.date);
setDateModalOpen(false);
}}
style={[
styles.menuItem,
{
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
>
<Text style={[theme.typography.bodySmall, { color: isSelected ? theme.colors.accent : theme.colors.fgPrimary, fontWeight: '600' }]}>
{file.date} ({Math.round(file.size / 1024)}KB)
</Text>
{isSelected && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
)}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
{/* 搜索框 */}
<View style={styles.searchContainer}>
<View style={[styles.searchBox, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]}>
<Ionicons name="search-outline" size={16} color={theme.colors.fgSecondary} />
<TextInput
style={[theme.typography.bodySmall, styles.searchInput, { color: theme.colors.fgPrimary }]}
placeholder={t('logs.searchPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
value={searchQuery}
onChangeText={setSearchQuery}
/>
{searchQuery ? (
<Pressable onPress={() => setSearchQuery('')} hitSlop={6}>
<Ionicons name="close-circle" size={18} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
</View>
</View>
{/* 级别 Chip 筛选 */}
<View style={styles.levelFilterRow}>
{(['ALL', LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR] as const).map(lvl => {
const isSelected = levelFilter === lvl;
const label = lvl === 'ALL' ? t('logs.filterLevelAll', { count: counts.ALL }) : `${LEVEL_LABELS[lvl]} (${counts[lvl]})`;
return (
<Pressable
key={String(lvl)}
onPress={() => setLevelFilter(lvl)}
style={[
styles.levelChip,
{
backgroundColor: isSelected ? theme.colors.accent : theme.colors.bgTertiary,
},
]}
>
<Text
style={[
theme.typography.caption,
{ color: isSelected ? theme.colors.fgInverse : theme.colors.fgSecondary, fontWeight: isSelected ? '700' : '400' },
]}
>
{label}
</Text>
</Pressable>
);
})}
</View>
{/* 日志列表 */}
<FlatList
data={filteredLogs}
keyExtractor={(_, index) => String(index)}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="document-text-outline" size={48} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 12 }]}>
{t('logs.empty')}
</Text>
</View>
}
renderItem={({ item, index }) => {
const style = getLevelStyle(item.level);
const isExpanded = expandedIndex === index;
const hasData = item.data !== undefined && item.data !== null;
return (
<Pressable
onPress={() => setExpandedIndex(isExpanded ? null : index)}
style={[
styles.logCard,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
>
<View style={styles.cardHeader}>
<View style={[styles.levelBadge, { backgroundColor: style.bg }]}>
<Text style={[theme.typography.caption, { color: style.fg, fontWeight: '700', fontSize: 10 }]}>
{LEVEL_LABELS[item.level]}
</Text>
</View>
<Text style={[theme.typography.caption, styles.tagText, { color: theme.colors.accent }]}>
[{item.tag}]
</Text>
<Text style={[theme.typography.caption, styles.timeText, { color: theme.colors.fgSecondary }]}>
{formatTime(item.timestamp)}
</Text>
</View>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, marginTop: 6, lineHeight: 18 }]}>
{item.message}
</Text>
{hasData && (
<View style={styles.dataFooter}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{isExpanded ? t('logs.collapseData') : t('logs.expandData')}
</Text>
<Ionicons name={isExpanded ? 'chevron-up' : 'chevron-down'} size={14} color={theme.colors.fgSecondary} />
</View>
)}
{isExpanded && hasData && (
<View style={[styles.dataBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontFamily: 'monospace' }]}>
{typeof item.data === 'object' ? JSON.stringify(item.data, null, 2) : String(item.data)}
</Text>
</View>
)}
</Pressable>
);
}}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 16, marginRight: 8 },
fileSelectorBar: { paddingHorizontal: 16, paddingVertical: 8, borderBottomWidth: 1 },
dropdownTriggerBtn: { flexDirection: 'row', alignItems: 'center', borderRadius: 8, borderWidth: 1, paddingHorizontal: 12, height: 38 },
modalOverlay: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
dropdownMenu: { width: '100%', maxWidth: 360, borderRadius: 16, borderWidth: 1, padding: 16 },
menuItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 12, paddingVertical: 10, borderRadius: 8, borderWidth: 1, marginBottom: 6 },
searchContainer: { paddingHorizontal: 16, paddingTop: 10 },
searchBox: { flexDirection: 'row', alignItems: 'center', borderRadius: 8, borderWidth: 1, paddingHorizontal: 10, height: 36, gap: 8 },
searchInput: { flex: 1, paddingVertical: 0 },
levelFilterRow: { flexDirection: 'row', paddingHorizontal: 16, paddingVertical: 10, gap: 6, flexWrap: 'wrap' },
levelChip: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: 12 },
listContent: { padding: 16, gap: 10, paddingBottom: 40 },
logCard: { borderRadius: 8, borderWidth: 1, padding: 12 },
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
levelBadge: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
tagText: { fontWeight: '600' },
timeText: { marginLeft: 'auto' },
dataFooter: { flexDirection: 'row', alignItems: 'center', marginTop: 8, gap: 4 },
dataBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
emptyContainer: { alignItems: 'center', justifyContent: 'center', paddingTop: 60 },
});

View File

@ -1,17 +1,16 @@
import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
import { Card } from '../../components/Card';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function PreferencesScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
// Settings State
const themeMode = useSettingsStore(s => s.themeMode);
@ -24,6 +23,8 @@ export default function PreferencesScreen() {
const reminderHour = useSettingsStore(s => s.reminderHour);
const reminderMinute = useSettingsStore(s => s.reminderMinute);
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
{ mode: 'light', labelKey: 'settings.themeLight' },
@ -38,14 +39,7 @@ export default function PreferencesScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('settings.preferencesTitle')}
</Text>
</View>
<ScreenHeader title={t('settings.preferencesTitle')} />
<ScrollView contentContainerStyle={styles.content}>
{/* 外观设置 */}
@ -106,6 +100,24 @@ export default function PreferencesScreen() {
</View>
</Card>
{/* 记账 */}
<Card title={t('settings.entryTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.numpadEntry')}
</Text>
<Switch
value={numpadGlobalEntry}
onValueChange={setNumpadGlobalEntry}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 6 }]}>
{t('settings.numpadEntryDesc')}
</Text>
</Card>
{/* 安全设置 */}
<Card title={t('settings.security')}>
<View style={styles.switchRow}>
@ -188,18 +200,41 @@ export default function PreferencesScreen() {
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
</Text>
<Pressable
onPress={async () => {
// 简单的时间调整:小时 +1 循环
const newHour = (reminderHour + 1) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ marginLeft: 12, padding: 4 }}
>
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
</Pressable>
<View style={{ flexDirection: 'row', gap: 4, marginLeft: 12 }}>
<Pressable
onPress={async () => {
const newHour = (reminderHour - 1 + 24) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ padding: 4 }}
>
<Ionicons name="remove-circle-outline" size={20} color={theme.colors.accent} />
</Pressable>
<Pressable
onPress={async () => {
const newHour = (reminderHour + 1) % 24;
updateReminderConfig({ reminderHour: newHour });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
}}
style={{ padding: 4 }}
>
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
</Pressable>
<Pressable
onPress={async () => {
const newMinute = (reminderMinute + 5) % 60;
updateReminderConfig({ reminderMinute: newMinute });
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
await setupDailyReminder(new RealNotificationScheduler(), reminderHour, newMinute);
}}
style={{ padding: 4 }}
>
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
</Pressable>
</View>
</View>
)}
</Card>
@ -210,7 +245,6 @@ export default function PreferencesScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 },
optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },

View File

@ -1,8 +1,6 @@
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
@ -15,6 +13,7 @@ import { useMetadataStore } from '../../store/metadataStore';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal';
import { ScreenHeader } from '../../components/ScreenHeader';
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
@ -24,7 +23,6 @@ import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../serv
export default function SyncSettingsScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
// Sync Configuration
const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
@ -235,9 +233,9 @@ export default function SyncSettingsScreen() {
const content = await FileSystem.readAsStringAsync(file.uri);
const bundle = deserializeBundle(content);
const restoredFiles = restoreFiles(bundle);
// 用恢复的 content 替换当前内容,支持新版 main.bean 并且兼容旧版 mobile.bean
const restoredMobileBean = restoredFiles.find(f => f.path === 'main.bean')?.content || restoredFiles.find(f => f.path === 'mobile.bean')?.content || '';
await replaceMobileBean(restoredMobileBean);
// 用恢复的 content 替换当前内容
const restoredMainBean = restoredFiles.find(f => f.path === 'main.bean')?.content || '';
await replaceMobileBean(restoredMainBean);
// 恢复设置v2
if (bundle.settings) {
@ -456,14 +454,7 @@ export default function SyncSettingsScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('sync.title')}
</Text>
</View>
<ScreenHeader title={t('sync.title')} />
<ScrollView contentContainerStyle={styles.content}>
{/* 云同步配置与云端操作 */}
@ -539,7 +530,6 @@ export default function SyncSettingsScreen() {
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 },
syncBtnRow: { flexDirection: 'row', gap: 8 },
buttonRow: { flexDirection: 'row', gap: 8 },

View File

@ -1,141 +1,92 @@
/**
* plan.md1.1 tag/index+ 1
* ManagementScreen P2
*
* () + //
* .bean #tag , [\w-]
*/
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal';
import { ManagementScreen } from '../../components/ManagementScreen';
import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/tags';
type ModalMode = { type: 'add' } | { type: 'edit'; tag: Tag } | null;
const COLORS = ['#F44336', '#E91E63', '#9C27B0', '#2196F3', '#00BCD4', '#4CAF50', '#FF9800', '#795548'];
export default function TagScreen() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag);
const [modal, setModal] = useState<ModalMode>(null);
const [selectedColor, setSelectedColor] = useState(COLORS[0]);
const handleDelete = (tag: Tag) => {
Alert.alert(t('tag.deleteTitle'), t('tag.deleteConfirm', { name: tag.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTag(tag.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return;
}
if (modal?.type === 'add') {
addTag({ id: generateId('tag'), name, color: selectedColor });
} else if (modal?.type === 'edit') {
updateTag(modal.tag.id, { name, color: selectedColor });
}
setModal(null);
};
const openModal = (mode: ModalMode) => {
if (mode?.type === 'edit') setSelectedColor(mode.tag.color);
else setSelectedColor(COLORS[0]);
setModal(mode);
};
const editFields: FormField[] = modal?.type === 'edit'
? [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: modal.tag.name }]
: [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food' }];
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('tag.title')}</Text>
</View>
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('tag.count', { count: tags.length })}>
<Pressable onPress={() => openModal({ type: 'add' })} style={styles.addBtn}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('tag.add')}</Text>
</Pressable>
<View style={[styles.grid, { gap: 8, paddingTop: 4 }]}>
{tags.map(tag => (
<Pressable
key={tag.id}
onPress={() => openModal({ type: 'edit', tag })}
onLongPress={() => handleDelete(tag)}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
))}
{tags.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('tag.empty')}</Text>
)}
</View>
</Card>
{/* 颜色选择器(模态打开时显示) */}
{modal && (
<Card title={t('tag.selectColor')}>
<View style={styles.colorRow}>
{COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
</Card>
)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
<ManagementScreen<Tag>
title={t('tag.title')}
items={tags}
keyExtractor={tag => tag.id}
addLabel={t('tag.add')}
emptyText={t('tag.empty')}
listStyle={styles.chipGrid}
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
renderItem={(tag, { openEdit, confirmDelete }) => (
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
)}
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
]}
formExtra={() => (
<View style={styles.colorRow}>
{TAG_COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
)}
onSubmit={(values, editing) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return false;
}
if (editing) updateTag(editing.id, { name, color: selectedColor });
else addTag({ id: generateId('tag'), name, color: selectedColor });
return true;
}}
onDelete={tag => removeTag(tag.id)}
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
deleteConfirmTitle={t('tag.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
</ScrollView>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('tag.editTitle') : t('tag.add')}
fields={editFields}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
}
/>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
grid: { flexDirection: 'row', flexWrap: 'wrap' },
chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 8 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 },
});

View File

@ -15,13 +15,16 @@ import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card';
import { useLedgerStore } from '../../store/ledgerStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { hash } from '../../domain/ledger';
import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard';
import { ScreenHeader } from '../../components/ScreenHeader';
import { logger } from '../../utils/logger';
export default function TransactionDetailScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
@ -48,7 +51,7 @@ export default function TransactionDetailScreen() {
// 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => {
if (!ledger) return [];
return ledger.transactions
const list = ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => {
if (!searchQuery) return true;
@ -59,17 +62,19 @@ export default function TransactionDetailScreen() {
t.postings.some(p => p.account.toLowerCase().includes(q))
);
})
.reverse() // 解决截断 Bug将顺序反转优先展示最新的交易
.slice(0, 30);
.sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
}, [ledger, id, tx?.links, searchQuery]);
const handleLinkTransaction = async (targetTx: typeof tx) => {
if (!tx || !targetTx) return;
let linkTag = tx.links[0] || targetTx.links[0] || '';
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 取出已有链接标签,若没有则生成一个随机哈希标签
let linkTag = tx.links[0] || targetTx.links[0];
if (!linkTag) {
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
}
@ -97,10 +102,33 @@ export default function TransactionDetailScreen() {
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
let newContent = mobileBean;
newContent = newContent.replace(tx.raw, newCurrentRaw);
newContent = newContent.replace(targetTx.raw, newTargetRaw);
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, {
linkTag,
transaction1: {
id: tx.id,
date: tx.date,
payee: tx.payee,
narration: tx.narration,
postings: tx.postings,
},
transaction2: {
id: targetTx.id,
date: targetTx.date,
payee: targetTx.payee,
narration: targetTx.narration,
postings: targetTx.postings,
},
});
setLinkModalVisible(false);
// 解决 race condition直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
@ -110,6 +138,7 @@ export default function TransactionDetailScreen() {
}
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.linkFail'), String(e));
}
};
@ -132,13 +161,31 @@ export default function TransactionDetailScreen() {
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n');
newContent = newContent.replace(companion.raw, newRaw);
newContent = newContent.replaceAll(companion.raw, newRaw);
if (companion.id === tx.id) {
newCurrentRaw = newRaw;
}
}
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n ');
logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, {
linkTag,
unlinkedCount: companionTxs.length,
unlinkedTransactions: companionTxs.map(c => ({
id: c.id,
date: c.date,
payee: c.payee,
narration: c.narration,
postings: c.postings,
})),
});
// 解决 race condition直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`);
@ -147,6 +194,7 @@ export default function TransactionDetailScreen() {
}
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.unlinkFail'), String(e));
}
};
@ -154,10 +202,7 @@ export default function TransactionDetailScreen() {
if (!tx) {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('common.notFound')}</Text>
</View>
<ScreenHeader title={t('common.notFound')} />
<View style={styles.content}>
<Card title={t('transaction.notFoundTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
@ -178,10 +223,7 @@ export default function TransactionDetailScreen() {
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<View style={styles.header}>
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('transaction.detailTitle')}</Text>
</View>
<ScreenHeader title={t('transaction.detailTitle')} />
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
{/* 摘要卡片 */}
<Card title={t('transaction.summary')}>
@ -200,39 +242,111 @@ export default function TransactionDetailScreen() {
</View>
</Card>
{/* Postings */}
{/* 资金流向 */}
<Card title={`${t('transaction.postings')} (${tx.postings.length})`}>
{tx.postings.map((p, i) => (
<View key={i} style={[styles.postingRow, { borderTopColor: theme.colors.divider, borderTopWidth: i > 0 ? 1 : 0 }]}>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
{p.account}
</Text>
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{k}: {v}
</Text>
))}
</View>
<View style={{ alignItems: 'flex-end' }}>
{p.amount && (
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>
{p.amount} {p.currency ?? ''}
</Text>
)}
{p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{p.cost}
</Text>
)}
{p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
@ {p.price}
</Text>
)}
</View>
</View>
))}
<View style={{ gap: 8 }}>
{tx.postings.map((p, i) => {
const parts = p.account.split(':');
const rootType = parts[0];
const accountShortName = parts.slice(1).join(':') || p.account;
let categoryTag = t('account.title');
let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline';
let tagBg = `${theme.colors.accent}15`;
let tagColor = theme.colors.accent;
if (rootType === 'Assets') {
categoryTag = t('account.rootTypes.Assets');
iconName = 'wallet-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Expenses') {
categoryTag = t('account.rootTypes.Expenses');
iconName = 'cart-outline';
tagBg = `${theme.colors.financial.expense}15`;
tagColor = theme.colors.financial.expense;
} else if (rootType === 'Income') {
categoryTag = t('account.rootTypes.Income');
iconName = 'cash-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Liabilities') {
categoryTag = t('account.rootTypes.Liabilities');
iconName = 'card-outline';
tagBg = `${theme.colors.warning}15`;
tagColor = theme.colors.warning;
} else if (rootType === 'Equity') {
categoryTag = t('account.rootTypes.Equity');
iconName = 'options-outline';
}
const amountVal = parseFloat(p.amount ?? '0');
const isNegative = amountVal < 0;
return (
<View
key={i}
style={[
styles.flowCardItem,
{
backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg,
},
]}
>
<View style={[styles.flowIconBox, { backgroundColor: tagBg }]}>
<Ionicons name={iconName} size={18} color={tagColor} />
</View>
<View style={{ flex: 1, marginLeft: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View style={[styles.miniTypeTag, { backgroundColor: `${theme.colors.fgSecondary}15` }]}>
<Text style={{ fontSize: 10, color: theme.colors.fgSecondary, fontWeight: '600' }}>
{categoryTag}
</Text>
</View>
</View>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', marginTop: 2 }]}>
{accountShortName}
</Text>
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
<Text key={k} style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{k}: {v}
</Text>
))}
</View>
<View style={{ alignItems: 'flex-end', justifyContent: 'center' }}>
{p.amount && (
<Text
style={[
theme.typography.body,
{
color: isNegative ? theme.colors.financial.expense : theme.colors.fgPrimary,
fontWeight: '700',
},
]}
>
{p.amount} {p.currency ?? ''}
</Text>
)}
{p.cost && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: {p.cost}
</Text>
)}
{p.price && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
: @ {p.price}
</Text>
)}
</View>
</View>
);
})}
</View>
</Card>
{/* 标签 */}
@ -297,9 +411,13 @@ export default function TransactionDetailScreen() {
</Card>
)}
{/* 元数据 */}
{/* 元数据 / 附加信息 */}
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
<Card title={t('transaction.metadata')}>
<Card
title={`${t('transaction.metadata')} (${Object.keys(tx.metadata).length})`}
collapsible
defaultCollapsed
>
{Object.entries(tx.metadata).map(([k, v]) => (
<View key={k} style={styles.metaRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{k}</Text>
@ -310,8 +428,12 @@ export default function TransactionDetailScreen() {
)}
{/* 原始 .bean */}
<Card title={t('transaction.rawBean')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontFamily: 'monospace', lineHeight: 20 }]}>
<Card
title={t('transaction.rawBean')}
collapsible
defaultCollapsed
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
{tx.raw}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
@ -323,7 +445,7 @@ export default function TransactionDetailScreen() {
{isEditable && (
<View style={styles.editActions}>
<Pressable
onPress={() => router.push({ pathname: '/transaction/new', params: { editId: id } })}
onPress={() => useNumpadUiStore.getState().open({ editId: id })}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="create-outline" size={16} color={theme.colors.accent} />
@ -426,4 +548,7 @@ const styles = StyleSheet.create({
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
});

View File

@ -1,792 +0,0 @@
import React, { useMemo, useState, useEffect, useRef } from 'react';
import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert, Pressable, LayoutAnimation, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { CategoryPicker } from '../../components/CategoryPicker';
import { TagPicker } from '../../components/TagPicker';
import { PostingEditor } from '../../components/PostingEditor';
import { tagsToBeanSyntax } from '../../domain/tags';
import { serializeTransaction } from '../../domain/ledger';
import { buildAndSaveTransaction } from '../../domain/transactionBuilder';
import { matchCategory } from '../../domain/categories';
import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge';
import { OcrProcessor } from '../../domain/ocrProcessor';
import type { Category } from '../../domain/categories';
import type { Tag } from '../../domain/tags';
import type { Posting } from '../../domain/types';
type Direction = 'expense' | 'income' | 'transfer';
export default function NewTransactionScreen() {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const router = useRouter();
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
const addTransaction = useLedgerStore(s => s.addTransaction);
const editTransaction = useLedgerStore(s => s.editTransaction);
const ledger = useLedgerStore(s => s.ledger);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const today = new Date().toISOString().slice(0, 10);
// 基础表单状态
const [date, setDate] = useState(today);
const [narration, setNarration] = useState('');
const [payee, setPayee] = useState('');
const [currency, setCurrency] = useState('CNY');
const [amount, setAmount] = useState('');
const [direction, setDirection] = useState<Direction>('expense');
const [sourceAccount, setSourceAccount] = useState('Assets:支付宝余额');
const [targetAccount, setTargetAccount] = useState('Assets:微信零钱');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [status, setStatus] = useState('');
// 高级模式状态
const [isAdvanced, setIsAdvanced] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [postings, setPostings] = useState<Posting[]>([
{ account: 'Assets:支付宝余额', amount: '', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '', currency: 'CNY' }
]);
const [isSubmitted, setIsSubmitted] = useState(false);
const navigation = useNavigation();
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
const hasChanges =
amount.trim() !== '' ||
payee.trim() !== '' ||
narration.trim() !== '' ||
selectedTags.length > 0 ||
(isAdvanced && postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''));
if (!hasChanges || isSubmitted) {
return;
}
e.preventDefault();
Alert.alert(
t('transaction.unsavedTitle'),
t('transaction.unsavedMessage'),
[
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{
text: t('common.discard'),
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action),
},
]
);
});
return unsubscribe;
}, [navigation, amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted]);
// === 草稿预填模式 ===
useEffect(() => {
if (draftJson) {
try {
const draft = JSON.parse(draftJson);
setDate(draft.date ? draft.date.slice(0, 10) : today);
setNarration(draft.narration || '');
setPayee(draft.payee || '');
const expensePosting = draft.postings.find((p: any) => p.account.startsWith('Expenses'));
const incomePosting = draft.postings.find((p: any) => p.account.startsWith('Income'));
const assetsPostings = draft.postings.filter((p: any) => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'));
if (expensePosting) {
setDirection('expense');
setCurrency(expensePosting.currency || 'CNY');
setAmount((expensePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else if (incomePosting) {
setDirection('income');
setCurrency(incomePosting.currency || 'CNY');
setAmount((incomePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else {
setDirection('transfer');
if (draft.postings.length >= 2) {
setCurrency(draft.postings[0].currency || 'CNY');
setAmount((draft.postings[0].amount || '').replace(/^-/, ''));
setSourceAccount(draft.postings[0].account);
setTargetAccount(draft.postings[1].account);
}
}
if (draft.postings && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
}
} catch (e) {
console.error('Failed to parse draftJson', e);
}
}
}, [draftJson, categories]);
// === 编辑模式:预填表单 ===
const editingTx = useMemo(
() => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined,
[editId, ledger],
);
const [oldRaw, setOldRaw] = useState('');
const [editFlag, setEditFlag] = useState<string>('*');
const [editLinks, setEditLinks] = useState<string[]>([]);
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
useEffect(() => {
if (editingTx) {
setOldRaw(editingTx.raw);
setDate(editingTx.date.slice(0, 10));
setNarration(editingTx.narration || '');
setPayee(editingTx.payee || '');
setCurrency(editingTx.postings[0]?.currency || 'CNY');
// 保留编辑前的 flag/links/metadata
setEditFlag(editingTx.flag || '*');
setEditLinks(editingTx.links || []);
setEditMetadata(editingTx.metadata);
// 从 postings 推断方向和金额
const expensePosting = editingTx.postings.find(p => p.account.startsWith('Expenses'));
const incomePosting = editingTx.postings.find(p => p.account.startsWith('Income'));
if (expensePosting) {
setDirection('expense');
setAmount(expensePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类:匹配 Expenses 账户对应的 category
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else if (incomePosting) {
setDirection('income');
setAmount(incomePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else {
setDirection('transfer');
setAmount(editingTx.postings[0]?.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings[0]?.account || '');
setTargetAccount(editingTx.postings[1]?.account || '');
}
// 预填 postings高级模式含 cost/price/metadata
setPostings(editingTx.postings.map(p => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
// 预填标签
if (editingTx.tags.length > 0) {
setSelectedTags(editingTx.tags.map(name => ({ id: name, name, color: '#2196F3' })));
}
}
}, [editingTx, categories]);
// 按方向过滤分类
const availableCategories = useMemo(
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
[categories, direction],
);
// 来源账户列表(从 ledger 提取 Assets 账户)
const assetAccounts = useMemo(() => {
if (!ledger) return ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:兴业银行储蓄卡-2586'];
const accts = Array.from(ledger.accounts.keys()).filter(a => a.startsWith('Assets'));
return accts.length > 0 ? accts : ['Assets:支付宝余额'];
}, [ledger]);
const toggleTag = (tag: Tag) => {
setSelectedTags(prev =>
prev.some(t => t.name === tag.name)
? prev.filter(t => t.name !== tag.name)
: [...prev, tag],
);
};
// === OCR 拍照识账 ===
const handleOcrScan = async () => {
try {
// 1. 选图(相册或拍照)
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
base64: true,
});
if (result.canceled || !result.assets?.[0]?.base64) return;
const base64 = result.assets[0].base64;
// 2. 检查 OCR 引擎是否就绪
const nativeModule = getNativeOcrModule();
if (nativeModule) {
const ready = await nativeModule.isReady();
if (!ready) {
Alert.alert(t('ocr.notReady'));
return;
}
}
setStatus(t('ocr.scanning'));
// 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI)
const ocrBridge = getNativeOcrBridge();
const processor = new OcrProcessor(ocrBridge);
const ocrResult = await processor.process(base64);
if (ocrResult.event) {
// 4. 预填表单
const event = ocrResult.event;
if (event.amount) {
const absAmount = event.amount.replace(/^-/, '');
setAmount(absAmount);
}
if (event.counterparty || event.memo) {
setNarration([event.counterparty, event.memo].filter(Boolean).join(' - '));
}
if (event.direction === 'income') {
setDirection('income');
} else if (event.direction === 'transfer') {
setDirection('transfer');
} else {
setDirection('expense');
}
// 5. 自动分类:根据商户名/备注匹配分类(与 BillPipeline 分类逻辑一致)
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
if (matchText) {
const type = event.direction === 'income' ? 'income' : 'expense';
const candidates = categories.filter(c => c.type === type);
const match = matchCategory(matchText, candidates);
if (match.category) {
setSelectedCategory(match.category);
}
}
setStatus(t('ocr.success'));
} else {
setStatus(t('ocr.failed'));
}
} catch (e) {
setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
}
};
// 从 SpeedDial OCR 入口进入时自动触发
useEffect(() => {
if (mode === 'ocr') {
handleOcrScan();
}
}, [mode]);
const submit = () => {
const tagNames = selectedTags.map(tg => tg.name);
if (isAdvanced) {
// 高级模式:校验多 postings
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) {
setStatus(t('transaction.atLeast2Postings'));
return;
}
// 实时借贷平衡验证
const currencySum: Record<string, number> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
const val = parseFloat(p.amount);
if (!isNaN(val)) {
currencySum[p.currency] = (currencySum[p.currency] || 0) + val;
}
}
}
const isBalanced = Object.values(currencySum).every(sum => Math.abs(sum) < 0.001);
if (!isBalanced) {
setStatus(t('transaction.unbalanced'));
return;
}
const draft = {
date,
flag: editId ? editFlag : undefined,
narration: narration.trim() || t('transaction.defaultAdvancedNarration'),
postings: validPostings,
tags: tagNames,
links: editId && editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined,
};
if (editId && oldRaw) {
// 编辑模式:序列化新内容并替换旧块
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
} else {
addTransaction(draft).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.appended'));
setNarration('');
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}
} else {
// 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存
const amt = amount.trim();
if (!amt) {
setStatus(t('transaction.amountRequired'));
return;
}
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
return;
}
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (editId && oldRaw) {
// 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction
let finalPostings: Posting[];
if (direction === 'expense') {
finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency },
{ account: categoryAccount, amount: amt, currency },
];
} else if (direction === 'income') {
finalPostings = [
{ account: sourceAccount, amount: amt, currency },
{ account: categoryAccount, amount: `-${amt}`, currency },
];
} else {
finalPostings = [
{ account: sourceAccount, amount: `-${amt}`, currency },
{ account: targetAccount, amount: amt, currency },
];
}
const draft = {
date,
payee: payee.trim() || undefined,
flag: editFlag,
narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer')
),
postings: finalPostings,
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
};
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
} else {
// 新建交易:直接调用统一的共享逻辑函数
buildAndSaveTransaction({
date,
amount: amt,
payee: payee.trim() || undefined,
narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer')
),
categoryAccount: direction === 'transfer' ? targetAccount : categoryAccount,
sourceAccount,
direction,
currency,
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
}).then(() => {
setIsSubmitted(true);
setStatus(t('transaction.appended'));
setAmount('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => setStatus(String(e)));
}
}
};
const directions: { key: Direction; label: string; color: string }[] = [
{ key: 'expense', label: t('transaction.directionExpense'), color: theme.colors.financial.expense },
{ key: 'income', label: t('transaction.directionIncome'), color: theme.colors.financial.income },
{ key: 'transfer', label: t('transaction.directionTransfer'), color: theme.colors.financial.transfer },
];
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 高级模式模式切换开关 */}
<View style={commonStyles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.body.fontFamily }]}>
{t('transaction.advancedMode')}
</Text>
<Switch
value={isAdvanced}
onValueChange={(val) => {
setIsAdvanced(val);
// 同步初始化当前分录
if (val) {
const amt = amount.trim();
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (direction === 'expense') {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: categoryAccount, amount: amt || '', currency: 'CNY' },
]);
} else if (direction === 'income') {
setPostings([
{ account: sourceAccount, amount: amt || '', currency: 'CNY' },
{ account: categoryAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
]);
} else {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: targetAccount, amount: amt || '', currency: 'CNY' },
]);
}
}
}}
trackColor={{ false: theme.colors.bgPrimary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* 简单模式下的方向选择 */}
{!isAdvanced && (
<View style={styles.directionRow}>
{directions.map(d => (
<Pressable
key={d.key}
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
style={[
commonStyles.chip,
direction === d.key && commonStyles.chipActive,
{ flex: 1, alignItems: 'center' }
]}
>
<Text style={[
commonStyles.chipText,
direction === d.key && commonStyles.chipTextActive,
{ fontFamily: theme.typography.caption.fontFamily }
]}>
{d.label}
</Text>
</Pressable>
))}
<Pressable
onPress={handleOcrScan}
style={[
commonStyles.chip,
{ alignItems: 'center', minWidth: 60, borderColor: theme.colors.accent }
]}
>
<Text style={[commonStyles.chipText, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.ocrScan')}
</Text>
</Pressable>
</View>
)}
{/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
{!isAdvanced ? (
<Card title={t('transaction.newTitle')}>
{/* 金额 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.amountPlaceholder')}
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={amount}
onChangeText={setAmount}
autoFocus={editId ? false : true}
keyboardType="decimal-pad"
placeholder={t('transaction.amountPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
/>
<TextInput
value={currency}
onChangeText={setCurrency}
placeholder="CNY"
maxLength={3}
autoCapitalize="characters"
style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
/>
</View>
{/* 来源账户 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formSourceAccount')}
</Text>
<TextInput
value={sourceAccount}
onChangeText={setSourceAccount}
placeholder="Assets:支付宝余额"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 快捷来源账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = sourceAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`src-${acct}`}
onPress={() => setSourceAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
{/* 目标账户 (仅在转账时显示) */}
{direction === 'transfer' && (
<View style={{ marginTop: 10 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.targetAccount')}
</Text>
<TextInput
value={targetAccount}
onChangeText={setTargetAccount}
placeholder="Assets:WeChat"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 快捷目标账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = targetAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`tgt-${acct}`}
onPress={() => setTargetAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
</View>
)}
</Card>
) : (
<Card title={t('transaction.newTitle')}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.postingsList')}
</Text>
<PostingEditor
postings={postings}
onChange={(p) => setPostings(p)}
/>
<View style={styles.postingBtnRow}>
<View style={{ flex: 1 }}>
<Button
label={t('transaction.addPosting')}
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
variant="secondary"
/>
</View>
<View style={{ flex: 1 }}>
<Button
label={t('transaction.deleteLastPosting')}
onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))}
variant="secondary"
/>
</View>
</View>
</Card>
)}
{/* 简单模式下的分类选择 */}
{!isAdvanced && direction !== 'transfer' && (
<Card title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<CategoryPicker
categories={availableCategories}
selectedId={selectedCategory?.id}
onSelect={(cat) => setSelectedCategory(cat)}
/>
{selectedCategory && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{selectedCategory.linkedAccount}
</Text>
</View>
)}
</Card>
)}
{/* 详情与高级字段收纳区 */}
<Pressable
onPress={() => {
LayoutAnimation.easeInEaseOut();
setShowDetails(!showDetails);
}}
style={({ pressed }) => [
styles.detailsToggle,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.bgSecondary,
opacity: pressed ? 0.8 : 1,
flexDirection: 'row',
gap: 6
}
]}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontFamily: theme.typography.bodySmall.fontFamily }]}>
{showDetails ? t('transaction.hideDetails') : t('transaction.addDetails')}
</Text>
<Ionicons
name={showDetails ? 'chevron-up-outline' : 'chevron-down-outline'}
size={16}
color={theme.colors.accent}
/>
</Pressable>
{showDetails && (
<View style={{ gap: theme.spacing.md }}>
<Card title={t('transaction.extraDetails')}>
{/* 日期 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formDate')}
</Text>
<TextInput
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 摘要 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formNarration')}
</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 收款方(简单模式才显示) */}
{!isAdvanced && (
<>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formPayee')}
</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
</>
)}
</Card>
{/* 标签选择 */}
<Card title={t('transaction.formTags')}>
<TagPicker
tags={tags}
selectedNames={selectedTags.map(tg => tg.name)}
onToggle={toggleTag}
/>
{selectedTags.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
</Text>
)}
</Card>
</View>
)}
<Button label={t('transaction.submit')} onPress={submit} />
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8, fontFamily: theme.typography.bodySmall.fontFamily }]}>{status}</Text> : null}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, paddingBottom: 40 },
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
});

View File

@ -0,0 +1,60 @@
import React, { useMemo } from 'react';
import { useT } from '../i18n';
import { FormModal, type FormField } from './FormModal';
export interface AccountCreateModalProps {
visible: boolean;
defaultType?: string;
onConfirm: (values: Record<string, string>) => void;
onCancel: () => void;
}
/**
*
* 3 Dropdown (1.2) + TextInput (2.0) + TextInput (0.9)
*/
export function AccountCreateModal({ visible, defaultType = 'Assets', onConfirm, onCancel }: AccountCreateModalProps) {
const t = useT();
const fields: FormField[] = useMemo(() => [
// Row 1: 账户类型 Dropdown (flex: 1) + 币种 TextInput (flex: 1)
{
key: 'type',
label: t('account.fieldType'),
type: 'dropdown',
options: [
{ label: t('account.rootTypes.Assets'), value: 'Assets' },
{ label: t('account.rootTypes.Liabilities'), value: 'Liabilities' },
{ label: t('account.rootTypes.Expenses'), value: 'Expenses' },
{ label: t('account.rootTypes.Income'), value: 'Income' },
{ label: t('account.rootTypes.Equity'), value: 'Equity' },
],
defaultValue: defaultType,
flex: 1,
},
{
key: 'currency',
label: t('account.fieldCurrency'),
placeholder: 'CNY',
defaultValue: 'CNY',
flex: 1,
},
// Row 2: 账户名称 TextInput (全宽大输入框)
{
key: 'name',
label: t('account.fieldName'),
placeholder: t('account.fieldNamePlaceholder'),
defaultValue: '',
},
], [t, defaultType]);
return (
<FormModal
visible={visible}
title={t('account.addModalTitle')}
fields={fields}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { calculateAccountTotalBalance, type AccountNode } from '../domain/accountTree';
interface AccountTreeProps {
@ -11,7 +12,6 @@ interface AccountTreeProps {
/** 账户树展示(主题化,缩进显示层级 + 余额)。 */
export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
const { theme } = useTheme();
return (
<View style={{ gap: 4 }}>
{nodes.map(node => (
@ -21,15 +21,25 @@ export function AccountTree({ nodes, maxDepth = Infinity }: AccountTreeProps) {
);
}
function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const AccountTreeNode = React.memo(function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth: number; maxDepth: number }) {
const { theme } = useTheme();
const total = calculateAccountTotalBalance(node);
const t = useT();
const total = useMemo(() => calculateAccountTotalBalance(node), [node]);
const isLeaf = node.children.length === 0;
let displayName = node.name;
if (depth === 0) {
const localized = t(`account.rootTypes.${node.name as 'Assets' | 'Liabilities' | 'Income' | 'Expenses' | 'Equity'}`);
if (localized && !localized.startsWith('account.')) {
displayName = localized;
}
}
return (
<View>
<View style={[styles.row, { paddingLeft: depth * 16 }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: depth === 0 ? '700' : '400' }]}>
{isLeaf ? '• ' : ''}{node.name}
{isLeaf ? '• ' : ''}{displayName}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{total}
@ -40,7 +50,7 @@ function AccountTreeNode({ node, depth, maxDepth }: { node: AccountNode; depth:
))}
</View>
);
}
});
const styles = StyleSheet.create({
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 4 },

View File

@ -0,0 +1,89 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useTheme } from '../theme';
import { useSettingsStore } from '../store/settingsStore';
import { useNumpadUiStore } from '../store/numpadUiStore';
import type { IoniconName } from './categoryIcons';
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
const TAB_ICONS: Record<string, IoniconName> = {
index: 'home-outline',
transactions: 'list-outline',
report: 'pie-chart-outline',
settings: 'settings-outline',
};
/**
* 4 Tab +
* P2 /transaction/newP3 NumpadSheet
*/
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const openNumpad = useNumpadUiStore(s => s.open);
const renderTab = (route: (typeof state.routes)[number]) => {
const routeIndex = state.routes.indexOf(route);
const focused = state.index === routeIndex;
const options = descriptors[route.key].options;
const label = (options.title ?? route.name) as string;
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
};
return (
<Pressable
key={route.key}
onPress={onPress}
style={styles.tab}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={label}
>
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
</Pressable>
);
};
return (
<View
style={[styles.bar, {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
paddingBottom: Math.max(insets.bottom, 8),
}]}
>
{state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}>
<Pressable
onPress={() => openNumpad()}
style={[styles.plus, {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.bgPrimary,
}, theme.shadows.lg]}
accessibilityRole="button"
accessibilityLabel="记一笔"
>
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
</Pressable>
</View>
{state.routes.slice(2).map(renderTab)}
</View>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
tabLabel: { fontSize: 10, fontWeight: '600' },
plusSlot: { width: 64, alignItems: 'center' },
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
});

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
/** 内容可滚动maxHeight 480适合长表单。 */
scrollable?: boolean;
children: React.ReactNode;
}
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
export function BottomSheet({ visible, onClose, title, scrollable, children }: BottomSheetProps) {
const { theme } = useTheme();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
{title ? (
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
) : null}
{scrollable ? (
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="handled">
{children}
</ScrollView>
) : (
children
)}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
title: { marginTop: 8, marginBottom: 12 },
scroll: { maxHeight: 480 },
});

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useCallback, useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text } from 'react-native';
import { useTheme } from '../theme';
@ -14,23 +14,23 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const handlePressIn = () => {
const handlePressIn = useCallback(() => {
Animated.spring(scale, {
toValue: 0.96,
useNativeDriver: true,
tension: 180,
friction: 7,
}).start();
};
}, [scale]);
const handlePressOut = () => {
const handlePressOut = useCallback(() => {
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
tension: 180,
friction: 7,
}).start();
};
}, [scale]);
const bg = variant === 'primary' ? theme.colors.accent
: variant === 'danger' ? theme.colors.error
@ -53,7 +53,7 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
styles.button,
{
backgroundColor: bg,
borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角
borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
opacity: disabled ? 0.5 : 1,
borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0,
borderColor: theme.colors.border,
@ -61,7 +61,7 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
},
]}
>
<Text style={[styles.text, { color: fg, fontFamily: theme.typography.body.fontFamily }]}>{label}</Text>
<Text style={[styles.text, { color: fg }]}>{label}</Text>
</Animated.View>
</Pressable>
);

View File

@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { getMonthGrid, type DayCell } from '../domain/calendarGrid';
import { dailyExpenseMap } from '../domain/chartStats';
import type { Transaction } from '../domain/types';
@ -15,27 +16,28 @@ interface CalendarViewProps {
onDayPress?: (date: string) => void;
}
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export function CalendarView({ transactions, year, month, onDayPress }: CalendarViewProps) {
const { theme } = useTheme();
const t = useT();
// 星期头周日起(与 getMonthGrid 网格一致),文案走 i18n
const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const maxDaily = useMemo(() => Math.max(...dailyMap.values(), 1), [dailyMap]);
const handlePress = (cell: DayCell) => {
const handlePress = useCallback((cell: DayCell) => {
if (!cell.isCurrentMonth) return;
setSelectedDate(cell.date);
onDayPress?.(cell.date);
};
}, [onDayPress]);
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.sm }]}>
{/* 星期表头 */}
<View style={styles.weekdayRow}>
{WEEKDAYS.map(d => (
{weekdays.map(d => (
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1 }]}>
{d}
</Text>

View File

@ -1,50 +1,88 @@
import React, { useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
interface CardProps {
title?: string;
children: React.ReactNode;
onPress?: () => void;
onLongPress?: () => void;
style?: ViewStyle;
/** 是否开启折叠/展开功能。 */
collapsible?: boolean;
/** 默认是否呈收起状态(仅当 collapsible 为 true 时生效)。 */
defaultCollapsed?: boolean;
}
/** 卡片容器(主题化,并有触控微交互)。 */
export function Card({ title, children, onPress, style }: CardProps) {
/** 卡片容器(主题化,并有触控微交互与可选折叠展开功能)。 */
export function Card({ title, children, onPress, onLongPress, style, collapsible, defaultCollapsed }: CardProps) {
const { theme } = useTheme();
const scale = useRef(new Animated.Value(1)).current;
const [isCollapsed, setIsCollapsed] = useState(collapsible ? (defaultCollapsed ?? false) : false);
const handlePressIn = () => {
const handlePressIn = useCallback(() => {
Animated.spring(scale, {
toValue: 0.98,
useNativeDriver: true,
tension: 150,
friction: 7,
}).start();
};
}, [scale]);
const handlePressOut = () => {
const handlePressOut = useCallback(() => {
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
tension: 150,
friction: 7,
}).start();
};
}, [scale]);
const cardStyle = {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg,
borderRadius: theme.radii.xl,
padding: theme.spacing.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
...theme.shadows.sm,
};
if (onPress) {
const isInteractive = Boolean(onPress || onLongPress);
if (collapsible) {
return (
<View style={[styles.card, cardStyle, style]}>
<Pressable
onPress={() => setIsCollapsed(prev => !prev)}
style={styles.headerRow}
hitSlop={4}
>
<Text
style={[
styles.title,
{ color: theme.colors.fgPrimary, flex: 1, marginBottom: 0 },
theme.typography.h3,
]}
>
{title}
</Text>
<Ionicons
name={isCollapsed ? 'chevron-down' : 'chevron-up'}
size={18}
color={theme.colors.fgSecondary}
/>
</Pressable>
{!isCollapsed && <View style={styles.collapsibleContent}>{children}</View>}
</View>
);
}
if (isInteractive) {
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={style}
@ -100,4 +138,6 @@ export function Card({ title, children, onPress, style }: CardProps) {
const styles = StyleSheet.create({
card: { gap: 8 },
title: { marginBottom: 4 },
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 2 },
collapsibleContent: { marginTop: 4, gap: 8 },
});

View File

@ -0,0 +1,32 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getCategoryColor } from '../theme/palette';
import { getCategoryIcon } from './categoryIcons';
interface CategoryIconProps {
categoryId: string;
/** 圆形底色直径,默认 36。 */
size?: number;
}
/** 分类图标Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji。 */
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
const color = getCategoryColor(categoryId);
return (
<View
style={[styles.circle, {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color + '1A',
}]}
>
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
</View>
);
}
const styles = StyleSheet.create({
circle: { alignItems: 'center', justifyContent: 'center' },
});

View File

@ -1,7 +1,10 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
import { getCategoryColor } from '../theme/palette';
import type { Category } from '../domain/categories';
import { CategoryIcon } from './CategoryIcon';
import { Touchable } from './Touchable';
interface CategoryPickerProps {
categories: Category[];
@ -9,44 +12,6 @@ interface CategoryPickerProps {
onSelect: (cat: Category) => void;
}
const CATEGORY_ICONS: Record<string, string> = {
food: '🍔',
transport: '🚗',
shopping: '🛍️',
housing_utility: '💧',
housing_rent: '🏠',
housing_communication: '📞',
entertainment: '🎮',
services: '⚙️',
personal_care: '💅',
clothing: '👕',
health: '🏥',
learning: '📚',
salary: '💰',
income_activity: '🧧',
income_investment: '📈',
fallback: '🏷️',
};
const CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B', // Amber
transport: '#3B82F6', // Blue
shopping: '#EC4899', // Pink
housing_utility: '#06B6D4',// Cyan
housing_rent: '#6366F1', // Indigo
housing_communication: '#8B5CF6', // Purple
entertainment: '#10B981', // Emerald
services: '#64748B', // Slate
personal_care: '#F43F5E', // Rose
clothing: '#14B8A6', // Teal
health: '#EF4444', // Red
learning: '#84CC16', // Lime
salary: '#22C55E', // Green
income_activity: '#EF4444',// Red/Orange
income_investment: '#F59E0B', // Amber
fallback: '#64748B',
};
/** 分类选择器(网格卡片化布局)。 */
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
const { theme } = useTheme();
@ -55,29 +20,26 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
<View style={styles.grid}>
{categories.map(cat => {
const active = cat.id === selectedId;
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
const color = getCategoryColor(cat.id);
return (
<Pressable
<Touchable
key={cat.id}
onPress={() => onSelect(cat)}
style={({ pressed }) => [
style={[
styles.item,
{
backgroundColor: active ? color + '15' : theme.colors.bgTertiary,
borderColor: active ? color : theme.colors.border,
opacity: pressed ? 0.75 : 1,
}
]}
>
<Text style={[styles.icon, { color }]}>{icon}</Text>
<CategoryIcon categoryId={cat.id} size={32} />
<Text
style={[
styles.label,
{
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
fontFamily: theme.typography.caption.fontFamily,
fontWeight: active ? '700' : '500',
}
]}
@ -85,7 +47,7 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
>
{cat.name}
</Text>
</Pressable>
</Touchable>
);
})}
</View>
@ -109,9 +71,6 @@ const styles = StyleSheet.create({
gap: 6,
padding: 4,
},
icon: {
fontSize: 22,
},
label: {
fontSize: 12,
},

View File

@ -0,0 +1,117 @@
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { BottomSheet } from './BottomSheet';
import { buildMonthGrid } from './dateGrid';
interface DatePickerFieldProps {
/** YYYY-MM-DD空串表示未选。 */
value: string;
onChange: (date: string) => void;
placeholder?: string;
}
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD。 */
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
const { theme } = useTheme();
const t = useT();
// 星期头周一起(与 buildMonthGrid 网格一致),文案走 i18n
const weekdays = useMemo(() => t('datepicker.weekdays').split(','), [t]);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
const openPicker = () => {
const y = Number(value.slice(0, 4));
const m = Number(value.slice(5, 7));
if (y) {
setViewYear(y);
setViewMonth(m || 1);
}
setOpen(true);
};
const shiftMonth = (delta: number) => {
const m = viewMonth - 1 + delta;
setViewYear(viewYear + Math.floor(m / 12));
setViewMonth(((m % 12) + 12) % 12 + 1);
};
const pick = (date: string) => {
onChange(date);
setOpen(false);
};
return (
<>
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button" accessibilityLabel={value || placeholder || '选择日期'}>
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
{value || placeholder || 'YYYY-MM-DD'}
</Text>
</Pressable>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
{/* 月份导航 */}
<View style={styles.navRow}>
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
{viewYear}-{String(viewMonth).padStart(2, '0')}
</Text>
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
</Pressable>
</View>
{/* 星期头 */}
<View style={styles.weekRow}>
{weekdays.map(w => (
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
))}
</View>
{/* 日期网格 */}
{grid.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const selected = cell.date === value;
return (
<Pressable
key={cell.date}
onPress={() => pick(cell.date)}
accessibilityRole="button"
accessibilityLabel={cell.date}
accessibilityState={{ selected }}
style={[styles.dayCell, {
backgroundColor: selected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.full,
}]}
>
<Text style={[theme.typography.bodySmall, {
color: selected
? theme.colors.fgInverse
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
opacity: cell.inMonth ? 1 : 0.5,
fontVariant: ['tabular-nums'],
}]}>
{Number(cell.date.slice(8, 10))}
</Text>
</Pressable>
);
})}
</View>
))}
</BottomSheet>
</>
);
}
const styles = StyleSheet.create({
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
weekRow: { flexDirection: 'row' },
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
});

View File

@ -7,12 +7,13 @@ import type { DedupResult } from '../domain/dedup';
interface DedupBannerProps {
result: DedupResult;
onAccept?: () => void; // 接受(仍要记账)
onReject?: () => void; // 拒绝(不记)
onAccept?: () => void; // 强行单独导入
onReject?: () => void; // 忽略跳过
onLink?: () => void; // 关联/合并已有交易
}
/** 去重提示横幅(主题化)。展示去重原因 + 操作按钮。 */
export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
export function DedupBanner({ result, onAccept, onReject, onLink }: DedupBannerProps) {
const { theme } = useTheme();
const t = useT();
if (!result.isDuplicate) return null;
@ -43,6 +44,11 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
<Pressable onPress={onReject} style={[styles.btn, { borderColor: theme.colors.border }]}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('importFlow.skip')}</Text>
</Pressable>
{onLink && (
<Pressable onPress={onLink} style={[styles.btn, { borderColor: theme.colors.accent, backgroundColor: `${theme.colors.accent}15` }]}>
<Text style={{ color: theme.colors.accent, fontWeight: '700' }}></Text>
</Pressable>
)}
<Pressable onPress={onAccept} style={[styles.btn, { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent }]}>
<Text style={{ color: theme.colors.fgInverse }}>{t('importFlow.forceImport')}</Text>
</Pressable>
@ -54,6 +60,6 @@ export function DedupBanner({ result, onAccept, onReject }: DedupBannerProps) {
const styles = StyleSheet.create({
banner: { borderLeftWidth: 3, borderRadius: 6, padding: 12, gap: 6, marginVertical: 8 },
header: { flexDirection: 'row', alignItems: 'center', gap: 6 },
actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
btn: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 6, borderWidth: 1 },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
btn: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 6, borderWidth: 1 },
});

View File

@ -0,0 +1,95 @@
/**
* spec §6 / /
*
*/
import React from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { BottomSheet } from './BottomSheet';
import { DatePickerField } from './DatePickerField';
import { Button } from './Button';
export interface AdvancedFilterValues {
account: string;
dateFrom: string;
dateTo: string;
amountMin: string;
amountMax: string;
}
interface FilterSheetProps {
visible: boolean;
onClose: () => void;
values: AdvancedFilterValues;
onChange: (values: AdvancedFilterValues) => void;
onReset: () => void;
}
export function FilterSheet({ visible, onClose, values, onChange, onReset }: FilterSheetProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const set = (patch: Partial<AdvancedFilterValues>) => onChange({ ...values, ...patch });
return (
<BottomSheet visible={visible} onClose={onClose} title={t('filter.title')} scrollable>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.accountFilterPlaceholder')}
</Text>
<TextInput
value={values.account}
onChangeText={v => set({ account: v })}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.dateFrom')} ~ {t('transactions.dateTo')}
</Text>
<View style={styles.row}>
<View style={styles.flex1}><DatePickerField value={values.dateFrom} onChange={v => set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /></View>
<View style={styles.flex1}><DatePickerField value={values.dateTo} onChange={v => set({ dateTo: v })} placeholder={t('transactions.dateTo')} /></View>
</View>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.amountMin')} ~ {t('transactions.amountMax')}
</Text>
<View style={styles.row}>
<TextInput
value={values.amountMin}
onChangeText={v => set({ amountMin: v })}
placeholder={t('transactions.amountMin')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
<TextInput
value={values.amountMax}
onChangeText={v => set({ amountMax: v })}
placeholder={t('transactions.amountMax')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
</View>
<View style={[styles.row, styles.actions]}>
<View style={styles.flex1}>
<Button label={t('filter.reset')} variant="secondary" onPress={onReset} />
</View>
<View style={styles.flex1}>
<Button label={t('filter.apply')} onPress={onClose} />
</View>
</View>
</BottomSheet>
);
}
const styles = StyleSheet.create({
label: { marginTop: 10, marginBottom: 4 },
row: { flexDirection: 'row', gap: 8 },
flex1: { flex: 1 },
actions: { marginTop: 16 },
});

View File

@ -18,11 +18,19 @@
* />
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { Button } from './Button';
import { Touchable } from './Touchable';
export interface FormOption {
label: string; // 中文名称
value: string; // 英文前缀 (如 Assets)
subLabel?: string;
}
export interface FormField {
/** 字段 key,对应 values 对象的属性名。 */
@ -37,6 +45,16 @@ export interface FormField {
keyboardType?: 'default' | 'numeric' | 'decimal-pad' | 'phone-pad';
/** 多行输入。 */
multiline?: boolean;
/** 密码输入(遮蔽文本)。 */
secureTextEntry?: boolean;
/** 控件类型:'text' (默认) | 'select' 单选卡片组 | 'dropdown' 下拉选择框 */
type?: 'text' | 'select' | 'dropdown';
/** select / dropdown 类型的可选项。 */
options?: FormOption[];
/** 在同一行内布局时的占比弹性系数。 */
flex?: number;
/** 行号标识,相同 row 的字段组合在同一行中。 */
row?: number;
}
interface FormModalProps {
@ -47,13 +65,30 @@ interface FormModalProps {
onCancel: () => void;
/** 确认按钮文本(默认「确定」)。 */
confirmLabel?: string;
/** 字段值变更时的联动回调,返回新值覆盖。 */
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode;
}
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) {
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, onValuesChange, children }: FormModalProps) {
const { theme } = useTheme();
const t = useT();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [values, setValues] = useState<Record<string, string>>({});
const [activeDropdownKey, setActiveDropdownKey] = useState<string | null>(null);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const updateFieldValue = (key: string, val: string) => {
setValues(prev => {
const next = { ...prev, [key]: val };
if (onValuesChange) {
const overridden = onValuesChange(key, val, next);
return overridden ? { ...next, ...overridden } : next;
}
return next;
});
};
// 每次 visible 变为 true 时,用 fields 的 defaultValue 初始化
useEffect(() => {
@ -63,47 +98,209 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
init[f.key] = f.defaultValue ?? '';
}
setValues(init);
setActiveDropdownKey(null);
setFocusedKey(null);
}
}, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
// 将包含 flex 的连续字段组合成同行渲染组
const renderField = (f: FormField) => {
const isFocused = focusedKey === f.key || activeDropdownKey === f.key;
if (f.type === 'dropdown' && f.options) {
const selectedOpt = f.options.find(o => o.value === (values[f.key] ?? ''));
const rawValue = selectedOpt ? selectedOpt.value : (values[f.key] ?? '');
const rawLabel = selectedOpt ? selectedOpt.label : '';
const hasColon = rawValue.includes(':');
const triggerPrefix = hasColon ? rawValue.slice(0, rawValue.lastIndexOf(':')) : '';
const triggerTitle = hasColon ? rawValue.slice(rawValue.lastIndexOf(':') + 1) : (rawLabel || f.placeholder || '');
const isPlaceholder = !rawValue;
return (
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
{f.label}
</Text>
<Touchable
style={[
commonStyles.input,
styles.dropdownTrigger,
{
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
},
]}
onPress={() => setActiveDropdownKey(f.key)}
>
<View style={styles.dropdownTriggerContent}>
{triggerPrefix ? (
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary, fontSize: 10, lineHeight: 12 }]} numberOfLines={1}>
{triggerPrefix}
</Text>
) : null}
<Text
style={[
styles.dropdownLabelText,
{
color: isPlaceholder ? theme.colors.fgSecondary : theme.colors.fgPrimary,
fontSize: 13,
fontWeight: isPlaceholder ? '400' : '600',
lineHeight: 16,
},
]}
numberOfLines={1}
>
{triggerTitle}
</Text>
</View>
<Ionicons name="chevron-down" size={16} color={isFocused ? theme.colors.accent : theme.colors.fgSecondary} />
</Touchable>
</View>
);
}
if (f.type === 'select' && f.options) {
return (
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
{f.label}
</Text>
<View style={styles.optionContainer}>
{f.options.map(opt => {
const isSelected = (values[f.key] ?? '') === opt.value;
return (
<Touchable
key={opt.value}
style={[
styles.optionChip,
{
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
onPress={() => updateFieldValue(f.key, opt.value)}
>
<View style={styles.optionTextWrap}>
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary }]}>
{opt.value}
</Text>
<Text
style={[
styles.dropdownLabelText,
{
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
fontWeight: isSelected ? '700' : '600',
},
]}
>
{opt.label}
</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} style={{ marginLeft: 'auto' }} />
)}
</Touchable>
);
})}
</View>
</View>
);
}
return (
<View key={f.key} style={[styles.field, f.flex ? { flex: f.flex } : undefined]}>
<Text style={[theme.typography.caption, styles.fieldLabel, { color: theme.colors.fgSecondary }]}>
{f.label}
</Text>
<TextInput
value={values[f.key] ?? ''}
onChangeText={(text) => updateFieldValue(f.key, text)}
onFocus={() => setFocusedKey(f.key)}
onBlur={() => setFocusedKey(null)}
placeholder={f.placeholder}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
secureTextEntry={f.secureTextEntry}
style={[
commonStyles.input,
f.flex ? { height: 44 } : undefined,
{
backgroundColor: isFocused ? theme.colors.bgPrimary : theme.colors.bgTertiary,
borderColor: isFocused ? theme.colors.accent : theme.colors.border,
borderWidth: isFocused ? 1.5 : StyleSheet.hairlineWidth,
},
]}
/>
</View>
);
};
// 分组具有 flex 且相同 row 的连续字段
const fieldElements: React.ReactNode[] = [];
let flexGroup: FormField[] = [];
let currentGroupRow: number | undefined = undefined;
const flushFlexGroup = () => {
if (flexGroup.length > 0) {
fieldElements.push(
<View key={`row-${flexGroup[0].key}`} style={styles.rowFieldGroup}>
{flexGroup.map(renderField)}
</View>
);
flexGroup = [];
}
};
for (const f of fields) {
if (f.flex) {
const fieldRow = f.row ?? 1;
if (flexGroup.length > 0 && currentGroupRow !== fieldRow) {
flushFlexGroup();
}
currentGroupRow = fieldRow;
flexGroup.push(f);
} else {
flushFlexGroup();
currentGroupRow = undefined;
fieldElements.push(renderField(f));
}
}
flushFlexGroup();
const activeField = fields.find(f => f.key === activeDropdownKey);
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onCancel}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
}]}
style={[
styles.sheet,
theme.shadows.lg,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
},
]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
<Pressable onPress={onCancel} hitSlop={8}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}></Text>
</Pressable>
{/* 顶部 Drag Handle */}
<View style={[styles.dragHandle, { backgroundColor: theme.colors.divider }]} />
<View style={styles.header}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{title}</Text>
<Touchable onPress={onCancel} hitSlop={8} style={[styles.closeCircleBtn, { backgroundColor: theme.colors.bgTertiary }]}>
<Ionicons name="close" size={16} color={theme.colors.fgSecondary} />
</Touchable>
</View>
<ScrollView style={styles.body}>
{fields.map(f => (
<View key={f.key} style={styles.field}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4 }]}>
{f.label}
</Text>
<TextInput
value={values[f.key] ?? ''}
onChangeText={(text) => setValues(prev => ({ ...prev, [f.key]: text }))}
placeholder={f.placeholder}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType={f.keyboardType ?? 'default'}
multiline={f.multiline}
style={commonStyles.input}
/>
</View>
))}
<ScrollView style={styles.body} showsVerticalScrollIndicator={false}>
{fieldElements}
</ScrollView>
{children}
<View style={styles.actions}>
<Button label={t('common.cancel')} onPress={onCancel} variant="secondary" />
<View style={{ flex: 1 }} />
@ -111,6 +308,64 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
</View>
</Pressable>
</Pressable>
{/* 下拉菜单弹出 Modal */}
<Modal visible={activeDropdownKey !== null} transparent animationType="fade" onRequestClose={() => setActiveDropdownKey(null)}>
<Pressable style={[styles.menuOverlay, { backgroundColor: theme.colors.overlay }]} onPress={() => setActiveDropdownKey(null)}>
<Pressable style={[styles.dropdownMenu, theme.shadows.lg, { backgroundColor: theme.colors.bgSecondary, borderColor: theme.colors.border }]} onPress={e => e.stopPropagation()}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, letterSpacing: 0.5, fontWeight: '600' }]}>
{activeField?.label}
</Text>
{activeField?.options?.map(opt => {
const isSelected = (values[activeField.key] ?? '') === opt.value;
const hasColon = opt.value.includes(':');
const pathPrefix = hasColon ? opt.value.slice(0, opt.value.lastIndexOf(':')) : '';
const shortName = hasColon ? opt.value.slice(opt.value.lastIndexOf(':') + 1) : opt.label;
return (
<Touchable
key={opt.value}
style={[
styles.dropdownMenuItem,
{
backgroundColor: isSelected ? `${theme.colors.accent}15` : theme.colors.bgPrimary,
borderColor: isSelected ? theme.colors.accent : theme.colors.border,
},
]}
onPress={() => {
updateFieldValue(activeField.key, opt.value);
setActiveDropdownKey(null);
}}
>
<View style={{ flex: 1 }}>
{pathPrefix ? (
<Text style={[styles.dropdownValueText, { color: theme.colors.fgSecondary, fontSize: 10, lineHeight: 12 }]}>
{pathPrefix}
</Text>
) : null}
<Text
style={[
styles.dropdownLabelText,
{
color: isSelected ? theme.colors.accent : theme.colors.fgPrimary,
fontWeight: isSelected ? '700' : '600',
fontSize: 14,
lineHeight: 18,
},
]}
>
{shortName}
</Text>
</View>
{isSelected && (
<Ionicons name="checkmark-circle" size={18} color={theme.colors.accent} />
)}
</Touchable>
);
})}
</Pressable>
</Pressable>
</Modal>
</Modal>
);
}
@ -123,26 +378,113 @@ const styles = StyleSheet.create({
sheet: {
maxHeight: '80%',
padding: 20,
paddingTop: 12,
paddingBottom: 36,
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
},
dragHandle: {
width: 36,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
opacity: 0.8,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: 12,
borderBottomWidth: 1,
marginBottom: 12,
marginBottom: 16,
},
closeCircleBtn: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
body: {
maxHeight: 400,
},
field: {
marginBottom: 12,
marginBottom: 14,
},
fieldLabel: {
fontSize: 12,
fontWeight: '600',
letterSpacing: 0.3,
marginBottom: 6,
},
rowFieldGroup: {
flexDirection: 'row',
gap: 12,
},
dropdownTrigger: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 2,
paddingHorizontal: 12,
height: 44,
borderRadius: 12,
},
dropdownTriggerContent: {
justifyContent: 'center',
},
dropdownValueText: {
fontSize: 10,
lineHeight: 12,
},
dropdownLabelText: {
fontSize: 13,
lineHeight: 16,
fontWeight: '600',
},
optionContainer: {
gap: 8,
marginTop: 4,
},
optionChip: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
flexDirection: 'row',
alignItems: 'center',
},
optionTextWrap: {
flexDirection: 'column',
},
optionSubLabel: {
fontSize: 11,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 8,
gap: 10,
marginTop: 12,
},
menuOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
dropdownMenu: {
width: '100%',
maxWidth: 320,
borderRadius: 20,
padding: 18,
borderWidth: StyleSheet.hairlineWidth,
gap: 10,
},
dropdownMenuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
});

View File

@ -9,6 +9,7 @@ import { authenticate, RealAuthProvider, hashPin, verifyPin } from '../services/
/** SecureStore 中存储 PIN 哈希的 key。 */
const PIN_HASH_KEY = 'app_pin_hash';
const PIN_SALT_KEY = 'app_pin_salt';
interface LockScreenProps {
onUnlock: () => void;
@ -44,7 +45,7 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
});
// 尝试生物识别
handleAuth();
}, []);
}, [handleAuth]);
const handleKeyPress = async (num: string) => {
setError(null);
@ -55,7 +56,8 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
// 验证 PIN
const storedHash = await SecureStore.getItemAsync(PIN_HASH_KEY);
if (storedHash) {
const valid = await verifyPin(nextPin, storedHash);
const storedSalt = await SecureStore.getItemAsync(PIN_SALT_KEY);
const valid = await verifyPin(nextPin, storedHash, storedSalt || undefined);
if (valid) {
onUnlock();
} else {
@ -199,7 +201,7 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
</Text>
</Pressable>
{renderKey('0')}
<Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel="Backspace">
<Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel={t('lockScreen.backspace')}>
<Ionicons name="backspace-outline" size={24} color={theme.colors.fgSecondary} />
</Pressable>
</View>
@ -228,12 +230,14 @@ export function LockScreen({ onUnlock }: LockScreenProps) {
/** 导出 PIN 设置/验证辅助函数供 settings 页面使用。 */
export async function setPin(pin: string): Promise<void> {
const hash = await hashPin(pin);
const { hash, salt } = await hashPin(pin);
await SecureStore.setItemAsync(PIN_HASH_KEY, hash);
await SecureStore.setItemAsync(PIN_SALT_KEY, salt);
}
export async function clearPin(): Promise<void> {
await SecureStore.deleteItemAsync(PIN_HASH_KEY);
await SecureStore.deleteItemAsync(PIN_SALT_KEY);
}
export async function hasPinSet(): Promise<boolean> {

View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal';
import { Touchable } from './Touchable';
/**
* P2 + + + + FormModal
* header/Alert/Modal
*/
interface ManagementScreenProps<T> {
title: string;
items: T[];
keyExtractor: (item: T) => string;
/** 渲染单个条目handlers.openEdit 打开编辑弹窗handlers.confirmDelete 弹删除确认。 */
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
addLabel: string;
emptyText?: string;
formTitle: (editing: T | null) => string;
formFields: (editing: T | null) => FormField[];
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
onDelete: (item: T) => void;
deleteConfirmText: (item: T) => string;
/** 删除确认弹窗标题(默认通用「删除」)。 */
deleteConfirmTitle?: string;
/** 字段值变更时的联动回调,返回新值覆盖。 */
onValuesChange?: (changedKey: string, newValue: string, prevValues: Record<string, string>) => Record<string, string> | void;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
onOpenForm?: (editing: T | null) => void;
/** 列表上方的额外内容(分组 Tab 等)。 */
headerContent?: React.ReactNode;
footer?: React.ReactNode;
/** 条目容器的额外样式(如标签页的 flexDirection row + flexWrap。 */
listStyle?: ViewStyle;
}
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
const { theme } = useTheme();
const t = useT();
const [editing, setEditing] = useState<T | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openForm = (item: T | null) => {
setEditing(item);
props.onOpenForm?.(item);
setModalOpen(true);
};
const confirmDelete = (item: T) => {
Alert.alert(props.deleteConfirmTitle ?? t('common.delete'), props.deleteConfirmText(item), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
]);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
};
const handleSubmit = (values: Record<string, string>) => {
if (props.onSubmit(values, editing)) closeModal();
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader
title={props.title}
right={
<Touchable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} />
</Touchable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{props.headerContent}
{props.items.length === 0 && props.emptyText ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
) : null}
<View style={props.listStyle}>
{props.items.map(item => (
<View key={props.keyExtractor(item)}>
{props.renderItem(item, {
openEdit: () => openForm(item),
confirmDelete: () => confirmDelete(item),
})}
</View>
))}
</View>
{props.footer}
</ScrollView>
<FormModal
visible={modalOpen}
title={props.formTitle(editing)}
fields={props.formFields(editing)}
onValuesChange={props.onValuesChange}
onConfirm={handleSubmit}
onCancel={closeModal}
>
{props.formExtra?.(editing)}
</FormModal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, gap: 12 },
});

View File

@ -0,0 +1,138 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { Touchable } from './Touchable';
/** 键值:数字/小数点/加减走 numpadExpressiontoday/done 由宿主处理。 */
export type KeyboardKey = string;
interface NumpadKeyboardProps {
onKey: (key: KeyboardKey) => void;
todayLabel: string;
doneLabel: string;
/** 退格键无障碍标签i18n。 */
backspaceLabel: string;
}
const ROWS: KeyboardKey[][] = [
['1', '2', '3', 'today'],
['4', '5', '6', '+'],
['7', '8', '9', '-'],
['.', '0', 'backspace', 'done'],
];
/** 内嵌数字键盘4×4金额优先/ 连续计算,「今天」快速设日期。带有按压微缩放与变色触控反馈。 */
export function NumpadKeyboard({ onKey, todayLabel, doneLabel, backspaceLabel }: NumpadKeyboardProps) {
const { theme } = useTheme();
const renderKey = (key: KeyboardKey) => {
if (key === 'done') {
return (
<Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
styles.doneKey,
{
backgroundColor: pressed ? theme.colors.accentDark : theme.colors.accent,
borderRadius: theme.radii.lg,
},
]}
accessibilityRole="button"
accessibilityLabel={doneLabel}
>
<Text style={[styles.doneText, { color: theme.colors.fgInverse }]}>{doneLabel}</Text>
</Touchable>
);
}
if (key === 'backspace') {
return (
<Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={backspaceLabel}
>
<Ionicons name="backspace-outline" size={22} color={theme.colors.fgPrimary} />
</Touchable>
);
}
if (key === 'today') {
return (
<Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={todayLabel}
>
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{todayLabel}</Text>
</Touchable>
);
}
if (key === '+' || key === '-') {
return (
<Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={key}
>
<Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{key === '+' ? '' : ''}</Text>
</Touchable>
);
}
return (
<Touchable
key={key}
onPress={() => onKey(key)}
disableRipple
style={({ pressed }) => [
styles.key,
pressed && { backgroundColor: theme.colors.bgTertiary },
]}
accessibilityRole="button"
accessibilityLabel={key}
>
<Text style={[styles.digitText, { color: theme.colors.fgPrimary }]}>{key}</Text>
</Touchable>
);
};
return (
<View style={styles.grid}>
{ROWS.map((row, i) => (
<View key={i} style={styles.row}>
{row.map(renderKey)}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
grid: { gap: 2 },
row: { flexDirection: 'row' },
key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14, borderRadius: 8 },
digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] },
opText: { fontSize: 16, fontWeight: '600' },
doneKey: { margin: 4 },
doneText: { fontSize: 15, fontWeight: '700' },
});

View File

@ -0,0 +1,844 @@
/**
* NumpadSheet P3
*
* chip chip
* ////PostingEditor
*
* - presentation="page"/transaction/new 宿 editId/draftJson
* - presentation="modal"NumpadSheetHost
* buildAndSaveTransaction()/editTransaction
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView,
StyleSheet, Switch, Text, TextInput, View,
} from 'react-native';
import { useNavigation, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useSettingsStore } from '../store/settingsStore';
import { TAG_COLORS } from '../theme/palette';
import { AccountCreateModal } from './AccountCreateModal';
import { BottomSheet } from './BottomSheet';
import { Button } from './Button';
import { CategoryIcon } from './CategoryIcon';
import { CategoryPicker } from './CategoryPicker';
import { DatePickerField } from './DatePickerField';
import { FormModal, type FormField } from './FormModal';
import { NumpadKeyboard } from './NumpadKeyboard';
import { PostingEditor } from './PostingEditor';
import { TagPicker } from './TagPicker';
import { pressKey, evaluateExpression, type NumpadKey } from './numpadExpression';
import { useOcrScan } from './useOcrScan';
import { buildAndSaveTransaction, buildPostings } from '../domain/transactionBuilder';
import { parsePostingsToLegs } from '../domain/postingLegs';
import { serializeTransaction } from '../domain/ledger';
import { addDecimals, compareDecimals, toDateString } from '../domain/decimal';
import { matchCategory } from '../domain/categories';
import type { Category } from '../domain/categories';
import type { Tag } from '../domain/tags';
import type { Posting } from '../domain/types';
import { logger } from '../utils/logger';
type Direction = 'expense' | 'income' | 'transfer';
interface NumpadSheetProps {
presentation?: 'modal' | 'page';
editId?: string;
draftJson?: string;
autoOcr?: boolean;
onClose?: () => void;
/** 表单脏状态变化上报modal 模式宿主用它做未保存守卫)。 */
onDirtyChange?: (dirty: boolean) => void;
}
export function NumpadSheet({ presentation = 'modal', editId, draftJson, autoOcr, onClose, onDirtyChange }: NumpadSheetProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const navigation = useNavigation();
const ledger = useLedgerStore(s => s.ledger);
const editTransaction = useLedgerStore(s => s.editTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount);
const lastUsedCategoryId = useSettingsStore(s => s.lastUsedCategoryId);
const setLastUsedEntry = useSettingsStore(s => s.setLastUsedEntry);
const [direction, setDirection] = useState<Direction>('expense');
const [expr, setExpr] = useState('');
const [currency, setCurrency] = useState('CNY');
const [date, setDate] = useState(() => toDateString(new Date()));
const [narration, setNarration] = useState('');
const [payee, setPayee] = useState('');
const [sourceAccount, setSourceAccount] = useState('');
const [targetAccount, setTargetAccount] = useState('');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [showMore, setShowMore] = useState(false);
const [isAdvanced, setIsAdvanced] = useState(false);
const [postings, setPostings] = useState<Posting[]>([]);
const [status, setStatus] = useState('');
const [submitting, setSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null);
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false);
const [isQuickAddingAccount, setIsQuickAddingAccount] = useState(false);
const handleQuickAddAccount = async (values: Record<string, string>) => {
const typeVal = values.type?.trim();
const nameVal = values.name?.trim();
if (!typeVal || !nameVal) {
Alert.alert(t('account.addFail'), t('account.addFailEmpty'));
return;
}
const rawAccount = `${typeVal}:${nameVal}`;
let sanitized = rawAccount.replace(//g, ':');
sanitized = sanitized
.split(':')
.map((seg, idx) => {
if (idx === 0) return seg;
let cleaned = seg.replace(/[\(\)\[\]]/g, '-');
cleaned = cleaned.replace(/-+/g, '-');
cleaned = cleaned.replace(/^-|-$/g, '');
cleaned = cleaned.replace(/[^\w\u4e00-\u9fa5-]/g, '');
return cleaned;
})
.join(':');
try {
await autoOpenAccounts([sanitized]);
setIsQuickAddingAccount(false);
if (accountPicker === 'source') {
setSourceAccount(sanitized);
} else if (accountPicker === 'target') {
setTargetAccount(sanitized);
}
setAccountPicker(null);
} catch (e) {
Alert.alert(t('account.openFail'), e instanceof Error ? e.message : String(e));
}
};
// 编辑模式保留字段
const [oldRaw, setOldRaw] = useState('');
const [editFlag, setEditFlag] = useState<string | undefined>(undefined);
const [editLinks, setEditLinks] = useState<string[] | undefined>(undefined);
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
const amount = useMemo(() => evaluateExpression(expr), [expr]);
const { scan, status: ocrStatus } = useOcrScan();
// ===== 账户列表双腿之一的资金账户Assets + Liabilities =====
const assetAccounts = useMemo(() => {
if (!ledger) return [] as string[];
return Array.from(ledger.accounts.keys()).filter(
a => a.startsWith('Assets') || a.startsWith('Liabilities'),
);
}, [ledger]);
// chip 行:上次使用优先,最多 4 个 + 「⋯」
const chipAccounts = useMemo(() => {
const rest = assetAccounts.filter(a => a !== lastUsedAccount);
const pinned = lastUsedAccount && assetAccounts.includes(lastUsedAccount) ? [lastUsedAccount] : [];
return [...pinned, ...rest].slice(0, 4);
}, [assetAccounts, lastUsedAccount]);
// 默认资金账户:上次使用 > 列表第一个
useEffect(() => {
if (sourceAccount) return;
const def = lastUsedAccount && assetAccounts.includes(lastUsedAccount)
? lastUsedAccount
: assetAccounts[0];
if (def) setSourceAccount(def);
}, [assetAccounts, lastUsedAccount, sourceAccount]);
// ===== 分类快捷行 =====
const availableCategories = useMemo(
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
[categories, direction],
);
const quickCategories = useMemo(() => {
const pinned = availableCategories.find(c => c.id === lastUsedCategoryId);
const rest = availableCategories.filter(c => c.id !== lastUsedCategoryId);
return [...(pinned ? [pinned] : []), ...rest].slice(0, 3);
}, [availableCategories, lastUsedCategoryId]);
const parsedOriginalDraftRef = useRef<{ payee?: string; categoryAccount?: string; sourceAccount?: string; amount?: string; direction?: string } | null>(null);
// ===== 预填draftJson草稿 =====
useEffect(() => {
if (!draftJson) return;
try {
const draft = JSON.parse(draftJson);
if (draft.date) setDate(String(draft.date).slice(0, 10));
setNarration(draft.narration || '');
setPayee(draft.payee || '');
const legs = parsePostingsToLegs(draft.postings ?? []);
if (legs) {
setDirection(legs.direction);
setExpr(legs.amount);
setCurrency(legs.currency);
setSourceAccount(legs.sourceAccount);
setTargetAccount(legs.targetAccount);
if (legs.direction !== 'transfer') {
const matched = categories.find(c => c.linkedAccount === legs.targetAccount);
if (matched) setSelectedCategory(matched);
}
}
parsedOriginalDraftRef.current = {
payee: (draft.payee || '').trim(),
categoryAccount: legs?.targetAccount || '',
sourceAccount: legs?.sourceAccount || '',
amount: legs?.amount || '',
direction: legs?.direction || 'expense',
};
if (Array.isArray(draft.postings) && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({
account: p.account, amount: p.amount, currency: p.currency,
cost: p.cost, price: p.price, metadata: p.metadata,
})));
// 带 cost/price/metadata 的分录在简单模式会丢失 → 进高级模式
const hasExtras = (draft.postings as Posting[]).some(
(p: Posting) => p.cost || p.price || (p.metadata && Object.keys(p.metadata).length > 0),
);
// 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿
if (!legs || hasExtras) setIsAdvanced(true);
}
// 标签预填(与 editId 预填同逻辑store 按 name 查找,找不到用兜底色)
if (Array.isArray(draft.tags) && draft.tags.length > 0) {
setSelectedTags(draft.tags.map((name: string) => {
const found = tags.find(tg => tg.name === name);
return found ?? { id: name, name, color: TAG_COLORS[3] };
}));
}
} catch (e) {
console.error('Failed to parse draftJson', e);
}
}, [draftJson, categories, tags]);
// ===== 预填editId编辑模式 =====
const editingTx = useMemo(
() => (editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined),
[editId, ledger],
);
useEffect(() => {
if (!editingTx) return;
setOldRaw(editingTx.raw);
setDate(editingTx.date.slice(0, 10));
setNarration(editingTx.narration || '');
setPayee(editingTx.payee || '');
setEditFlag(editingTx.flag || '*');
setEditLinks(editingTx.links || []);
setEditMetadata(editingTx.metadata);
const legs = parsePostingsToLegs(editingTx.postings);
if (legs) {
setDirection(legs.direction);
setExpr(legs.amount);
setCurrency(legs.currency);
setSourceAccount(legs.sourceAccount);
setTargetAccount(legs.targetAccount);
if (legs.direction !== 'transfer') {
const matched = categories.find(c => c.linkedAccount === legs.targetAccount);
if (matched) setSelectedCategory(matched);
}
}
setPostings(editingTx.postings.map(p => ({
account: p.account, amount: p.amount, currency: p.currency,
cost: p.cost, price: p.price, metadata: p.metadata,
})));
// 带 cost/price/metadata 的分录在简单模式会丢失 → 进高级模式
const hasExtras = editingTx.postings.some(
p => p.cost || p.price || (p.metadata && Object.keys(p.metadata).length > 0),
);
// 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿
if ((!legs || hasExtras) && editingTx.postings.length > 0) setIsAdvanced(true);
if (editingTx.tags.length > 0) {
setSelectedTags(editingTx.tags.map(name => {
const found = tags.find(tg => tg.name === name);
return found ?? { id: name, name, color: TAG_COLORS[3] };
}));
}
}, [editingTx, categories, tags]);
// ===== OCR =====
const applyOcrEvent = (event: import('./useOcrScan').OcrEvent) => {
// 金额/摘要仅在当前为空时填充,避免覆盖用户已输入内容
if (event.amount) setExpr(prev => prev || event.amount!.replace(/^-/, ''));
if (event.direction === 'income' || event.direction === 'transfer') setDirection(event.direction);
else setDirection('expense');
const text = [event.counterparty, event.memo].filter(Boolean).join(' - ');
if (text) setNarration(prev => prev || text);
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
if (matchText) {
const type = event.direction === 'income' ? 'income' : 'expense';
const match = matchCategory(matchText, categories.filter(c => c.type === type));
if (match.category) setSelectedCategory(match.category);
}
};
const handleOcr = async () => {
const event = await scan();
if (event) applyOcrEvent(event);
};
useEffect(() => {
if (autoOcr) handleOcr();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoOcr]);
// ===== 未保存守卫 =====
const initialSnapshotRef = useRef<{ expr: string; narration: string; payee: string; tags: string[] } | null>(null);
useEffect(() => {
if (editingTx) {
const legs = parsePostingsToLegs(editingTx.postings);
initialSnapshotRef.current = {
expr: legs?.amount || '',
narration: editingTx.narration || '',
payee: editingTx.payee || '',
tags: editingTx.tags || [],
};
} else if (draftJson) {
try {
const draft = JSON.parse(draftJson);
const legs = parsePostingsToLegs(draft.postings ?? []);
initialSnapshotRef.current = {
expr: legs?.amount || '',
narration: draft.narration || '',
payee: draft.payee || '',
tags: Array.isArray(draft.tags) ? draft.tags : [],
};
} catch {}
}
}, [editingTx, draftJson]);
const formRef = useRef({ expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted });
formRef.current = { expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted };
// 脏状态计算(读 formRef整页守卫与 modal 宿主共用)
const computeDirty = () => {
const s = formRef.current;
if (s.isSubmitted) return false;
const init = initialSnapshotRef.current;
if (init) {
const tagsChanged = JSON.stringify(s.selectedTags.map(t => t.name).sort()) !== JSON.stringify([...init.tags].sort());
return (
evaluateExpression(s.expr) !== evaluateExpression(init.expr) ||
s.narration.trim() !== init.narration.trim() ||
s.payee.trim() !== init.payee.trim() ||
tagsChanged
);
}
return (
evaluateExpression(s.expr) !== '' ||
s.narration.trim() !== '' ||
s.payee.trim() !== '' ||
s.selectedTags.length > 0 ||
(s.isAdvanced && s.postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''))
);
};
// modal 模式每次渲染后把脏状态上报给宿主Host 的遮罩/返回键守卫用)
useEffect(() => {
onDirtyChange?.(computeDirty());
});
// 整页模式beforeRemove 守卫
useEffect(() => {
if (presentation !== 'page') return;
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
if (!computeDirty()) return;
e.preventDefault();
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{ text: t('common.discard'), style: 'destructive', onPress: () => navigation.dispatch(e.data.action) },
]);
});
return unsubscribe;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, presentation, t]);
// ===== 键盘 =====
const handleKey = (key: string) => {
if (key === 'done') { submit(); return; }
if (key === 'today') { setDate(toDateString(new Date())); return; }
setExpr(prev => pressKey(prev, key as NumpadKey));
};
const swapAccounts = () => {
const s = sourceAccount;
setSourceAccount(targetAccount);
setTargetAccount(s);
};
const closeSoon = () => {
onClose?.();
};
// modal 工具行关闭按钮:脏数据时先确认(提交成功走 closeSoon 直关isSubmitted 已置位不会误拦)
const requestModalClose = () => {
if (!computeDirty()) { onClose?.(); return; }
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{ text: t('common.discard'), style: 'destructive', onPress: () => onClose?.() },
]);
};
// ===== 提交 =====
const submit = async () => {
if (submitting || isSubmitted) return;
setSubmitting(true);
const tagNames = selectedTags.map(tg => tg.name);
try {
if (isAdvanced) {
// ===== 高级模式:多行分录 =====
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) { setStatus(t('transaction.atLeast2Postings')); return; }
const currencySum: Record<string, string[]> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
(currencySum[p.currency] = currencySum[p.currency] || []).push(p.amount);
}
}
const balanced = Object.values(currencySum).every(amounts => compareDecimals(addDecimals(amounts), '0') === 0);
if (!balanced) { setStatus(t('transaction.unbalanced')); return; }
const draft = {
date,
flag: editId ? editFlag : undefined,
narration: narration.trim() || t('transaction.defaultAdvancedNarration'),
postings: validPostings,
tags: tagNames,
links: editId && editLinks && editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined,
};
if (editId && oldRaw) {
await editTransaction(oldRaw, serializeTransaction(draft));
setStatus(t('transaction.editSuccess'));
} else {
await useLedgerStore.getState().addTransaction(draft);
setStatus(t('transaction.appended'));
}
setIsSubmitted(true);
closeSoon();
return;
}
// ===== 简单模式:双腿 =====
const amt = amount;
if (!amt || compareDecimals(amt, '0') <= 0) { setStatus(t('transaction.amountRequired')); return; }
if (!sourceAccount) { setStatus(t('numpad.noAccount')); return; }
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
return;
}
// 转账双方必须是资金/负债账户,防止分类账户污染目标腿
if (direction === 'transfer') {
const notAssetLike = (a: string) => !a.startsWith('Assets') && !a.startsWith('Liabilities');
if (notAssetLike(sourceAccount) || notAssetLike(targetAccount)) {
setStatus(t('numpad.transferTargetError'));
return;
}
}
const categoryAccount = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
const defaultNarration =
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer');
if (editId && oldRaw) {
const draft = {
date,
payee: payee.trim() || undefined,
flag: editFlag,
narration: narration.trim() || defaultNarration,
postings: buildPostings(direction, sourceAccount, categoryAccount, amt, currency),
tags: tagNames,
links: editLinks && editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
};
await editTransaction(oldRaw, serializeTransaction(draft));
setStatus(t('transaction.editSuccess'));
setIsSubmitted(true);
closeSoon();
} else {
if (parsedOriginalDraftRef.current) {
const orig = parsedOriginalDraftRef.current;
const userPayee = payee.trim();
const userCategory = categoryAccount;
const userSource = sourceAccount;
const userAmount = amt;
const userDirection = direction;
const isCategoryChanged = userCategory !== orig.categoryAccount;
const isPayeeChanged = userPayee !== orig.payee;
const isAmountChanged = userAmount !== orig.amount;
const isSourceChanged = userSource !== orig.sourceAccount;
const isDirectionChanged = userDirection !== orig.direction;
if (isCategoryChanged || isPayeeChanged || isAmountChanged || isSourceChanged || isDirectionChanged) {
logger.info('userCorrection', `[用户修正草稿记账] 预填推荐 vs 用户手动修正:`, {
original: { payee: orig.payee, category: orig.categoryAccount, sourceAccount: orig.sourceAccount, amount: orig.amount, direction: orig.direction },
corrected: { payee: userPayee, category: userCategory, sourceAccount: userSource, amount: userAmount, direction: userDirection },
changes: { categoryChanged: isCategoryChanged, payeeChanged: isPayeeChanged, amountChanged: isAmountChanged, sourceChanged: isSourceChanged, directionChanged: isDirectionChanged },
});
}
}
await buildAndSaveTransaction({
date,
amount: amt,
payee: payee.trim() || undefined,
narration: narration.trim() || defaultNarration,
categoryAccount,
sourceAccount,
direction,
currency,
tags: tagNames,
}, useLedgerStore.getState());
setLastUsedEntry(sourceAccount || null, direction === 'transfer' ? lastUsedCategoryId : (selectedCategory?.id ?? null));
setStatus(t('transaction.appended'));
setIsSubmitted(true);
setExpr('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
closeSoon();
}
} catch (e) {
setStatus(String(e instanceof Error ? e.message : e));
} finally {
setSubmitting(false);
}
};
// ===== 高级模式切换时初始化分录 =====
const toggleAdvanced = (val: boolean) => {
setIsAdvanced(val);
if (val) {
const amt = amount;
const categoryAccount = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized');
setPostings(buildPostings(direction, sourceAccount, categoryAccount, amt || '0', currency)
.map(p => ({ ...p, amount: p.amount === '-0' || p.amount === '0' ? '' : p.amount })));
}
};
const directions: { key: Direction; label: string }[] = [
{ key: 'expense', label: t('transaction.directionExpense') },
{ key: 'income', label: t('transaction.directionIncome') },
{ key: 'transfer', label: t('transaction.directionTransfer') },
];
const shortName = (acct: string) => acct.split(':').pop() || acct;
const previewTarget = direction === 'transfer'
? targetAccount
: selectedCategory?.linkedAccount ?? '';
const renderAccountRow = (label: string, value: string, onSelect: (a: string) => void, pickerKey: 'source' | 'target') => (
<View style={styles.accountRow}>
<Text style={[theme.typography.caption, styles.accountLabel, { color: theme.colors.fgSecondary }]}>{label}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.accountChips}>
{assetAccounts.length === 0 ? (
<Pressable onPress={() => router.push('/account')} style={commonStyles.chip}>
<Text style={[commonStyles.chipText, { color: theme.colors.accent }]}>{t('numpad.noAccount')}</Text>
</Pressable>
) : (
<>
{chipAccounts.map(acct => {
const active = value === acct;
return (
<Pressable
key={`${pickerKey}-${acct}`}
onPress={() => onSelect(acct)}
style={[commonStyles.chip, active && commonStyles.chipActive]}
>
<Text style={[commonStyles.chipText, active && commonStyles.chipTextActive]}>{shortName(acct)}</Text>
</Pressable>
);
})}
<Pressable onPress={() => setAccountPicker(pickerKey)} style={commonStyles.chip}>
<Text style={[commonStyles.chipText, { color: theme.colors.fgSecondary }]}></Text>
</Pressable>
</>
)}
</ScrollView>
</View>
);
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={presentation === 'page' ? styles.flex1 : undefined}>
<View style={[
styles.sheet,
presentation === 'modal' && {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
},
presentation === 'page' && { backgroundColor: theme.colors.bgPrimary },
]}>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={styles.content}>
{/* 工具行 */}
<View style={styles.toolRow}>
{presentation === 'modal' ? (
<Pressable onPress={requestModalClose} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('numpad.close')}>
<Ionicons name="chevron-down" size={24} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
<View style={styles.flex1} />
<Pressable onPress={handleOcr} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('home.ocrScan')}>
<Ionicons name="camera-outline" size={22} color={theme.colors.fgSecondary} />
</Pressable>
{presentation === 'page' ? (
<Pressable onPress={() => router.push('/import')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('tab.import')} style={{ marginLeft: 16 }}>
<Ionicons name="download-outline" size={22} color={theme.colors.fgSecondary} />
</Pressable>
) : null}
</View>
{/* 方向 */}
<View style={styles.directionRow}>
{directions.map(d => (
<Pressable
key={d.key}
onPress={() => {
setDirection(d.key);
setSelectedCategory(null);
// 切到转账时清掉非资金/负债的目标账户,避免分类账户污染转账腿
if (d.key === 'transfer' && targetAccount
&& !targetAccount.startsWith('Assets') && !targetAccount.startsWith('Liabilities')) {
setTargetAccount('');
}
}}
style={[commonStyles.chip, direction === d.key && commonStyles.chipActive, styles.directionChip]}
>
<Text style={[commonStyles.chipText, direction === d.key && commonStyles.chipTextActive]}>{d.label}</Text>
</Pressable>
))}
</View>
{/* 金额 */}
<Text style={[theme.typography.display, styles.amount, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
¥ {amount || '0'}
</Text>
{/* 账户行(双腿之一) */}
{direction === 'transfer' ? (
<>
{renderAccountRow(t('numpad.outFrom'), sourceAccount, setSourceAccount, 'source')}
<Pressable onPress={swapAccounts} style={styles.swapBtn} accessibilityRole="button" accessibilityLabel={t('numpad.swap')}>
<Ionicons name="swap-vertical" size={18} color={theme.colors.fgSecondary} />
</Pressable>
{renderAccountRow(t('numpad.transferTo'), targetAccount, setTargetAccount, 'target')}
</>
) : (
renderAccountRow(direction === 'income' ? t('numpad.to') : t('numpad.from'), sourceAccount, setSourceAccount, 'source')
)}
{/* 分类快捷行(另一腿,非转账) */}
{direction !== 'transfer' && (
<View style={styles.categoryRow}>
{quickCategories.map(cat => {
const active = selectedCategory?.id === cat.id;
return (
<Pressable
key={cat.id}
onPress={() => setSelectedCategory(active ? null : cat)}
style={[styles.categoryCell, {
backgroundColor: active ? theme.colors.accentLight : theme.colors.bgTertiary,
borderColor: active ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.md,
}]}
>
<CategoryIcon categoryId={cat.id} size={28} />
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]} numberOfLines={1}>{cat.name}</Text>
</Pressable>
);
})}
<Pressable
onPress={() => setCategoryPickerOpen(true)}
style={[styles.categoryCell, { backgroundColor: theme.colors.bgTertiary, borderColor: 'transparent', borderRadius: theme.radii.md }]}
>
<Ionicons name="grid-outline" size={22} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('numpad.allCategories')}</Text>
</Pressable>
</View>
)}
{/* 数字键盘(简单模式) */}
{!isAdvanced && (
<NumpadKeyboard onKey={handleKey} todayLabel={t('numpad.today')} doneLabel={t('numpad.done')} backspaceLabel={t('numpad.backspace')} />
)}
{/* 分录预览(常驻小字) */}
{!isAdvanced && amount && sourceAccount && previewTarget ? (
<Text style={[theme.typography.caption, styles.preview, { color: theme.colors.fgSecondary }]} numberOfLines={1}>
{shortName(sourceAccount)} {shortName(previewTarget)} · ¥{amount} · {date}
</Text>
) : null}
{/* 更多抽屉 */}
<Pressable onPress={() => setShowMore(v => !v)} style={styles.moreToggle} accessibilityRole="button">
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>
{showMore ? t('numpad.less') : t('numpad.more')}
</Text>
<Ionicons name={showMore ? 'chevron-up-outline' : 'chevron-down-outline'} size={16} color={theme.colors.accent} />
</Pressable>
{showMore && (
<View style={styles.moreSection}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('transaction.formDate')}</Text>
<DatePickerField value={date} onChange={setDate} />
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formCurrency')}</Text>
<TextInput
value={currency}
onChangeText={setCurrency}
autoCapitalize="characters"
maxLength={8}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formNarration')}</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formPayee')}</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.fieldGap, { color: theme.colors.fgSecondary }]}>{t('transaction.formTags')}</Text>
<TagPicker
tags={tags}
selectedNames={selectedTags.map(tg => tg.name)}
onToggle={(tag) => setSelectedTags(prev =>
prev.some(x => x.name === tag.name) ? prev.filter(x => x.name !== tag.name) : [...prev, tag],
)}
/>
{/* 高级模式 */}
<View style={[commonStyles.switchRow, styles.advancedRow]}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{t('numpad.advanced')}
</Text>
<Switch
value={isAdvanced}
onValueChange={toggleAdvanced}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
</View>
)}
{/* 高级模式分录编辑器 */}
{isAdvanced && (
<View style={styles.advancedSection}>
<PostingEditor postings={postings} onChange={setPostings} />
<View style={styles.postingBtnRow}>
<View style={styles.flex1}>
<Button label={t('transaction.addPosting')} onPress={() => setPostings([...postings, { account: '', amount: '', currency }])} variant="secondary" />
</View>
<View style={styles.flex1}>
<Button label={t('transaction.deleteLastPosting')} onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))} variant="secondary" />
</View>
</View>
<Button label={t('transaction.submit')} onPress={submit} disabled={submitting || isSubmitted} />
</View>
)}
{status || ocrStatus ? (
<Text style={[theme.typography.bodySmall, styles.status, { color: theme.colors.fgSecondary }]}>{status || ocrStatus}</Text>
) : null}
</ScrollView>
{/* 全部账户 BottomSheet */}
<BottomSheet visible={accountPicker !== null} onClose={() => setAccountPicker(null)} title={t('numpad.allAccounts')}>
<ScrollView style={styles.accountList}>
<Pressable
onPress={() => setIsQuickAddingAccount(true)}
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider, flexDirection: 'row', alignItems: 'center' }]}
>
<Ionicons name="add-circle-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700', marginLeft: 6 }]}>
{t('account.addNew')}
</Text>
</Pressable>
{assetAccounts.map(acct => (
<Pressable
key={acct}
onPress={() => {
if (accountPicker === 'source') setSourceAccount(acct);
else if (accountPicker === 'target') setTargetAccount(acct);
setAccountPicker(null);
}}
style={[styles.accountListItem, { borderBottomColor: theme.colors.divider }]}
>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{acct}</Text>
</Pressable>
))}
</ScrollView>
</BottomSheet>
{/* 快捷新建账户 AccountCreateModal (全应用 100% 统一复用) */}
<AccountCreateModal
visible={isQuickAddingAccount}
defaultType="Assets"
onConfirm={handleQuickAddAccount}
onCancel={() => setIsQuickAddingAccount(false)}
/>
{/* 全部分类 BottomSheet */}
<BottomSheet visible={categoryPickerOpen} onClose={() => setCategoryPickerOpen(false)} title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<ScrollView style={styles.accountList}>
<CategoryPicker
categories={availableCategories}
selectedId={selectedCategory?.id}
onSelect={(cat) => { setSelectedCategory(cat); setCategoryPickerOpen(false); }}
/>
</ScrollView>
</BottomSheet>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex1: { flex: 1 },
sheet: { maxHeight: '100%' },
content: { paddingHorizontal: 16, paddingBottom: 24 },
toolRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8 },
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
directionChip: { flex: 1, alignItems: 'center' },
amount: { textAlign: 'right', paddingVertical: 8, fontVariant: ['tabular-nums'] },
accountRow: { flexDirection: 'row', alignItems: 'center', marginTop: 4 },
accountLabel: { width: 34 },
accountChips: { gap: 6, paddingRight: 8 },
swapBtn: { alignSelf: 'center', padding: 4 },
categoryRow: { flexDirection: 'row', gap: 6, marginTop: 10, marginBottom: 6 },
categoryCell: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 8, gap: 4, borderWidth: 1.5 },
preview: { textAlign: 'center', marginTop: 6 },
moreToggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 },
moreSection: { gap: 4 },
fieldGap: { marginTop: 10, marginBottom: 4 },
advancedRow: { marginTop: 14 },
advancedSection: { marginTop: 8, gap: 8 },
postingBtnRow: { flexDirection: 'row', gap: 8 },
status: { textAlign: 'center', marginTop: 8 },
accountList: { maxHeight: 360 },
accountListItem: { paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth },
});

View File

@ -0,0 +1,47 @@
import React, { useRef } from 'react';
import { Alert, Modal, Pressable, StyleSheet } from 'react-native';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { NumpadSheet } from './NumpadSheet';
import { useNumpadUiStore } from '../store/numpadUiStore';
/** 全局记账面板宿主:挂载于根布局,任意页面可唤起(不丢上下文)。 */
export function NumpadSheetHost() {
const { theme } = useTheme();
const t = useT();
const visible = useNumpadUiStore(s => s.visible);
const prefill = useNumpadUiStore(s => s.prefill);
const close = useNumpadUiStore(s => s.close);
// 表单脏状态NumpadSheet 每次渲染后上报),遮罩/返回键关闭前据此弹未保存确认
const dirtyRef = useRef(false);
const requestClose = () => {
if (!dirtyRef.current) { close(); return; }
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.discard'), style: 'destructive', onPress: close },
]);
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={requestClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={requestClose}>
<Pressable style={styles.sheetWrap} onPress={e => e.stopPropagation()}>
<NumpadSheet
presentation="modal"
editId={prefill?.editId}
draftJson={prefill?.draftJson}
autoOcr={prefill?.autoOcr}
onClose={close}
onDirtyChange={d => { dirtyRef.current = d; }}
/>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheetWrap: { maxHeight: '94%' },
});

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { BalanceIndicator } from './BalanceIndicator';
@ -12,29 +12,41 @@ interface PostingEditorProps {
accounts?: string[];
}
/** 生成 Posting 的稳定 key基于内容的复合 key。 */
function postingKey(p: Posting, index: number): string {
return `${p.account ?? ''}|${p.amount ?? ''}|${p.currency ?? ''}|${index}`;
}
/** 分类编辑器(主题化):编辑多行 account/amount/currency实时显示平衡状态。 */
export function PostingEditor({ postings, onChange }: PostingEditorProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const postingsRef = useRef(postings);
postingsRef.current = postings;
const update = (index: number, patch: Partial<Posting>) => {
onChange(postings.map((p, i) => i === index ? { ...p, ...patch } : p));
};
// 使用函数式更新,避免 onChange 依赖 postings 导致每次渲染重建
const update = useCallback((index: number, patch: Partial<Posting>) => {
const current = postingsRef.current;
onChange(current.map((p, i) => i === index ? { ...p, ...patch } : p));
}, [onChange]);
// 计算各币种余额
const grouped = new Map<string, string[]>();
for (const p of postings) {
if (p.amount && p.currency) {
grouped.set(p.currency, [...(grouped.get(p.currency) ?? []), p.amount]);
const balances = useMemo(() => {
const grouped = new Map<string, string[]>();
for (const p of postings) {
if (p.amount && p.currency) {
grouped.set(p.currency, [...(grouped.get(p.currency) ?? []), p.amount]);
}
}
}
const balances: Record<string, string> = {};
for (const [cur, amts] of grouped) balances[cur] = addDecimals(amts);
const result: Record<string, string> = {};
for (const [cur, amts] of grouped) result[cur] = addDecimals(amts);
return result;
}, [postings]);
return (
<View style={{ gap: 8 }}>
{postings.map((p, i) => (
<View key={i} style={styles.row}>
<View key={postingKey(p, i)} style={styles.row}>
<TextInput
value={p.account}
onChangeText={v => update(i, { account: v })}
@ -48,7 +60,7 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
placeholder="金额"
keyboardType="decimal-pad"
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]} // 金额输入也采用等宽字体更专业
style={[commonStyles.input, { flex: 1, fontVariant: ['tabular-nums'] }]}
/>
<TextInput
value={p.currency ?? ''}

View File

@ -0,0 +1,44 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
import { Touchable } from './Touchable';
interface ScreenHeaderProps {
title: string;
/** 右侧操作位(图标按钮/文本按钮)。 */
right?: React.ReactNode;
/** 自定义返回行为;默认 router.back()。 */
onBack?: () => void;
/** 返回按钮的无障碍标签。 */
backLabel?: string;
}
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header。 */
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
const { theme } = useTheme();
const router = useRouter();
return (
<View style={styles.row}>
<Touchable
onPress={onBack ?? (() => router.back())}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={backLabel}
>
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Touchable>
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{title}
</Text>
{right ?? <View style={styles.rightPlaceholder} />}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
title: { flex: 1 },
rightPlaceholder: { width: 24 },
});

View File

@ -17,7 +17,7 @@ export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps)
styles.container,
{
backgroundColor: theme.colors.bgTertiary,
borderRadius: theme.radii.lg, // 升级为 lg 圆角
borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
},

View File

@ -1,195 +0,0 @@
import React, { useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
import { useT } from '../i18n';
/** 快捷操作项。 */
export interface SpeedDialAction {
key: string;
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
}
interface SpeedDialProps {
/** 展开式快捷操作列表。不传时保持单按钮行为(记一笔)。 */
actions?: SpeedDialAction[];
}
/**
*
* - actions "+"
* - actions mini-FAB //
*/
export function SpeedDial({ actions }: SpeedDialProps) {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const [open, setOpen] = useState(false);
const [spin] = useState(new Animated.Value(0));
const hasActions = actions && actions.length > 0;
const toggle = () => {
const next = !open;
setOpen(next);
Animated.spring(spin, {
toValue: next ? 1 : 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
};
const handleAction = (action: SpeedDialAction) => {
setOpen(false);
Animated.spring(spin, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
action.onPress();
};
const mainRotation = spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '45deg'],
});
const mainIcon = hasActions ? (
<Animated.View style={{ transform: [{ rotate: mainRotation }] }}>
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
</Animated.View>
) : (
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
);
return (
<>
{/* 遮罩层:展开时点击空白收起 */}
{hasActions && open && (
<Pressable
onPress={toggle}
style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}
/>
)}
{/* 展开的快捷操作列表 */}
{hasActions && (
<View style={styles.actionsContainer} pointerEvents={open ? 'auto' : 'none'}>
{actions!.map((action, i) => {
const offset = 72 + 56 * (actions!.length - 1 - i);
const translateY = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, -offset],
});
const opacity = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
const scale = spin.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
});
return (
<Animated.View
key={action.key}
style={[
styles.actionRow,
{ transform: [{ translateY }, { scale }], opacity },
]}
>
<View style={[styles.actionLabel, { backgroundColor: theme.colors.bgSecondary }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]}>
{action.label}
</Text>
</View>
<Pressable
onPress={() => handleAction(action)}
accessibilityRole="button"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.miniFab,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
opacity: pressed ? 0.7 : 1,
},
]}
>
<Ionicons name={action.icon} size={20} color={theme.colors.accent} />
</Pressable>
</Animated.View>
);
})}
</View>
)}
{/* 主按钮 */}
<Pressable
onPress={hasActions ? toggle : () => router.push('/transaction/new')}
style={({ pressed }) => [
styles.fab,
{
backgroundColor: theme.colors.accent,
opacity: pressed ? 0.85 : 1,
...theme.shadows.lg,
},
]}
accessibilityRole="button"
accessibilityLabel={t('home.newTransaction')}
accessibilityHint={hasActions ? t('speedDial.expandHint') : undefined}
>
{mainIcon}
</Pressable>
</>
);
}
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
zIndex: 9998,
},
fab: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
},
actionsContainer: {
position: 'absolute',
right: 28,
bottom: 28,
zIndex: 9999,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 8,
marginBottom: 8,
},
actionLabel: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
},
miniFab: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
});

View File

@ -0,0 +1,41 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface StatCardProps {
label: string;
value: string;
caption?: string;
/** 默认 fgPrimary收支场景传 financial.income/expense。 */
valueColor?: string;
}
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
const { theme } = useTheme();
return (
<View
style={[styles.card, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.xl,
borderColor: theme.colors.border,
}, theme.shadows.sm]}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
<Text
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
numberOfLines={1}
>
{value}
</Text>
{caption ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
});

View File

@ -0,0 +1,64 @@
/**
* spec §7.2 /
* gesture-handler Swipeable reanimated worklet
*/
import React, { useRef } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { TransactionCard } from './TransactionCard';
import type { Transaction } from '../domain/types';
interface SwipeableTransactionCardProps {
transaction: Transaction;
categoryId?: string;
showDate?: boolean;
onPress: (t: Transaction) => void;
/** 复制:跳转录入页预填(由调用方实现)。 */
onDuplicate: (t: Transaction) => void;
/** 删除:调用方负责 confirm + deleteTransaction。 */
onDelete: (t: Transaction) => void;
}
export function SwipeableTransactionCard({ transaction, categoryId, showDate, onPress, onDuplicate, onDelete }: SwipeableTransactionCardProps) {
const { theme } = useTheme();
const t = useT();
const swipeRef = useRef<Swipeable>(null);
const renderRightActions = () => (
<View style={styles.actions}>
<Pressable
onPress={() => { swipeRef.current?.close(); onDuplicate(transaction); }}
style={[styles.actionBtn, { backgroundColor: theme.colors.bgTertiary, borderRadius: theme.radii.md }]}
accessibilityRole="button"
accessibilityLabel={t('transactions.duplicate')}
>
<Ionicons name="copy-outline" size={20} color={theme.colors.fgPrimary} />
<Text style={[styles.actionText, { color: theme.colors.fgPrimary }]}>{t('transactions.duplicate')}</Text>
</Pressable>
<Pressable
onPress={() => { swipeRef.current?.close(); onDelete(transaction); }}
style={[styles.actionBtn, { backgroundColor: theme.colors.financial.expense, borderRadius: theme.radii.md }]}
accessibilityRole="button"
accessibilityLabel={t('common.delete')}
>
<Ionicons name="trash-outline" size={20} color={theme.colors.fgInverse} />
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{t('common.delete')}</Text>
</Pressable>
</View>
);
return (
<Swipeable ref={swipeRef} renderRightActions={renderRightActions} overshootRight={false}>
<TransactionCard transaction={transaction} categoryId={categoryId} showDate={showDate} onPress={onPress} />
</Swipeable>
);
}
const styles = StyleSheet.create({
actions: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingLeft: 8, marginBottom: 8 },
actionBtn: { width: 64, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 10 },
actionText: { fontSize: 11, fontWeight: '600' },
});

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import type { Tag } from '../domain/tags';
@ -12,7 +12,7 @@ interface TagPickerProps {
/** 标签选择器(主题化,多选 chip。 */
export function TagPicker({ tags, selectedNames, onToggle }: TagPickerProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
return (
<View style={styles.container}>

View File

@ -0,0 +1,110 @@
/**
* spec §7.1 / /
*
*/
import React, { useMemo } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { Card } from './Card';
import { useLedgerStore } from '../store/ledgerStore';
import { useMetadataStore } from '../store/metadataStore';
import { useAutomationStore } from '../store/automationStore';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../domain/recurring';
import { calculateBillingPeriod } from '../domain/creditCards';
import type { CreditCard } from '../domain/creditCards';
import { toDateString } from '../domain/decimal';
import { shiftAnchor } from '../domain/periodNav';
export function TodoStrip() {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const creditCards = useMetadataStore(s => s.creditCards);
const draftsCount = useAutomationStore(s => s.drafts.length);
const todayStr = useMemo(() => toDateString(new Date()), []);
const weekLater = useMemo(() => shiftAnchor(todayStr, 'weekly', 1), [todayStr]);
// 下一还款日当月还款日已过则取下月周期月末跨月场景filter 与展示共用)
const nextDueDate = (card: CreditCard): string => {
const now = new Date();
const period = calculateBillingPeriod(card, now);
if (period.dueDate >= todayStr) return period.dueDate;
return calculateBillingPeriod(card, new Date(now.getFullYear(), now.getMonth() + 1, 1)).dueDate;
};
// autoConfirm 的由首页自动处理,待办条只列需手动确认的
const dueRecurring = useMemo(
() => getDueRecurring(recurringTransactions, todayStr).filter(r => !r.autoConfirm),
[recurringTransactions, todayStr],
);
const dueCards = useMemo(
() => creditCards.filter(card => {
const due = nextDueDate(card);
return due >= todayStr && due <= weekLater;
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[creditCards, todayStr, weekLater],
);
if (dueRecurring.length === 0 && dueCards.length === 0 && draftsCount === 0) return null;
const handleConfirmRecurring = async (rec: (typeof dueRecurring)[number]) => {
try {
const draft = instantiateRecurring(rec);
await addTransaction(draft);
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate);
updateRecurringTransaction(rec.id, { nextDueDate });
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name }));
} catch (e) {
Alert.alert(t('home.recurringFail'), String(e));
}
};
const renderRow = (icon: keyof typeof Ionicons.glyphMap, text: string, key: string, action?: { label: string; onPress: () => void }) => (
<View key={key} style={[styles.row, { borderBottomColor: theme.colors.divider }]}>
<Ionicons name={icon} size={18} color={theme.colors.accent} />
<Text style={[theme.typography.bodySmall, styles.rowText, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{text}
</Text>
{action ? (
<Pressable
onPress={action.onPress}
style={[styles.actionBtn, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.full }]}
accessibilityRole="button"
accessibilityLabel={action.label}
>
<Text style={[styles.actionText, { color: theme.colors.fgInverse }]}>{action.label}</Text>
</Pressable>
) : (
<Ionicons name="chevron-forward" size={16} color={theme.colors.fgSecondary} />
)}
</View>
);
return (
<Card title={t('todo.title')}>
{dueRecurring.map(rec =>
renderRow('repeat-outline', t('todo.dueRecurring', { name: rec.name }), `rec-${rec.id}`, { label: t('todo.confirm'), onPress: () => handleConfirmRecurring(rec) }),
)}
{dueCards.map(card =>
renderRow('card-outline', t('todo.creditCardDue', { name: card.name, date: nextDueDate(card).slice(5) }), `card-${card.id}`, { label: t('todo.view'), onPress: () => router.push('/credit-card') }),
)}
{draftsCount > 0 &&
renderRow('flash-outline', t('todo.pendingDrafts', { count: draftsCount }), 'drafts', { label: t('todo.view'), onPress: () => router.push('/automation') })}
</Card>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth },
rowText: { flex: 1 },
actionBtn: { paddingHorizontal: 12, paddingVertical: 5 },
actionText: { fontSize: 12, fontWeight: '700' },
});

View File

@ -0,0 +1,44 @@
import React from 'react';
import { Platform, Pressable, type PressableProps, type StyleProp, type ViewStyle } from 'react-native';
import { useTheme } from '../theme';
export interface TouchableProps extends Omit<PressableProps, 'style'> {
/** 按下时的透明度 (不传则使用主题 theme.opacities.pressed 0.7)。 */
activeOpacity?: number;
/** 是否禁用 Android 水波纹 (默认 false, 自动开启 android_ripple)。 */
disableRipple?: boolean;
/** 支持传统 ViewStyle 数组/对象,或者 Pressable 的动态 style 函数。 */
style?: StyleProp<ViewStyle> | ((state: { pressed: boolean }) => StyleProp<ViewStyle>);
}
/**
* Pressable
* - iOS: 优雅的 opacity ( tokens )
* - Android: 自动配置 android_ripple
*/
export function Touchable({ activeOpacity, disableRipple = false, style, children, android_ripple, ...props }: TouchableProps) {
const { theme } = useTheme();
const defaultOpacity = activeOpacity ?? theme.opacities.pressed;
const defaultRipple = !disableRipple && Platform.OS === 'android' ? {
color: theme.colors.border,
foreground: true,
...android_ripple,
} : android_ripple;
return (
<Pressable
android_ripple={defaultRipple}
{...props}
style={(state) => {
const resolvedStyle = typeof style === 'function' ? style(state) : style;
return [
resolvedStyle,
state.pressed && { opacity: defaultOpacity },
];
}}
>
{children}
</Pressable>
);
}

Some files were not shown because too many files have changed in this diff Show More