guardia-messenger/app/_layout.tsx

100 lines
3.3 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { AppState, AppStateStatus, View } from 'react-native'
import { Stack, useRouter, useSegments } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import * as SplashScreen from 'expo-splash-screen'
import { AuthContext, useAuthState } from '../hooks/useAuth'
import { isSessionExpired, recordActivity, clearSession } from '../hooks/useSessionExpiry'
import PinLock, { isPinEnabled } from '../components/PinLock'
import { ThemeProvider } from '../contexts/ThemeContext'
import { FontProvider } from '../contexts/FontContext'
import { OfflineProvider } from '../contexts/OfflineContext'
SplashScreen.preventAutoHideAsync()
export default function RootLayout() {
const auth = useAuthState()
const router = useRouter()
const segments = useSegments()
// #30 PIN 잠금 상태
const [locked, setLocked] = useState(false)
const appState = useRef<AppStateStatus>(AppState.currentState)
useEffect(() => {
if (auth.loading) return
SplashScreen.hideAsync()
const inAuth = segments[0] === '(auth)'
if (!auth.token && !inAuth) {
router.replace('/(auth)/login')
} else if (auth.token && inAuth) {
router.replace('/(tabs)')
}
}, [auth.loading, auth.token])
/* #31 세션 자동 만료 + #30 PIN 잠금 — AppState background→foreground 처리 */
useEffect(() => {
const onChange = async (next: AppStateStatus) => {
const prev = appState.current
appState.current = next
if (next === 'active' && prev.match(/inactive|background/)) {
// 포그라운드 복귀
if (auth.token) {
// #31 15분 초과 시 세션 종료
if (await isSessionExpired()) {
await clearSession()
await auth.logout()
setLocked(false)
router.replace('/(auth)/login')
return
}
// #30 PIN이 활성화되어 있으면 잠금 화면 표시
if (await isPinEnabled()) {
setLocked(true)
}
await recordActivity()
}
} else if (next.match(/inactive|background/)) {
// 백그라운드 진입 시 활동 시각 갱신
if (auth.token) await recordActivity()
}
}
const sub = AppState.addEventListener('change', onChange)
return () => sub.remove()
}, [auth.token])
const handleUnlock = async () => {
await recordActivity()
setLocked(false)
}
const handlePinFail = async () => {
// 5회 실패 → 세션 종료
await clearSession()
await auth.logout()
setLocked(false)
router.replace('/(auth)/login')
}
return (
<ThemeProvider>
<FontProvider>
<OfflineProvider>
<AuthContext.Provider value={auth}>
<StatusBar style="light" />
<Stack screenOptions={{ headerShown: false }} />
{/* #30 PIN 잠금 오버레이 — 인증된 상태에서만 */}
{locked && auth.token && (
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9999 }}>
<PinLock mode="verify" onSuccess={handleUnlock} onFail={handlePinFail} />
</View>
)}
</AuthContext.Provider>
</OfflineProvider>
</FontProvider>
</ThemeProvider>
)
}