DriftLedger/src/components/LockScreen.tsx
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 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+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

261 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 },
});