# UI 重设计 P3：录入闭环（NumpadSheet）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:** 用金额优先的数字键盘面板（NumpadSheet）重写录入闭环：双腿账户 chip 选择、＋/－ 连续计算、全局＋唤起（灰度开关）、transaction/new 整页重写为面板宿主、SpeedDial 退役。

**Architecture:** 纯逻辑（键盘表达式、postings→双腿解析）抽成可测模块；NumpadSheet 是唯一录入 UI，同时服务整页路由（/transaction/new，含编辑/草稿/OCR 预填）与全局 Modal（NumpadSheetHost 挂根布局）；落账仍走 `buildAndSaveTransaction()`/`editTransaction`，domain 写路径零改动。

**Tech Stack:** React Native + Expo Router + Zustand + Ionicons + Vitest。

**Spec:** `docs/ui-redesign-design.md` §4（NumpadSheet）、§5（导航）。**与 spec 的偏差**：分录预览行常驻显示，不做设置开关（YAGNI）；灰度开关只控制「＋ 开全局面板 vs 跳整页」，默认 **false**（P5 稳定后翻默认）。

**前置状态：** P1/P2 已完成（547 测试全绿；BottomSheet/DatePickerField/CategoryIcon/CategoryPicker 已可用）。

---

### Task 1: 纯逻辑失败测试

**Files:**
- Create: `tests/numpad.test.ts`

- [ ] **Step 1: 新建 tests/numpad.test.ts**

```typescript
import { describe, expect, it } from 'vitest';
import { pressKey, evaluateExpression } from '../src/components/numpadExpression';
import { parsePostingsToLegs } from '../src/domain/postingLegs';
import { buildPostings } from '../src/domain/transactionBuilder';

describe('pressKey 表达式编辑', () => {
  it('数字键追加', () => {
    expect(pressKey('', '5')).toBe('5');
    expect(pressKey('5', '3')).toBe('53');
  });

  it('前导零被替换', () => {
    expect(pressKey('0', '5')).toBe('5');
  });

  it('小数点：空段补 0，同段不重复', () => {
    expect(pressKey('', '.')).toBe('0.');
    expect(pressKey('5', '.')).toBe('5.');
    expect(pressKey('5.5', '.')).toBe('5.5');
  });

  it('小数最多两位', () => {
    expect(pressKey('5.55', '5')).toBe('5.55');
    expect(pressKey('5.5', '5')).toBe('5.55');
  });

  it('整数位最多 9 位', () => {
    expect(pressKey('123456789', '0')).toBe('123456789');
    expect(pressKey('12345678', '9')).toBe('123456789');
  });

  it('运算符：数字后可加，连续运算符替换，空表达式不可加', () => {
    expect(pressKey('5', '+')).toBe('5+');
    expect(pressKey('5+', '-')).toBe('5-');
    expect(pressKey('', '+')).toBe('');
    expect(pressKey('', '-')).toBe('');
  });

  it('运算符后开启新数字段（小数规则独立）', () => {
    expect(pressKey('5.5+', '.')).toBe('5.5+0.');
    expect(pressKey('5.55+3', '.')).toBe('5.55+3.');
  });

  it('backspace 删除末字符', () => {
    expect(pressKey('5.5', 'backspace')).toBe('5.');
    expect(pressKey('5', 'backspace')).toBe('');
    expect(pressKey('', 'backspace')).toBe('');
  });
});

describe('evaluateExpression 求值', () => {
  it('单值', () => {
    expect(evaluateExpression('35')).toBe('35');
  });

  it('加减混合（字符串十进制，无浮点误差）', () => {
    expect(evaluateExpression('20+15')).toBe('35');
    expect(evaluateExpression('20+15-5.5')).toBe('29.5');
    expect(evaluateExpression('0.1+0.2')).toBe('0.3');
  });

  it('尾随运算符被忽略', () => {
    expect(evaluateExpression('20+')).toBe('20');
    expect(evaluateExpression('20+15-')).toBe('35');
  });

  it('空表达式返回空串', () => {
    expect(evaluateExpression('')).toBe('');
  });
});

describe('parsePostingsToLegs 双腿解析', () => {
  it('与 buildPostings 往返一致：支出', () => {
    const legs = parsePostingsToLegs(buildPostings('expense', 'Assets:招行', 'Expenses:餐饮', '35', 'CNY'));
    expect(legs).toEqual({
      direction: 'expense',
      sourceAccount: 'Assets:招行',
      targetAccount: 'Expenses:餐饮',
      amount: '35',
      currency: 'CNY',
    });
  });

  it('与 buildPostings 往返一致：收入', () => {
    const legs = parsePostingsToLegs(buildPostings('income', 'Assets:招行', 'Income:工资', '12000', 'CNY'));
    expect(legs).toEqual({
      direction: 'income',
      sourceAccount: 'Assets:招行',
      targetAccount: 'Income:工资',
      amount: '12000',
      currency: 'CNY',
    });
  });

  it('与 buildPostings 往返一致：转账', () => {
    const legs = parsePostingsToLegs(buildPostings('transfer', 'Assets:招行', 'Liabilities:花呗', '2000', 'CNY'));
    expect(legs).toEqual({
      direction: 'transfer',
      sourceAccount: 'Assets:招行',
      targetAccount: 'Liabilities:花呗',
      amount: '2000',
      currency: 'CNY',
    });
  });

  it('金额取绝对值（去掉负号）', () => {
    const legs = parsePostingsToLegs([
      { account: 'Assets:招行', amount: '-35', currency: 'CNY' },
      { account: 'Expenses:餐饮', amount: '35', currency: 'CNY' },
    ]);
    expect(legs?.amount).toBe('35');
  });

  it('无法识别时返回 null', () => {
    expect(parsePostingsToLegs([])).toBeNull();
    expect(parsePostingsToLegs([{ account: 'Assets:招行', amount: '35', currency: 'CNY' }])).toBeNull();
  });
});
```

