Files
territory-log/app/_layout.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

125 lines
3.3 KiB
TypeScript

// app/_layout.tsx
import { Stack, router } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
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');
if (user) {
setUser({
id: user.id,
displayName: user.display_name,
shareId: user.share_id,
});
}
} catch (e) {
console.error('Bootstrap error:', e);
} finally {
setLoading(false);
}
}
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',
}}
>
<ActivityIndicator size="large" color="#1A6B72" />
</View>
);
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<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>
);
}