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:
234
components/sync/QRScanner.tsx
Normal file
234
components/sync/QRScanner.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
// components/sync/QRScanner.tsx
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { CameraView, Camera, BarcodeScanningResult } from 'expo-camera';
|
||||
import { X } from 'lucide-react-native';
|
||||
|
||||
interface QRScannerProps {
|
||||
onScanned: (shareId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function QRScanner({ onScanned, onCancel }: QRScannerProps) {
|
||||
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||
const [scanned, setScanned] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
requestPermission();
|
||||
}, []);
|
||||
|
||||
const requestPermission = async () => {
|
||||
const { status } = await Camera.requestCameraPermissionsAsync();
|
||||
setHasPermission(status === 'granted');
|
||||
};
|
||||
|
||||
const handleBarCodeScanned = (result: BarcodeScanningResult) => {
|
||||
if (scanned) return;
|
||||
const { data } = result;
|
||||
|
||||
// Prevent duplicate scans of the same code
|
||||
if (lastScannedRef.current === data) return;
|
||||
lastScannedRef.current = data;
|
||||
|
||||
// Extract Share ID — expected format: "TL-XXXXXXXX" (8 alphanumeric chars)
|
||||
const match = data.match(/TL-[A-Z0-9]{8}/i);
|
||||
if (match) {
|
||||
setScanned(true);
|
||||
onScanned(match[0].toUpperCase());
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Invalid QR Code',
|
||||
'This QR code does not contain a valid TerritoryLog Share ID.',
|
||||
[
|
||||
{
|
||||
text: 'Try Again',
|
||||
onPress: () => {
|
||||
lastScannedRef.current = null;
|
||||
},
|
||||
},
|
||||
{ text: 'Cancel', onPress: onCancel },
|
||||
]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasPermission === null) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.message}>Requesting camera permission…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasPermission === false) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.message}>Camera access denied.</Text>
|
||||
<Text style={styles.submessage}>
|
||||
Enable camera access in your device settings to scan QR codes.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.cancelBtn} onPress={onCancel}>
|
||||
<Text style={styles.cancelText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ['qr'],
|
||||
}}
|
||||
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||
/>
|
||||
|
||||
{/* Overlay */}
|
||||
<View style={styles.overlay}>
|
||||
{/* Top */}
|
||||
<View style={styles.overlayTop}>
|
||||
<TouchableOpacity style={styles.closeBtn} onPress={onCancel}>
|
||||
<X size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.overlayTitle}>Scan Partner QR Code</Text>
|
||||
<Text style={styles.overlaySubtitle}>
|
||||
Point your camera at your partner's TerritoryLog QR code
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Scan frame row */}
|
||||
<View style={styles.frameRow}>
|
||||
<View style={styles.frameShade} />
|
||||
<View style={styles.frame}>
|
||||
{/* Corner markers */}
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
<View style={styles.frameShade} />
|
||||
</View>
|
||||
|
||||
{/* Bottom */}
|
||||
<View style={styles.overlayBottom}>
|
||||
<Text style={styles.overlayHint}>Share ID format: TL-XXXXXXXX</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const FRAME_SIZE = 240;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
backgroundColor: '#111827',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
gap: 12,
|
||||
},
|
||||
message: {
|
||||
color: '#F9FAFB',
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
submessage: {
|
||||
color: '#9CA3AF',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
cancelBtn: {
|
||||
marginTop: 16,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
backgroundColor: '#1A6B72',
|
||||
borderRadius: 12,
|
||||
},
|
||||
cancelText: {
|
||||
color: '#fff',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
overlayTop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
alignItems: 'center',
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: 24,
|
||||
gap: 8,
|
||||
},
|
||||
closeBtn: {
|
||||
position: 'absolute',
|
||||
top: 52,
|
||||
right: 24,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
overlayTitle: {
|
||||
color: '#fff',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
},
|
||||
overlaySubtitle: {
|
||||
color: '#D1D5DB',
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
},
|
||||
frameRow: {
|
||||
flexDirection: 'row',
|
||||
height: FRAME_SIZE,
|
||||
},
|
||||
frameShade: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
},
|
||||
frame: {
|
||||
width: FRAME_SIZE,
|
||||
height: FRAME_SIZE,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: '#1A6B72',
|
||||
borderWidth: 3,
|
||||
},
|
||||
cornerTL: { top: 0, left: 0, borderBottomWidth: 0, borderRightWidth: 0 },
|
||||
cornerTR: { top: 0, right: 0, borderBottomWidth: 0, borderLeftWidth: 0 },
|
||||
cornerBL: { bottom: 0, left: 0, borderTopWidth: 0, borderRightWidth: 0 },
|
||||
cornerBR: { bottom: 0, right: 0, borderTopWidth: 0, borderLeftWidth: 0 },
|
||||
overlayBottom: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
},
|
||||
overlayHint: {
|
||||
color: '#9CA3AF',
|
||||
fontSize: 13,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user