- [ ] **Step 2: 运行确认失败**

Run: `npx vitest run tests/numpad.test.ts`
Expected: FAIL —— `Cannot find module '../src/components/numpadExpression'`。**若意外通过，报告 BLOCKED。**

---

### Task 2: numpadExpression + postingLegs 纯逻辑

**Files:**
- Create: `src/components/numpadExpression.ts`
- Create: `src/domain/postingLegs.ts`
- Modify: `src/domain/index.ts`（barrel 加一行导出）

- [ ] **Step 1: 创建 src/components/numpadExpression.ts**

```typescript
/**
 * 数字键盘表达式编辑与求值（＋/－ 连续记账，如 20+15 直接出 35）。
 * 金额运算走 domain/decimal 的字符串十进制，无浮点误差。
 */
import { addDecimals, subtractDecimals } from '../domain/decimal';

export type NumpadKey =
  | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
  | '.' | '+' | '-' | 'backspace';

const MAX_INT_DIGITS = 9;
const MAX_DECIMAL_DIGITS = 2;

/** 处理一次按键，返回新表达式字符串。 */
export function pressKey(expr: string, key: NumpadKey): string {
  if (key === 'backspace') return expr.slice(0, -1);

  if (key === '+' || key === '-') {
    if (expr === '') return ''; // 金额恒正，不允许运算符开头
    const last = expr[expr.length - 1];
    if (last === '+' || last === '-') return expr.slice(0, -1) + key; // 替换运算符
    return expr + key;
  }

  // 当前数字段（最后一个运算符之后）
  const seg = expr.split(/[+-]/).pop() ?? '';

  if (key === '.') {
    if (seg.includes('.')) return expr;
    if (seg === '') return expr + '0.';
    return expr + '.';
  }

  // 数字键
  if (seg.includes('.')) {
    const decimals = seg.split('.')[1] ?? '';
    if (decimals.length >= MAX_DECIMAL_DIGITS) return expr;
  } else if (seg === '0') {
    return expr.slice(0, -1) + key; // 前导零替换
  }
  const intPart = seg.split('.')[0];
  if (intPart.length >= MAX_INT_DIGITS) return expr;
  return expr + key;
}

/** 求值：忽略尾随运算符；空表达式返回空串。 */
export function evaluateExpression(expr: string): string {
  const cleaned = expr.replace(/[+-]$/, '');
  if (!cleaned) return '';
  // 按符号切分：'20+15-5.5' → ['20', '+15', '-5.5']
  const parts = cleaned.split(/(?=[+-])/).filter(Boolean);
  let sum = '0';
  for (const part of parts) {
    if (part.startsWith('-')) sum = subtractDecimals(sum, part.slice(1));
    else if (part.startsWith('+')) sum = addDecimals([sum, part.slice(1)]);
    else sum = addDecimals([sum, part]);
  }
  return sum;
}
```

