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:
205
app/lock.tsx
Normal file
205
app/lock.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
// app/lock.tsx — Full-screen PIN lock screen
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { Shield } from 'lucide-react-native';
|
||||
import PinPad from '@/components/ui/PinPad';
|
||||
import { verifyPin, disablePin } from '@/lib/pinService';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import { useUserStore } from '@/store/useUserStore';
|
||||
|
||||
export default function LockScreen() {
|
||||
const [pin, setPin] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
||||
const clearUser = useUserStore((s) => s.clearUser);
|
||||
|
||||
useEffect(() => {
|
||||
checkBiometric();
|
||||
}, []);
|
||||
|
||||
const checkBiometric = async () => {
|
||||
try {
|
||||
const hardware = await LocalAuthentication.hasHardwareAsync();
|
||||
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
setBiometricAvailable(hardware && enrolled);
|
||||
} catch {
|
||||
setBiometricAvailable(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBiometric = useCallback(async () => {
|
||||
try {
|
||||
const result = await LocalAuthentication.authenticateAsync({
|
||||
promptMessage: 'Unlock TerritoryLog',
|
||||
fallbackLabel: 'Use PIN',
|
||||
cancelLabel: 'Cancel',
|
||||
});
|
||||
if (result.success) {
|
||||
router.replace('/(tabs)');
|
||||
} else {
|
||||
setError('Biometric authentication failed. Use your PIN.');
|
||||
}
|
||||
} catch {
|
||||
setError('Biometric not available.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-trigger biometric on mount if available
|
||||
useEffect(() => {
|
||||
if (biometricAvailable) {
|
||||
handleBiometric();
|
||||
}
|
||||
}, [biometricAvailable, handleBiometric]);
|
||||
|
||||
const handlePinChange = async (newPin: string) => {
|
||||
setPin(newPin);
|
||||
setError('');
|
||||
|
||||
if (newPin.length === 6) {
|
||||
const ok = await verifyPin(newPin);
|
||||
if (ok) {
|
||||
router.replace('/(tabs)');
|
||||
} else {
|
||||
setError('Incorrect PIN. Try again.');
|
||||
setTimeout(() => setPin(''), 600);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPin = () => {
|
||||
Alert.alert(
|
||||
'Forgot PIN?',
|
||||
'Resetting your PIN will clear ALL app data. This cannot be undone.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Clear & Reset',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
await db.execAsync(`
|
||||
DELETE FROM contacts;
|
||||
DELETE FROM visits;
|
||||
DELETE FROM territories;
|
||||
DELETE FROM users;
|
||||
DELETE FROM sync_log;
|
||||
DELETE FROM topics;
|
||||
`);
|
||||
await disablePin();
|
||||
clearUser();
|
||||
router.replace('/onboarding');
|
||||
} catch (e) {
|
||||
Alert.alert('Error', 'Could not clear data. Please try again.');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{/* Logo */}
|
||||
<View style={styles.logoArea}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Shield size={40} color="#1A6B72" />
|
||||
</View>
|
||||
<Text style={styles.appName}>TerritoryLog</Text>
|
||||
<Text style={styles.subtitle}>Enter your PIN to continue</Text>
|
||||
</View>
|
||||
|
||||
{/* Error */}
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
{/* PIN Pad */}
|
||||
<PinPad pin={pin} onPinChange={handlePinChange} maxLength={6} />
|
||||
|
||||
{/* Biometric button */}
|
||||
{biometricAvailable && (
|
||||
<TouchableOpacity style={styles.biometricBtn} onPress={handleBiometric}>
|
||||
<Text style={styles.biometricText}>Use Biometrics</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Forgot PIN */}
|
||||
<TouchableOpacity style={styles.forgotBtn} onPress={handleForgotPin}>
|
||||
<Text style={styles.forgotText}>Forgot PIN? (Clear App Data)</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#111827',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
gap: 32,
|
||||
},
|
||||
logoArea: {
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(26,107,114,0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(26,107,114,0.4)',
|
||||
},
|
||||
appName: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
color: '#F9FAFB',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
error: {
|
||||
color: '#F87171',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
biometricBtn: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 28,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1A6B72',
|
||||
},
|
||||
biometricText: {
|
||||
color: '#1A6B72',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
forgotBtn: {
|
||||
marginTop: 8,
|
||||
},
|
||||
forgotText: {
|
||||
color: '#6B7280',
|
||||
fontSize: 13,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user