Files
territory-log/app/lock.tsx
Kibin 6fe6ebef13 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
2026-02-18 19:57:35 +08:00

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