feat: Sprint 5 — PIN lock, biometric auth, sync screen, QR code, merge logic

- lib/pinService.ts: PIN storage/verification via expo-secure-store
- lib/syncService.ts: sync abstraction with full merge logic (mDNS/TCP stubbed)
- app/lock.tsx: full-screen PIN lock with biometric support
- components/ui/PinPad.tsx: reusable 6-digit PIN pad component
- components/sync/QRScanner.tsx: QR code scanner via expo-camera
- app/(tabs)/sync.tsx: Sync tab with QR display, partner input, history
- app/sync-progress.tsx: animated sync progress screen with summary
- app/(tabs)/settings.tsx: full settings with PIN setup, security, data sections
- app/(tabs)/_layout.tsx: added Sync tab (RefreshCw icon)
- app/_layout.tsx: AppState listener locks app after 60s background
- package.json: added expo-secure-store, react-native-qrcode-svg
This commit is contained in:
2026-02-18 19:57:35 +08:00
parent 123b21f4db
commit 6fe6ebef13
12 changed files with 18385 additions and 22 deletions

130
components/ui/PinPad.tsx Normal file
View File

@@ -0,0 +1,130 @@
// components/ui/PinPad.tsx
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Vibration,
} from 'react-native';
import { Delete } from 'lucide-react-native';
interface PinPadProps {
pin: string;
onPinChange: (pin: string) => void;
maxLength?: number;
}
const KEYS = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['', '0', 'del'],
];
export default function PinPad({
pin,
onPinChange,
maxLength = 6,
}: PinPadProps) {
const handleKey = (key: string) => {
if (key === 'del') {
onPinChange(pin.slice(0, -1));
} else if (key !== '' && pin.length < maxLength) {
Vibration.vibrate(30);
onPinChange(pin + key);
}
};
return (
<View style={styles.container}>
{/* PIN dots */}
<View style={styles.dotsRow}>
{Array.from({ length: maxLength }).map((_, i) => (
<View
key={i}
style={[styles.dot, i < pin.length && styles.dotFilled]}
/>
))}
</View>
{/* Keypad */}
{KEYS.map((row, ri) => (
<View key={ri} style={styles.row}>
{row.map((key, ki) => {
if (key === '') {
return <View key={ki} style={styles.keyEmpty} />;
}
if (key === 'del') {
return (
<TouchableOpacity
key={ki}
style={styles.key}
onPress={() => handleKey('del')}
activeOpacity={0.6}
>
<Delete size={22} color="#E5E7EB" />
</TouchableOpacity>
);
}
return (
<TouchableOpacity
key={ki}
style={styles.key}
onPress={() => handleKey(key)}
activeOpacity={0.6}
>
<Text style={styles.keyText}>{key}</Text>
</TouchableOpacity>
);
})}
</View>
))}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
gap: 24,
},
dotsRow: {
flexDirection: 'row',
gap: 16,
marginBottom: 8,
},
dot: {
width: 16,
height: 16,
borderRadius: 8,
borderWidth: 2,
borderColor: '#9CA3AF',
backgroundColor: 'transparent',
},
dotFilled: {
backgroundColor: '#1A6B72',
borderColor: '#1A6B72',
},
row: {
flexDirection: 'row',
gap: 20,
},
key: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(255,255,255,0.08)',
justifyContent: 'center',
alignItems: 'center',
},
keyEmpty: {
width: 72,
height: 72,
},
keyText: {
fontSize: 26,
fontWeight: '500',
color: '#F9FAFB',
},
});