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:
@@ -1,25 +1,38 @@
|
||||
// app/_layout.tsx
|
||||
import { Stack } from 'expo-router';
|
||||
import { Stack, router } from 'expo-router';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { View, ActivityIndicator, AppState, AppStateStatus } from 'react-native';
|
||||
import { useUserStore } from '@/store/useUserStore';
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import { isPinEnabled } from '@/lib/pinService';
|
||||
import '../global.css';
|
||||
|
||||
const LOCK_AFTER_SECONDS = 60;
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const setUser = useUserStore((s) => s.setUser);
|
||||
|
||||
// Track when app was backgrounded
|
||||
const backgroundedAt = useRef<number | null>(null);
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
async function bootstrap() {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const user = await db.getFirstAsync<{ id: string; display_name: string; share_id: string }>(
|
||||
'SELECT id, display_name, share_id FROM users WHERE is_self = 1 LIMIT 1'
|
||||
);
|
||||
const user = await db.getFirstAsync<{
|
||||
id: string;
|
||||
display_name: string;
|
||||
share_id: string;
|
||||
}>('SELECT id, display_name, share_id FROM users WHERE is_self = 1 LIMIT 1');
|
||||
if (user) {
|
||||
setUser({ id: user.id, displayName: user.display_name, shareId: user.share_id });
|
||||
setUser({
|
||||
id: user.id,
|
||||
displayName: user.display_name,
|
||||
shareId: user.share_id,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Bootstrap error:', e);
|
||||
@@ -30,9 +43,56 @@ export default function RootLayout() {
|
||||
bootstrap();
|
||||
}, []);
|
||||
|
||||
// PIN lock on resume
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener(
|
||||
'change',
|
||||
async (nextState: AppStateStatus) => {
|
||||
const prev = appState.current;
|
||||
|
||||
if (
|
||||
(prev === 'active' || prev === 'inactive') &&
|
||||
nextState === 'background'
|
||||
) {
|
||||
// App going to background — record timestamp
|
||||
backgroundedAt.current = Date.now();
|
||||
}
|
||||
|
||||
if (
|
||||
(prev === 'background' || prev === 'inactive') &&
|
||||
nextState === 'active'
|
||||
) {
|
||||
// App coming to foreground
|
||||
const bgAt = backgroundedAt.current;
|
||||
if (bgAt !== null) {
|
||||
const elapsedSec = (Date.now() - bgAt) / 1000;
|
||||
if (elapsedSec >= LOCK_AFTER_SECONDS) {
|
||||
const pinOn = await isPinEnabled();
|
||||
if (pinOn) {
|
||||
router.replace('/lock');
|
||||
}
|
||||
}
|
||||
}
|
||||
backgroundedAt.current = null;
|
||||
}
|
||||
|
||||
appState.current = nextState;
|
||||
}
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F8F4EF' }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F8F4EF',
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color="#1A6B72" />
|
||||
</View>
|
||||
);
|
||||
@@ -43,6 +103,21 @@ export default function RootLayout() {
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen
|
||||
name="lock"
|
||||
options={{
|
||||
presentation: 'fullScreenModal',
|
||||
animation: 'fade',
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="sync-progress"
|
||||
options={{
|
||||
presentation: 'fullScreenModal',
|
||||
animation: 'slide_from_bottom',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user