- [ ] **Step 2: 创建 src/domain/postingLegs.ts**

```typescript
/**
 * postings → 「双腿」解析（NumpadSheet 编辑/草稿预填用）。
 * 是 buildPostings 的逆操作：从一组分录推断方向、资金账户、目标账户与绝对值金额。
 */
import type { Posting } from './types';

export interface TransactionLegs {
  direction: 'expense' | 'income' | 'transfer';
  /** 资金腿（Assets/Liabilities）；转账时为转出方。 */
  sourceAccount: string;
  /** 分类账户（Expenses/Income）或转账的目标资金账户。 */
  targetAccount: string;
  /** 绝对值金额（无负号）。 */
  amount: string;
  currency: string;
}

function isAssetLike(account: string): boolean {
  return account.startsWith('Assets') || account.startsWith('Liabilities');
}

function abs(amount: string | undefined): string {
  return (amount ?? '').replace(/^-/, '');
}

/** 从分录推断双腿；无法识别（少于 2 条或无资金腿）时返回 null。 */
export function parsePostingsToLegs(postings: Posting[]): TransactionLegs | null {
  if (postings.length < 2) return null;
  const expensePosting = postings.find(p => p.account.startsWith('Expenses'));
  const incomePosting = postings.find(p => p.account.startsWith('Income'));
  const assetPosting = postings.find(p => isAssetLike(p.account));

  if (expensePosting) {
    return {
      direction: 'expense',
      sourceAccount: assetPosting?.account ?? '',
      targetAccount: expensePosting.account,
      amount: abs(expensePosting.amount),
      currency: expensePosting.currency ?? 'CNY',
    };
  }
  if (incomePosting) {
    return {
      direction: 'income',
      sourceAccount: assetPosting?.account ?? '',
      targetAccount: incomePosting.account,
      amount: abs(incomePosting.amount),
      currency: incomePosting.currency ?? 'CNY',
    };
  }
  // 转账：两条资金腿，第一条为转出
  if (isAssetLike(postings[0].account) && isAssetLike(postings[1].account)) {
    return {
      direction: 'transfer',
      sourceAccount: postings[0].account,
      targetAccount: postings[1].account,
      amount: abs(postings[0].amount),
      currency: postings[0].currency ?? 'CNY',
    };
  }
  return null;
}
```

- [ ] **Step 3: domain/index.ts 加 barrel 导出**

在 `src/domain/index.ts` 中追加（保持字母序附近的合适位置）：

```typescript
export * from './postingLegs';
```

- [ ] **Step 4: 验证**

Run: `npx vitest run tests/numpad.test.ts tests/transactionBuilder.test.ts` → Expected: 全部 PASS
Run: `npm run typecheck` → Expected: 无错误

