import FontAwesome from '@expo/vector-icons/FontAwesome'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { useFonts } from 'expo-font'; import { Stack, useRouter } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import * as Notifications from 'expo-notifications'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import 'react-native-reanimated'; import { Animated, AppState, Image, StyleSheet, View } from 'react-native'; import { useColorScheme } from '@/components/useColorScheme'; import { initI18n } from '@/src/i18n'; import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco'; import { getConsentAccepted, getOnboardingCompleted, getOrCreateClientUserId } from '@/src/storage/appStorage'; import { persistHomePushMessageFromResponse } from '@/src/services/pushNotificationRoute'; import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi'; // 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常) Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, // 新版 expo-notifications 类型要求显式返回 banner/list 行为 shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false, }), }); export { // Catch any errors thrown by the Layout component. ErrorBoundary, } from 'expo-router'; export const unstable_settings = { // Ensure that reloading on `/modal` keeps a back button present. // 让首次启动(未同意协议)直接进入协议页,避免先渲染 index 再跳转导致“闪一下” initialRouteName: '(splash)/splash', }; // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); export default function RootLayout() { const [loaded, error] = useFonts({ STIXTwoText: require('../assets/fonts/STIXTwoText-VariableFont_wght.ttf'), ...FontAwesome.font, }); const [i18nReady, setI18nReady] = useState(false); const [appReady, setAppReady] = useState(false); const [splashOverlayVisible, setSplashOverlayVisible] = useState(true); const splashOpacity = useRef(new Animated.Value(1)).current; const hasHiddenNativeSplashRef = useRef(false); // Expo Router uses Error Boundaries to catch errors in the navigation tree. useEffect(() => { if (error) throw error; }, [error]); useEffect(() => { initI18n() .catch((e) => { // i18n 初始化失败不应阻塞 App 启动,先打印错误再继续 console.error('i18n init failed', e); }) .finally(() => setI18nReady(true)); }, []); useEffect(() => { // 尽早生成 client_user_id,便于后续任意时刻与后端建立关联(Push Token/偏好等) getOrCreateClientUserId() .then((id) => { if (__DEV__) console.log('[client_user_id]', id); }) .catch((e) => { console.warn('[client_user_id] 生成失败(不阻塞启动)', e); }); }, []); useEffect(() => { // 只要系统通知权限已经 granted,就主动上报 Push Token(不依赖用户在“每日提醒”里点确认) ensurePushTokenRegisteredIfPermitted() .then((res) => { if (__DEV__) console.log('[push_token_sync]', res); }) .catch((e) => { if (__DEV__) console.warn('[push_token_sync] 失败(不阻塞启动)', e); }); }, []); useEffect(() => { // 兜底:当用户在系统弹窗/系统设置里变更权限后,App 回到前台时再同步一次 token const sub = AppState.addEventListener('change', (state) => { if (state !== 'active') return; ensurePushTokenRegisteredIfPermitted().catch(() => { // ignore:不阻塞 }); }); return () => sub.remove(); }, []); useEffect(() => { // 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”) if (loaded && i18nReady) setAppReady(true); }, [loaded, i18nReady]); const onLayoutRootView = useCallback(() => { if (!appReady) return; if (hasHiddenNativeSplashRef.current) return; hasHiddenNativeSplashRef.current = true; // 先隐藏原生 splash,再把同款覆盖层淡出,视觉上实现平滑过渡 void SplashScreen.hideAsync().finally(() => { Animated.timing(splashOpacity, { toValue: 0, duration: 380, useNativeDriver: true, }).start(({ finished }) => { if (finished) setSplashOverlayVisible(false); }); }); }, [appReady, splashOpacity]); const content = useMemo(() => { if (!appReady) return null; return ; }, [appReady]); if (!loaded || !i18nReady) { return null; } return ( {content} {splashOverlayVisible && ( )} ); } function RootLayoutNav() { const colorScheme = useColorScheme(); const router = useRouter(); useEffect(() => { // iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐” syncWidgetConfig().catch(() => {}); syncWidgetUserProfileFromStorage().catch(() => {}); ensureDailyWidgetRecoUpToDate({ reason: 'app_start' }).catch(() => {}); const sub = AppState.addEventListener('change', (state) => { if (state === 'active') { // App 回到前台时尝试刷新(失败不阻塞) ensureDailyWidgetRecoUpToDate({ reason: 'app_active' }).catch(() => {}); } }); return () => sub.remove(); }, []); const handleNotificationResponse = useCallback( async (response: Notifications.NotificationResponse) => { const message = await persistHomePushMessageFromResponse(response); if (!message) return; const [consentAccepted, onboardingCompleted] = await Promise.all([ getConsentAccepted(), getOnboardingCompleted(), ]); if (consentAccepted && onboardingCompleted) { router.replace('/(app)/home'); } }, [router] ); useEffect(() => { let cancelled = false; Notifications.getLastNotificationResponseAsync() .then((response) => { if (cancelled || !response) return; return handleNotificationResponse(response); }) .catch(() => { // ignore:通知冷启动读取失败不阻塞主流程 }); const sub = Notifications.addNotificationResponseReceivedListener((response) => { void handleNotificationResponse(response); }); return () => { cancelled = true; sub.remove(); }; }, [handleNotificationResponse]); return ( {/* 协议页分组(首次启动优先进入) */} {/* 启动分发页:根据 onboarding 状态跳转 */} {/* Onboarding 分组 */} {/* 主应用分组(不使用 Tabs) */} {/* 其他 */} ); } const styles = StyleSheet.create({ root: { flex: 1, }, splashOverlay: { flex: 1, alignItems: 'center', justifyContent: 'center', // 与 app.json 的 expo.splash.backgroundColor 保持一致 backgroundColor: '#EAD2BA', }, splashImage: { // 覆盖层图片尺寸需与系统原生 Splash 的视觉一致,避免出现“缩小一下”的错觉 width: '100%', height: '100%', }, });