src/components/EditRecordModal.tsx
Rama main · réplica de lectura del repositorio original.
import React, { useEffect, useRef, useState } from 'react';
import {
BackHandler,
DeviceEventEmitter,
Keyboard,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { COLORS, FONT_SIZE, LEDGER_EVENTS, RADIUS, SPACING, getCategories } from '../constants';
import { updateRecord } from '../database/ledgerDB';
import { mergeDateKeepTime, formatMoney } from '../utils/dateUtils';
import {
appendKey,
evaluateAmount,
hasOperator,
isValidAmount,
toAmount,
type PadKey,
} from '../utils/moneyUtils';
import { hapticError, hapticLight, hapticSuccess } from '../utils/haptics';
import CategorySelector from './CategorySelector';
import DateField from './DateField';
import NumberPad from './NumberPad';
import type { LedgerRecord, RecordType } from '../types';
interface Props {
visible: boolean;
record: LedgerRecord | null; // 待编辑记录(null 时不渲染)
onClose: () => void;
}
// 编辑记录全屏页(v0.10:明细页点击记录进入)
// v0.11.4 根因修复:弃用 RNModal——Android 上它是独立 Dialog 窗口(RN 内部强制
// disableEdgeToEdge + 已废弃的 ADJUST_RESIZE),与本项目 edge-to-edge 的 Activity
// 坐标系不一致,导致卡底数字键盘 Pressable 触摸失灵/被 IME 遮挡(备注靠系统 IME
// 输入可幸免,金额必须触摸窗内按键故"无法修改")。改为渲染在 LedgerScreen 的
// Activity 视图层级内的绝对定位覆盖层:触摸与键盘避让(adjustResize)行为与首页记账卡完全一致。
export default function EditRecordModal({ visible, record, onClose }: Props) {
const [type, setType] = useState<RecordType>('expense');
const [amountStr, setAmountStr] = useState('');
const [category, setCategory] = useState('food');
const [note, setNote] = useState('');
const [showNote, setShowNote] = useState(false);
const [reimbursable, setReimbursable] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// 仅用户主动点「+ 添加备注」才聚焦弹键盘;打开页面时不自动弹
const [noteFocused, setNoteFocused] = useState(false);
// 系统键盘是否弹起:Android 15+ edge-to-edge 下 adjustResize 不再压缩窗口,
// 固定数字键盘(约250px)+备注行会整体压在 IME 之下,滚动无法救——改为一抬键盘就收起数字键盘
// (随手记同款交互),键盘落下后自动恢复。
const [keyboardUp, setKeyboardUp] = useState(false);
const [keyboardH, setKeyboardH] = useState(0);
// 记录日期(v0.11.22:编辑时可改,保存后明细/统计按新日期归档)
const [recordDate, setRecordDate] = useState('');
const scrollRef = useRef<ScrollView>(null);
// 打开时用记录内容初始化(金额转字符串供键盘继续编辑)
useEffect(() => {
if (visible && record) {
setType(record.type);
setAmountStr(String(record.amount));
setCategory(record.category);
setNote(record.note);
setShowNote(!!record.note);
setNoteFocused(false);
setReimbursable(record.reimbursable);
setRecordDate(record.date);
setSaving(false);
setError('');
}
}, [visible, record]);
// 系统返回键:编辑页打开时关闭编辑页(此前由 RNModal onRequestClose 承担)
useEffect(() => {
if (!visible) return;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
onClose();
return true;
});
return () => sub.remove();
}, [visible, onClose]);
// 备注弹键盘时的"手动 adjustResize":Android 15+ edge-to-edge 下系统不再压缩窗口,
// 改为给滚动内容补 paddingBottom=键盘高度并滚到底,把备注行精确顶到 IME 上沿;
// 同时收起自定义数字键盘(其位置正被系统键盘占据)。iOS 同样适用。
useEffect(() => {
if (!visible) {
setKeyboardUp(false);
setKeyboardH(0);
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
const show = Keyboard.addListener('keyboardDidShow', (e) => {
setKeyboardUp(true);
setKeyboardH(e.endCoordinates?.height ?? 0);
if (timer) clearTimeout(timer);
timer = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 80);
});
const hide = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardUp(false);
setKeyboardH(0);
});
return () => {
if (timer) clearTimeout(timer);
show.remove();
hide.remove();
};
}, [visible]);
// 切换收支类型:分类切到该类型第一个;待报销仅对支出有意义,切收入时重置
const handleTypeChange = (next: RecordType) => {
if (next === type) return;
setType(next);
setCategory(getCategories(next)[0]?.key ?? 'other');
if (next === 'income') setReimbursable(false);
hapticLight();
};
const handleKey = (key: PadKey) => {
setAmountStr((prev) => appendKey(prev, key));
hapticLight();
};
const handleSave = async () => {
if (!record || !isValidAmount(amountStr)) return;
const amount = toAmount(amountStr);
try {
setSaving(true);
await updateRecord(record.id, {
amount,
category,
type,
note: note.trim(),
reimbursable,
date: recordDate || record.date,
timestamp: mergeDateKeepTime(record.timestamp, recordDate || record.date),
});
hapticSuccess();
// 列表刷新 + App 层 debounce 自动同步(与新增记录同一链路)
DeviceEventEmitter.emit(LEDGER_EVENTS.RECORDED);
onClose();
} catch {
hapticError();
setSaving(false);
// v0.11 修复:保存失败在页内给出文案提示(此前仅震动,用户以为已保存)
setError('保存失败,请重试');
}
};
const canSave = isValidAmount(amountStr) && !saving;
// 计算器友好展示 + 实时「= 结果」预览(与记账页一致)
const displayAmount =
amountStr === '' ? '0.00' : amountStr.replace(/\*/g, '×').replace(/\//g, '÷').replace(/-/g, '−');
const showPreview = hasOperator(amountStr);
const previewAmount = showPreview ? evaluateAmount(amountStr) : 0;
if (!visible || !record) return null;
// v0.11.5 结构修正:不再用"绝对定位覆盖层"(absolute 子级会无视父级 paddingTop 顶进状态栏、
// 底部安全区重复计算顶出幽灵空隙,且叠层触摸在部分机型仍不稳)。
// 改为与 App.tsx 切 Tab 同款的"并列页 + display 切换":本组件作为宿主页面根内的普通页面渲染,
// 触摸/安全区行为与首页记账卡完全一致(宿主负责两页互斥显示:明细页、统计页分类明细)。
return (
<View style={styles.page}>
{/* 顶部导航栏:取消 / 标题 / 保存 */}
<View style={styles.navBar}>
<Pressable
onPress={onClose}
hitSlop={8}
style={styles.navBtn}
accessibilityRole="button"
accessibilityLabel="取消编辑"
>
<Text style={styles.navCancel}>取消</Text>
</Pressable>
<Text style={styles.navTitle}>编辑记录</Text>
<Pressable
onPress={handleSave}
disabled={!canSave}
hitSlop={8}
style={styles.navBtn}
accessibilityRole="button"
accessibilityLabel="保存修改"
>
<Text style={[styles.navSave, !canSave && styles.navSaveDisabled]}>保存</Text>
</Pressable>
</View>
{/* 保存失败提示(页内可见) */}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
{/* 记账卡片结构:上半内容与数字键盘连为一张卡,键盘固定卡底(与 HomeScreen 一致) */}
<View style={styles.card}>
<ScrollView
ref={scrollRef}
style={styles.scroll}
contentContainerStyle={[styles.content, keyboardUp && { paddingBottom: keyboardH + SPACING.sm }]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
>
{/* 收支切换 */}
<View style={styles.typeSwitch}>
{(['expense', 'income'] as RecordType[]).map((t) => (
<Pressable
key={t}
style={[
styles.typeBtn,
type === t && (t === 'expense' ? styles.typeBtnExpense : styles.typeBtnIncome),
]}
onPress={() => handleTypeChange(t)}
accessibilityRole="button"
accessibilityLabel={t === 'expense' ? '改为支出' : '改为收入'}
accessibilityState={{ selected: type === t }}
>
<Text style={[styles.typeText, type === t && styles.typeTextActive]}>
{t === 'expense' ? '支出' : '收入'}
</Text>
</Pressable>
))}
</View>
{/* 记录日期:可改到过去的某天(时分秒保留原记录,同日排序不乱) */}
<View style={styles.dateRow}>
<DateField
value={recordDate || record.date}
label="记录日期"
onChange={(d) => {
setRecordDate(d);
hapticLight();
}}
/>
</View>
{/* 金额区 */}
<View style={styles.amountZone}>
<View style={styles.amountRow}>
<Text
style={[styles.amountSymbol, { color: type === 'expense' ? COLORS.expense : COLORS.income }]}
>
¥
</Text>
<Text
style={[styles.amountInput, amountStr === '' && styles.amountPlaceholder]}
adjustsFontSizeToFit
numberOfLines={1}
accessibilityLabel={`金额 ${displayAmount}元`}
>
{displayAmount}
</Text>
</View>
<View style={styles.calcSlot}>
{showPreview ? <Text style={styles.calcPreview}>= ¥{formatMoney(previewAmount)}</Text> : null}
</View>
</View>
{/* 分类选择(横向滑动一行) */}
<CategorySelector
categories={getCategories(type)}
selected={category}
onSelect={(key) => {
setCategory(key);
hapticLight();
}}
/>
{/* 备注 + 待报销 */}
<View style={styles.optionRow}>
{showNote ? (
<TextInput
style={styles.noteInput}
placeholder="备注(可选)"
placeholderTextColor={COLORS.textTertiary}
value={note}
onChangeText={setNote}
maxLength={30}
autoFocus={noteFocused}
onFocus={() => {
// 与首页一致:聚焦时滚到底,输入框位于系统键盘上方(adjustResize 生效)
scrollRef.current?.scrollToEnd({ animated: true });
}}
/>
) : (
<Pressable
style={styles.noteToggle}
onPress={() => {
setShowNote(true);
setNoteFocused(true);
}}
accessibilityRole="button"
accessibilityLabel="添加备注"
>
<Text style={styles.noteToggleText}>{note ? `备注:${note}` : '+ 添加备注'}</Text>
</Pressable>
)}
{type === 'expense' ? (
<Pressable
style={[styles.reimburseBtn, reimbursable && styles.reimburseBtnOn]}
onPress={() => {
setReimbursable((v) => !v);
hapticLight();
}}
accessibilityRole="button"
accessibilityLabel="标记待报销"
accessibilityState={{ selected: reimbursable }}
>
<Text style={[styles.reimburseText, reimbursable && styles.reimburseTextOn]}>待报销</Text>
</Pressable>
) : null}
</View>
</ScrollView>
{/* 数字键盘:固定在卡片底部;系统键盘弹起时让位(此时正被 IME 占据,收起可让备注行回落到可见区) */}
{!keyboardUp ? (
<View style={styles.padDock}>
<NumberPad onKey={handleKey} />
</View>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
// 普通并列页:由宿主用 display 与列表视图互斥切换(同 App 切 Tab 的做法)
page: {
flex: 1,
backgroundColor: COLORS.background,
},
navBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.xs,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: COLORS.border,
},
navBtn: {
minWidth: 56,
alignItems: 'center',
},
navCancel: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
fontWeight: '600',
paddingVertical: 8,
},
navTitle: {
fontSize: FONT_SIZE.lg,
fontWeight: '800',
color: COLORS.text,
},
navSave: {
fontSize: FONT_SIZE.md,
color: COLORS.accent,
fontWeight: '700',
paddingVertical: 8,
},
navSaveDisabled: {
opacity: 0.4,
},
errorText: {
color: COLORS.danger,
fontSize: FONT_SIZE.sm,
fontWeight: '600',
textAlign: 'center',
paddingVertical: SPACING.xs,
},
// ===== 记账卡片(与 HomeScreen 记账卡一致) =====
card: {
flex: 1,
backgroundColor: COLORS.surface,
borderRadius: RADIUS.lg,
borderWidth: 1,
borderColor: COLORS.border,
marginHorizontal: SPACING.md,
marginBottom: SPACING.sm,
overflow: 'hidden',
},
scroll: {
flex: 1,
},
content: {
paddingHorizontal: SPACING.md,
paddingTop: SPACING.sm,
paddingBottom: SPACING.sm,
gap: SPACING.sm,
flexGrow: 1,
},
typeSwitch: {
flexDirection: 'row',
backgroundColor: COLORS.bgAlt,
borderRadius: RADIUS.pill,
padding: 3,
},
typeBtn: {
flex: 1,
paddingVertical: SPACING.sm,
borderRadius: RADIUS.pill,
alignItems: 'center',
},
typeBtnExpense: {
backgroundColor: COLORS.expense,
},
typeBtnIncome: {
backgroundColor: COLORS.income,
},
typeText: {
fontSize: FONT_SIZE.md,
color: COLORS.textSecondary,
fontWeight: '600',
},
typeTextActive: {
color: COLORS.white,
fontWeight: '700',
},
// 日期胶囊单独一行、靠左
dateRow: {
flexDirection: 'row',
alignItems: 'center',
},
amountZone: {
flex: 1,
justifyContent: 'center',
minHeight: 64,
},
amountRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
amountSymbol: {
fontSize: FONT_SIZE.xl,
fontWeight: '700',
marginRight: 4,
},
amountInput: {
fontSize: FONT_SIZE.display,
fontWeight: '800',
color: COLORS.text,
maxWidth: '80%',
},
amountPlaceholder: {
color: COLORS.borderSubtle,
},
calcSlot: {
height: 34,
alignItems: 'center',
justifyContent: 'center',
},
calcPreview: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
textAlign: 'center',
},
optionRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
noteToggle: {
paddingVertical: 2,
flexShrink: 1,
},
noteToggleText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textTertiary,
},
noteInput: {
flex: 1,
backgroundColor: COLORS.bgAlt,
borderRadius: RADIUS.sm,
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.sm,
fontSize: FONT_SIZE.md,
color: COLORS.text,
},
reimburseBtn: {
paddingHorizontal: SPACING.md,
paddingVertical: 6,
borderRadius: RADIUS.pill,
borderWidth: 1,
borderColor: COLORS.border,
marginLeft: SPACING.sm,
},
reimburseBtnOn: {
// v0.10.2:选中效果与「连记」统一——主色 9% 透明底 + 主色边框 + 深色文字
backgroundColor: `${COLORS.warningBorder}18`,
borderColor: COLORS.warningBorder,
},
reimburseText: {
fontSize: FONT_SIZE.sm,
color: COLORS.textSecondary,
fontWeight: '600',
},
reimburseTextOn: {
color: COLORS.warningText,
},
padDock: {
paddingHorizontal: SPACING.md,
paddingTop: SPACING.xs,
paddingBottom: SPACING.sm,
backgroundColor: COLORS.surface,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: COLORS.border,
},
});