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:
130
components/ui/PinPad.tsx
Normal file
130
components/ui/PinPad.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user