> **评审修订（已实施，以实际代码为准）：** 代码评审后已修复并补回归测试——①`evaluateExpression` 剥段尾 `.`（`5.`/`5.+3` 不再崩溃）；②`pressKey` 整数 9 位上限仅对无小数段生效；③`parsePostingsToLegs` 对 `postings.length !== 2` 一律返回 null（拆分交易走高级模式）；④识别腿缺金额回退对侧资金腿；⑤转账按负号腿判定转出方；⑥currency 兜底顺序：识别腿 → 对侧腿 → CNY。

---

### Task 3: settingsStore 新字段 + 偏好开关 + i18n

**Files:**
- Modify: `src/store/settingsStore.ts`
- Modify: `src/app/settings/preferences.tsx`
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`

- [ ] **Step 1: settingsStore.ts 加 4 个字段（完全镜像 floatingBallEnabled 的模式）**

- State 接口加：

```typescript
  /** ＋按钮是否打开全局记账面板（P3 灰度，默认 false；P5 稳定后翻默认）。 */
  numpadGlobalEntry: boolean;
  setNumpadGlobalEntry: (enabled: boolean) => void;
  /** 上次记账的资金账户（NumpadSheet 默认选中）。 */
  lastUsedAccount: string | null;
  /** 上次使用的分类 id。 */
  lastUsedCategoryId: string | null;
  setLastUsedEntry: (account: string | null, categoryId: string | null) => void;
```

- DEFAULTS 加：`numpadGlobalEntry: false, lastUsedAccount: null, lastUsedCategoryId: null,`
- 持久化选择器（pick PersistableSettings 处，参照 `floatingBallEnabled: state.floatingBallEnabled,`）加：

```typescript
    numpadGlobalEntry: state.numpadGlobalEntry,
    lastUsedAccount: state.lastUsedAccount,
    lastUsedCategoryId: state.lastUsedCategoryId,
```

- 实现加：

```typescript
  setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }),
  setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
```

注意：`PersistableSettings` 类型若显式列字段，需同步加这 4 个字段（lastUsed* 为 string | null）。

- [ ] **Step 2: preferences.tsx 加「记账」Card（放在「安全设置」Card 之前）**

```tsx
        {/* 记账 */}
        <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>
```

组件顶部加 store 订阅：

```typescript
  const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
  const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
```

- [ ] **Step 3: i18n 加键（zh.ts 与 en.ts 必须同步加，i18n.test.ts 校验键位对等）**

zh.ts 加：

```typescript
  'settings.entryTitle': '记账',
  'settings.numpadEntry': '全局记账面板',
  'settings.numpadEntryDesc': '开启后，底部 ＋ 在任意页面直接弹出记账面板（灰度功能）',
  'numpad.from': '从',
  'numpad.to': '到',
  'numpad.outFrom': '转出',
  'numpad.transferTo': '转入',
  'numpad.swap': '互换',
  'numpad.today': '今天',
  'numpad.done': '完成',
  'numpad.more': '更多',
  'numpad.less': '收起',
  'numpad.allAccounts': '全部账户',
  'numpad.allCategories': '全部',
  'numpad.advanced': '高级模式',
  'numpad.noAccount': '去创建账户',
```

en.ts 加对应英文：

```typescript
  'settings.entryTitle': 'Entry',
  'settings.numpadEntry': 'Global entry panel',
  'settings.numpadEntryDesc': 'When enabled, the ＋ button opens the entry panel on any screen (beta)',
  'numpad.from': 'From',
  'numpad.to': 'To',
  'numpad.outFrom': 'From',
  'numpad.transferTo': 'To',
  'numpad.swap': 'Swap',
  'numpad.today': 'Today',
  'numpad.done': 'Done',
  'numpad.more': 'More',
  'numpad.less': 'Less',
  'numpad.allAccounts': 'All accounts',
  'numpad.allCategories': 'All',
  'numpad.advanced': 'Advanced mode',
  'numpad.noAccount': 'Create account',
