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