- 切换到 expo-router 文件路由,删除 App.tsx - 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取), notification-listener, screenshot-monitor, sms-receiver - 实现核心领域逻辑:billPipeline (账单流水), dedup (去重), transferRecognizer (转账识别), ruleEngine + categories (双轨制分类), budgets, creditCards, recurring, sync, ocrProcessor - 增强 ledger.ts:支持 balance assertion, option, pad/note 指令, posting 级 metadata, cost/price 解析 - 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图, 分类管理, 信用卡, 定期交易, 规则管理 - 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore, metadataStore, automationStore + 持久化 - 新增 AI 功能:chatAssistant, monthlySummary, voiceInput - 实现多端同步:gitSync, webdavSync, icloudSync - 新增主题系统 (tokens/presets) 和 i18n (zh/en) - 添加 30+ 单元测试覆盖核心逻辑
261 lines
9.4 KiB
TypeScript
261 lines
9.4 KiB
TypeScript
import React, { useState, useCallback, useEffect } from 'react';
|
||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import * as SecureStore from 'expo-secure-store';
|
||
import { useTheme } from '../theme';
|
||
import { useT } from '../i18n';
|
||
import { authenticate, RealAuthProvider, hashPin, verifyPin } from '../services/security';
|
||
|
||
/** SecureStore 中存储 PIN 哈希的 key。 */
|
||
const PIN_HASH_KEY = 'app_pin_hash';
|
||
|
||
interface LockScreenProps {
|
||
onUnlock: () => void;
|
||
}
|
||
|
||
export function LockScreen({ onUnlock }: LockScreenProps) {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [retrying, setRetrying] = useState(false);
|
||
const [showPinInput, setShowPinInput] = useState(false);
|
||
const [pin, setPin] = useState('');
|
||
const [hasPin, setHasPin] = useState(true); // 是否已设置过 PIN
|
||
|
||
const handleAuth = useCallback(async () => {
|
||
setRetrying(true);
|
||
setError(null);
|
||
const provider = new RealAuthProvider();
|
||
const result = await authenticate(provider);
|
||
if (result.success) {
|
||
onUnlock();
|
||
} else {
|
||
setError(t('lockScreen.authFail'));
|
||
setShowPinInput(true);
|
||
}
|
||
setRetrying(false);
|
||
}, [onUnlock, t]);
|
||
|
||
useEffect(() => {
|
||
// 检查是否已设置 PIN
|
||
SecureStore.getItemAsync(PIN_HASH_KEY).then(hash => {
|
||
setHasPin(hash !== null);
|
||
});
|
||
// 尝试生物识别
|
||
handleAuth();
|
||
}, []);
|
||
|
||
const handleKeyPress = async (num: string) => {
|
||
setError(null);
|
||
if (pin.length < 4) {
|
||
const nextPin = pin + num;
|
||
setPin(nextPin);
|
||
if (nextPin.length === 4) {
|
||
// 验证 PIN
|
||
const storedHash = await SecureStore.getItemAsync(PIN_HASH_KEY);
|
||
if (storedHash) {
|
||
const valid = await verifyPin(nextPin, storedHash);
|
||
if (valid) {
|
||
onUnlock();
|
||
} else {
|
||
setError(t('lockScreen.pinFail'));
|
||
setTimeout(() => setPin(''), 500);
|
||
}
|
||
} else {
|
||
// 未设置 PIN,直接放行(不应发生——首次设置在 preferences 页)
|
||
onUnlock();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleBackspace = () => {
|
||
setPin(prev => prev.slice(0, -1));
|
||
};
|
||
|
||
const handleClear = () => {
|
||
setPin('');
|
||
};
|
||
|
||
const renderKey = (val: string) => (
|
||
<Pressable
|
||
key={val}
|
||
onPress={() => handleKeyPress(val)}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={val}
|
||
style={({ pressed }) => [
|
||
styles.keyBtn,
|
||
{
|
||
backgroundColor: theme.colors.bgTertiary,
|
||
borderColor: theme.colors.border,
|
||
opacity: pressed ? 0.6 : 1,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={[styles.keyText, { color: theme.colors.fgPrimary }]}>{val}</Text>
|
||
</Pressable>
|
||
);
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top', 'bottom']}>
|
||
<View style={styles.content}>
|
||
{!showPinInput ? (
|
||
<View style={styles.centerSection}>
|
||
<View style={[styles.iconWrap, { backgroundColor: theme.colors.bgTertiary }]}>
|
||
<Ionicons name="lock-closed" size={48} color={theme.colors.accent} />
|
||
</View>
|
||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, marginTop: 16 }]}>
|
||
{t('lockScreen.locked')}
|
||
</Text>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary, marginTop: 8, textAlign: 'center' }]}>
|
||
{t('lockScreen.bioPrompt')}
|
||
</Text>
|
||
{error && (
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginTop: 12 }]}>
|
||
{error}
|
||
</Text>
|
||
)}
|
||
<View style={styles.actionRow}>
|
||
<Pressable
|
||
onPress={handleAuth}
|
||
disabled={retrying}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={retrying ? t('lockScreen.authenticating') : t('lockScreen.clickUnlock')}
|
||
style={({ pressed }) => [
|
||
styles.unlockBtn,
|
||
{ backgroundColor: theme.colors.accent, opacity: pressed || retrying ? 0.6 : 1 },
|
||
]}
|
||
>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgInverse, fontWeight: '700' }]}>
|
||
{retrying ? t('lockScreen.authenticating') : t('lockScreen.clickUnlock')}
|
||
</Text>
|
||
</Pressable>
|
||
{hasPin && (
|
||
<Pressable
|
||
onPress={() => setShowPinInput(true)}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('lockScreen.usePin')}
|
||
style={({ pressed }) => [
|
||
styles.unlockBtn,
|
||
{ backgroundColor: theme.colors.bgTertiary, borderWidth: 1, borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 },
|
||
]}
|
||
>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||
{t('lockScreen.usePin')}
|
||
</Text>
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<View style={styles.pinSection}>
|
||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, marginBottom: 8 }]}>
|
||
{t('lockScreen.enterPin')}
|
||
</Text>
|
||
|
||
{/* Dots */}
|
||
<View style={styles.dotsRow}>
|
||
{[0, 1, 2, 3].map(i => (
|
||
<View
|
||
key={i}
|
||
style={[
|
||
styles.dot,
|
||
{
|
||
backgroundColor: pin.length > i ? theme.colors.accent : theme.colors.border,
|
||
},
|
||
]}
|
||
/>
|
||
))}
|
||
</View>
|
||
|
||
{error && (
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.error, marginBottom: 16 }]}>
|
||
{error}
|
||
</Text>
|
||
)}
|
||
|
||
{/* Keypad Grid */}
|
||
<View style={styles.grid}>
|
||
<View style={styles.gridRow}>
|
||
{renderKey('1')}
|
||
{renderKey('2')}
|
||
{renderKey('3')}
|
||
</View>
|
||
<View style={styles.gridRow}>
|
||
{renderKey('4')}
|
||
{renderKey('5')}
|
||
{renderKey('6')}
|
||
</View>
|
||
<View style={styles.gridRow}>
|
||
{renderKey('7')}
|
||
{renderKey('8')}
|
||
{renderKey('9')}
|
||
</View>
|
||
<View style={styles.gridRow}>
|
||
<Pressable onPress={handleClear} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel={t('lockScreen.clear')}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||
{t('lockScreen.clear')}
|
||
</Text>
|
||
</Pressable>
|
||
{renderKey('0')}
|
||
<Pressable onPress={handleBackspace} style={styles.utilityBtn} accessibilityRole="button" accessibilityLabel="Backspace">
|
||
<Ionicons name="backspace-outline" size={24} color={theme.colors.fgSecondary} />
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
|
||
<Pressable
|
||
onPress={() => {
|
||
setShowPinInput(false);
|
||
setError(null);
|
||
setPin('');
|
||
}}
|
||
style={styles.switchBackBtn}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={t('lockScreen.useBio')}
|
||
>
|
||
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
|
||
{t('lockScreen.useBio')}
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
/** 导出 PIN 设置/验证辅助函数供 settings 页面使用。 */
|
||
export async function setPin(pin: string): Promise<void> {
|
||
const hash = await hashPin(pin);
|
||
await SecureStore.setItemAsync(PIN_HASH_KEY, hash);
|
||
}
|
||
|
||
export async function clearPin(): Promise<void> {
|
||
await SecureStore.deleteItemAsync(PIN_HASH_KEY);
|
||
}
|
||
|
||
export async function hasPinSet(): Promise<boolean> {
|
||
const hash = await SecureStore.getItemAsync(PIN_HASH_KEY);
|
||
return hash !== null;
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
page: { flex: 1 },
|
||
content: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
||
centerSection: { alignItems: 'center', justifyContent: 'center' },
|
||
iconWrap: { width: 96, height: 96, borderRadius: 48, alignItems: 'center', justifyContent: 'center' },
|
||
actionRow: { flexDirection: 'row', gap: 12, marginTop: 32 },
|
||
unlockBtn: { paddingVertical: 14, paddingHorizontal: 28, borderRadius: 12, minWidth: 120, alignItems: 'center' },
|
||
pinSection: { alignItems: 'center', width: '100%' },
|
||
dotsRow: { flexDirection: 'row', gap: 20, marginBottom: 24 },
|
||
dot: { width: 16, height: 16, borderRadius: 8 },
|
||
grid: { width: '80%', gap: 12 },
|
||
gridRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
|
||
keyBtn: { flex: 1, height: 60, borderRadius: 30, borderWidth: 1, alignItems: 'center', justifyContent: 'center' },
|
||
keyText: { fontSize: 24, fontWeight: '700' },
|
||
utilityBtn: { flex: 1, height: 60, alignItems: 'center', justifyContent: 'center' },
|
||
switchBackBtn: { marginTop: 32, padding: 8 },
|
||
});
|