- 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
131 lines
2.7 KiB
TypeScript
131 lines
2.7 KiB
TypeScript
// 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',
|
|
},
|
|
});
|