```

- [ ] **Step 4: 验证**

Run: `npx vitest run tests/i18n.test.ts tests/stores.test.ts tests/automationStore.test.ts` → Expected: PASS
Run: `npm run typecheck` → Expected: 无错误

---

### Task 4: NumpadKeyboard + useOcrScan + numpadUiStore

**Files:**
- Create: `src/components/NumpadKeyboard.tsx`
- Create: `src/components/useOcrScan.ts`
- Create: `src/store/numpadUiStore.ts`

- [ ] **Step 1: 创建 src/components/NumpadKeyboard.tsx**

```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';

/** 键值：数字/小数点/加减走 numpadExpression；today/done 由宿主处理。 */
export type KeyboardKey = string;

interface NumpadKeyboardProps {
  onKey: (key: KeyboardKey) => void;
  todayLabel: string;
  doneLabel: 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 }: NumpadKeyboardProps) {
  const { theme } = useTheme();

  const renderKey = (key: KeyboardKey) => {
    if (key === 'done') {
      return (
        <Pressable
          key={key}
          onPress={() => onKey(key)}
          style={[styles.key, styles.doneKey, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.lg }]}
          accessibilityRole="button"
          accessibilityLabel={doneLabel}
        >
          <Text style={[styles.doneText, { color: theme.colors.fgInverse }]}>{doneLabel}</Text>
        </Pressable>
      );
    }
    if (key === 'backspace') {
      return (
        <Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel="backspace">
          <Ionicons name="backspace-outline" size={22} color={theme.colors.fgPrimary} />
        </Pressable>
      );
    }
    if (key === 'today') {
      return (
        <Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={todayLabel}>
          <Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{todayLabel}</Text>
        </Pressable>
      );
    }
    if (key === '+' || key === '-') {
      return (
        <Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
          <Text style={[styles.opText, { color: theme.colors.fgSecondary }]}>{key === '+' ? '＋' : '－'}</Text>
        </Pressable>
      );
    }
    return (
      <Pressable key={key} onPress={() => onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}>
        <Text style={[styles.digitText, { color: theme.colors.fgPrimary }]}>{key}</Text>
      </Pressable>
    );
  };

  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 },
  digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] },
  opText: { fontSize: 16, fontWeight: '600' },
  doneKey: { margin: 4 },
  doneText: { fontSize: 15, fontWeight: '700' },
});
```

- [ ] **Step 2: 创建 src/components/useOcrScan.ts**

```typescript
/**
 * OCR 拍照识账 hook（从旧 transaction/new.tsx 提取，逻辑不变）。
 * 返回 scan()：选图 → OcrProcessor 三级管线 → 解析出账单事件（或 null）。
 */
