DriftLedger/docs/ui-redesign-p3-plan.md
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

57 KiB
Raw Blame History

UI 重设计 P3录入闭环NumpadSheetImplementation 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 预填)与全局 ModalNumpadSheetHost 挂根布局);落账仍走 buildAndSaveTransaction()/editTransactiondomain 写路径零改动。

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

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

前置状态: P1/P2 已完成547 测试全绿BottomSheet/DatePickerField/CategoryIcon/CategoryPicker 已可用)。


Task 1: 纯逻辑失败测试

Files:

  • Create: tests/numpad.test.ts

  • Step 1: 新建 tests/numpad.test.ts

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.tsbarrel 加一行导出)

  • Step 1: 创建 src/components/numpadExpression.ts

/**
 * 数字键盘表达式编辑与求值(+/ 连续记账,如 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
/**
 * 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 中追加(保持字母序附近的合适位置):

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 位上限仅对无小数段生效;③parsePostingsToLegspostings.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.tssrc/i18n/en.ts

  • Step 1: settingsStore.ts 加 4 个字段(完全镜像 floatingBallEnabled 的模式)

  • State 接口加:

  /** 按钮是否打开全局记账面板P3 灰度,默认 falseP5 稳定后翻默认)。 */
  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,)加:
    numpadGlobalEntry: state.numpadGlobalEntry,
    lastUsedAccount: state.lastUsedAccount,
    lastUsedCategoryId: state.lastUsedCategoryId,
  • 实现加:
  setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }),
  setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),

注意:PersistableSettings 类型若显式列字段,需同步加这 4 个字段lastUsed* 为 string | null

  • Step 2: preferences.tsx 加「记账」Card放在「安全设置」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>

组件顶部加 store 订阅:

  const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
  const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
  • Step 3: i18n 加键zh.ts 与 en.ts 必须同步加i18n.test.ts 校验键位对等)

zh.ts 加:

  '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 加对应英文:

  '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

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

/** 键值:数字/小数点/加减走 numpadExpressiontoday/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
/**
 * 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
/**
 * 全局记账面板 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 核心,逐字实现):

/**
 * 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.tsxtransaction/new 的 Stack.Screen 去掉原生 header

  • Step 1: 完整重写 src/app/transaction/new.tsx

/**
 * 记一笔NumpadSheet 整页宿主P3 * 编辑editId/ 草稿draftJson/ OCRmode=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/newStack.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

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

import { NumpadSheetHost } from '../components/NumpadSheetHost';
  • Step 3: AppTabBar.tsx +按开关分流

  • 顶部加:

import { useSettingsStore } from '../store/settingsStore';
import { useNumpadUiStore } from '../store/numpadUiStore';
  • 组件内加:
  const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
  const openNumpad = useNumpadUiStore(s => s.open);
  • +按钮 onPress 改为:
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约 211215 行)

  • 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.tsTAG_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