- 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
235 lines
5.8 KiB
TypeScript
235 lines
5.8 KiB
TypeScript
// 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,
|
|
},
|
|
});
|