import { useState } from 'react';
import { Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { getNativeOcrModule, getNativeOcrBridge } from '../services/ocrBridge';
import { OcrProcessor } from '../domain/ocrProcessor';
import { useT } from '../i18n';

export type OcrEvent = NonNullable<Awaited<ReturnType<OcrProcessor['process']>>['event']>;

let processor: OcrProcessor | null = null;

export function useOcrScan() {
  const t = useT();
  const [status, setStatus] = useState('');

  const scan = async (): Promise<OcrEvent | null> => {
    try {
      const result = await ImagePicker.launchImageLibraryAsync({
        mediaTypes: ImagePicker.MediaTypeOptions.Images,
        allowsEditing: false,
        quality: 0.8,
        base64: true,
      });
      if (result.canceled || !result.assets?.[0]?.base64) return null;

      const nativeModule = getNativeOcrModule();
      if (nativeModule && !(await nativeModule.isReady())) {
        Alert.alert(t('ocr.notReady'));
        return null;
      }

      setStatus(t('ocr.scanning'));
      if (!processor) processor = new OcrProcessor(getNativeOcrBridge());
      const ocrResult = await processor.process(result.assets[0].base64);
      setStatus(ocrResult.event ? t('ocr.success') : t('ocr.failed'));
      return ocrResult.event;
    } catch (e) {
      setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
      return null;
    }
  };

  return { scan, status };
}
```

- [ ] **Step 3: 创建 src/store/numpadUiStore.ts**

```typescript
/**
 * 全局记账面板 UI 状态（＋按钮唤起 NumpadSheet 全局弹层）。
 */
import { create } from 'zustand';

export interface NumpadPrefill {
  editId?: string;
  draftJson?: string;
  autoOcr?: boolean;
}

interface NumpadUiState {
  visible: boolean;
  prefill: NumpadPrefill | null;
  open: (prefill?: NumpadPrefill) => void;
  close: () => void;
}

export const useNumpadUiStore = create<NumpadUiState>((set) => ({
  visible: false,
  prefill: null,
  open: (prefill) => set({ visible: true, prefill: prefill ?? null }),
  close: () => set({ visible: false, prefill: null }),
}));
```

- [ ] **Step 4: 验证**

Run: `npm run typecheck` → Expected: 无错误（若 OcrEvent 类型推导报错，读 src/domain/ocrProcessor.ts 确认 process 返回类型后如实报告，不要改 domain）

---

### Task 5: NumpadSheet 主组件

**Files:**
- Create: `src/components/NumpadSheet.tsx`

- [ ] **Step 1: 创建 src/components/NumpadSheet.tsx**

完整代码（此为 P3 核心，逐字实现）：

```tsx
/**
 * 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 { BottomSheet } from './BottomSheet';
import { Button } from './Button';
import { CategoryIcon } from './CategoryIcon';
import { CategoryPicker } from './CategoryPicker';
import { DatePickerField } from './DatePickerField';
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';

type Direction = 'expense' | 'income' | 'transfer';

interface NumpadSheetProps {
  presentation: 'page' | 'modal';
  editId?: string;
  draftJson?: string;
  autoOcr?: boolean;
  onClose?: () => void;
}

export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose }: 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 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] = 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 [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]);

  // ===== 预填：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);
        setSourceAccount(legs.sourceAccount);
        setTargetAccount(legs.targetAccount);
        if (legs.direction !== 'transfer') {
          const matched = categories.find(c => c.linkedAccount === legs.targetAccount);
          if (matched) setSelectedCategory(matched);
        }
      }
      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,
        })));
        // 无法双腿解析（拆分交易等）→ 直接进高级模式，避免丢腿
        if (!legs) setIsAdvanced(true);
      }
    } catch (e) {
      console.error('Failed to parse draftJson', e);
    }
  }, [draftJson, categories]);

  // ===== 预填：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);
      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,
    })));
    // 无法双腿解析（拆分交易等）→ 直接进高级模式，避免丢腿
    if (!legs && 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(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(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 formRef = useRef({ expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted });
  formRef.current = { expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted };
  useEffect(() => {
    if (presentation !== 'page') return;
    const unsubscribe = navigation.addListener('beforeRemove', (e) => {
      const s = formRef.current;
      const dirty =
        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() !== ''));
      if (!dirty || s.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, 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 = () => {
    if (presentation === 'modal') onClose?.();
    else setTimeout(() => router.back(), 600);
  };

  // ===== 提交 =====
  const submit = async () => {
    if (submitting) 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 (direction === 'transfer' && sourceAccount === targetAccount) {
        setStatus(t('transaction.sameAccountError'));
        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 {
        await buildAndSaveTransaction({
          date,
          amount: amt,
          payee: payee.trim() || undefined,
          narration: narration.trim() || defaultNarration,
          categoryAccount,
          sourceAccount,
          direction,
          currency,
          tags: tagNames,
        }, useLedgerStore.getState());
        setLastUsedEntry(sourceAccount || null, 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 ?? '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={onClose} hitSlop={8} accessibilityRole="button" accessibilityLabel="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); }}
                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(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')} />
          )}

          {/* 分录预览（常驻小字） */}
          {!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.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: 'CNY' }])} 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} />
            </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}>
            {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>

        {/* 全部分类 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 },
});
```

- [ ] **Step 2: 验证**

Run: `npm run typecheck` → Expected: 无错误
Run: `npm test` → Expected: 全部通过

> **评审修订（已实施，以实际代码为准）：** ①C1 币种全链路（currency 可写、预填回填 legs.currency、「更多」抽屉可编辑，i18n 新增 `transaction.formCurrency`）；②收入账户行 label 用 `numpad.to`；③submit 与提交按钮加 `isSubmitted` 防护（防 600ms 窗口重复提交）；④toggleAdvanced 兜底方向感知（Income/Expenses:Uncategorized）；⑤切转账清空非资金 targetAccount + 提交前资金账户校验（i18n 新增 `numpad.transferTargetError`）；⑥含 cost/price/metadata 的交易预填直接进高级模式；⑦转账保存不清空 lastUsedCategoryId；⑧sourceAccount 空校验；⑨OCR 状态 3s 自清 + 金额/摘要非空跳过。

---

### Task 6: transaction/new 重写为面板宿主

**Files:**
- Modify: `src/app/transaction/new.tsx`（完整重写）
- Modify: `src/app/_layout.tsx`（transaction/new 的 Stack.Screen 去掉原生 header）

- [ ] **Step 1: 完整重写 src/app/transaction/new.tsx**

```tsx
/**
 * 记一笔：NumpadSheet 整页宿主（P3）。
 * 编辑（editId）/ 草稿（draftJson）/ OCR（mode=ocr）预填复用同一面板。
 */
import React from 'react';
import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams } from 'expo-router';
import { useTheme } from '../../theme';
import { NumpadSheet } from '../../components/NumpadSheet';

export default function NewTransactionScreen() {
  const { theme } = useTheme();
  const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
  return (
    <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
      <NumpadSheet
        presentation="page"
        editId={editId}
        draftJson={draftJson}
        autoOcr={mode === 'ocr'}
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  page: { flex: 1 },
});
```

- [ ] **Step 2: _layout.tsx 中 transaction/new 的 Stack.Screen 改为 headerShown: false**

找到注册 `transaction/new` 的 `Stack.Screen`（当前是少数 headerShown 为 true 的），把 options 改为 `headerShown: false`（面板自带工具行，不需要原生标题栏；返回由系统手势/返回键处理）。

- [ ] **Step 3: 验证**

Run: `npm run typecheck && npm test` → Expected: 全部通过

---

### Task 7: 全局唤起（NumpadSheetHost + 根布局 + AppTabBar 分流）

**Files:**
- Create: `src/components/NumpadSheetHost.tsx`
- Modify: `src/app/_layout.tsx`（挂载 Host）
- Modify: `src/components/AppTabBar.tsx`（＋按开关分流）

- [ ] **Step 1: 创建 src/components/NumpadSheetHost.tsx**

```tsx
import React from 'react';
import { Modal, Pressable, StyleSheet } from 'react-native';
import { useTheme } from '../theme';
import { NumpadSheet } from './NumpadSheet';
import { useNumpadUiStore } from '../store/numpadUiStore';

/** 全局记账面板宿主：挂载于根布局，任意页面可唤起（不丢上下文）。 */
export function NumpadSheetHost() {
  const { theme } = useTheme();
  const visible = useNumpadUiStore(s => s.visible);
  const prefill = useNumpadUiStore(s => s.prefill);
  const close = useNumpadUiStore(s => s.close);
  return (
    <Modal visible={visible} transparent animationType="slide" onRequestClose={close}>
      <Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={close}>
        <Pressable style={styles.sheetWrap} onPress={e => e.stopPropagation()}>
          <NumpadSheet
            presentation="modal"
            editId={prefill?.editId}
            draftJson={prefill?.draftJson}
            autoOcr={prefill?.autoOcr}
            onClose={close}
          />
        </Pressable>
      </Pressable>
    </Modal>
  );
}

const styles = StyleSheet.create({
  overlay: { flex: 1, justifyContent: 'flex-end' },
  sheetWrap: { maxHeight: '94%' },
});
```

- [ ] **Step 2: _layout.tsx 挂载 Host**

在 `AppShell` 的 ready 分支渲染 `<Stack>` 的位置，Stack 之后加 `<NumpadSheetHost />`（仍在 ThemeProvider 内；若 ready 分支返回单个元素，用 `<>` Fragment 包裹）。顶部加 import：

```typescript
import { NumpadSheetHost } from '../components/NumpadSheetHost';
```

- [ ] **Step 3: AppTabBar.tsx ＋按开关分流**

- 顶部加：

```typescript
import { useSettingsStore } from '../store/settingsStore';
import { useNumpadUiStore } from '../store/numpadUiStore';
```

- 组件内加：

```typescript
  const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
  const openNumpad = useNumpadUiStore(s => s.open);
```

- ＋按钮 onPress 改为：

```typescript
onPress={() => { if (numpadGlobalEntry) openNumpad(); else router.navigate('/transaction/new'); }}
```

- [ ] **Step 4: 验证**

Run: `npm run typecheck && npm test` → Expected: 全部通过

---

### Task 8: SpeedDial 退役

**Files:**
- Modify: `src/app/(tabs)/index.tsx`
- Delete: `src/components/SpeedDial.tsx`

- [ ] **Step 1: index.tsx 移除 SpeedDial**

- 删除 `import { SpeedDial } from '../../components/SpeedDial';`
- 删除文件末尾的 `<SpeedDial actions={[...]} />` JSX（约 211–215 行）
- styles.content 的 `paddingBottom: 96` 改为 `paddingBottom: 40`（不再需要给 FAB 留位）
- 检查 `router` 变量是否仍被使用（其他地方有跳转则保留，否则清理未使用变量与 import）

- [ ] **Step 2: 删除 src/components/SpeedDial.tsx**

Run: `rm src/components/SpeedDial.tsx`
然后确认无引用残留：`grep -rn "SpeedDial" src/` → Expected: 无输出

- [ ] **Step 3: 验证**

Run: `npm run typecheck && npm test` → Expected: 全部通过

---

### Task 9: P3 全量验收

- [ ] **Step 1: 全量测试 + typecheck**

Run: `npm test` → Expected: 全部通过（含 numpad.test.ts 新测试）
Run: `npm run typecheck` → Expected: 无错误

- [ ] **Step 2: 审计**

Run: `grep -rn "SpeedDial" src/` → 无输出
Run: `grep -n "2196F3" src/` → 仅剩 `src/theme/palette.ts`（TAG_COLORS 定义处；transaction/new.tsx:219 的遗留已随重写消除）

- [ ] **Step 3: 手工走查（需设备/模拟器）**

Run: `npm run android`
走查清单（浅/暗双主题）：
1. ＋按钮（开关关闭时）→ 整页 NumpadSheet：方向/金额/账户 chip/分类快捷/键盘/连续计算（20+15=35）/完成入账
2. 设置→偏好→开启「全局记账面板」→ ＋在任意页面弹出 Modal 面板，记账后自动关闭，原页面状态保留
3. 转账：双账户行 + 互换；同账户提交报「账户相同」错误
4. 编辑：交易详情 → 编辑 → 双腿/金额/标签正确回填，保存后内容替换
5. OCR：工具行相机 → 选图 → 金额/方向/分类自动预填
6. 无 Assets 账户的账本：账户行显示「去创建账户」
7. 首页 SpeedDial 已消失；键盘「今天」键设置日期为当天

---

## 后续计划（不在本文件）

- **P4 四个 Tab 页**：首页待办条 / 交易时间线（FilterSheet）/ 报表 anchor 统一 / 我的 4 分组
- **P5 管理页全面模板化** + 清零审计 + `numpadGlobalEntry` 默认翻 true
