100 lines
3.3 KiB
TypeScript
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>
|
|
)
|
|
}
|