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

View 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,
},
});