import { useCallback, useEffect, useRef, useState } from "react"; import { useWindowDimensions } from "react-native"; import { useFonts } from "expo-font"; import type { EventArg } from "@react-navigation/native"; import type { RootTabParamList } from "../types/navigation"; import { useAreaInfo } from "../stateBox/useAreaInfo"; import { useTrainMenu } from "../stateBox/useTrainMenu"; import { AS } from "../storageControl"; import { STORAGE_KEYS } from "../constants/storage"; import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef, startupExplicitTargetRef, waitForStartupNavigationResolution, } from "./rootNavigation"; import { recordAppLifecycleRootNavigation } from "./observability/appLifecycleCrashSentinel"; import { telemetry } from "./observability/telemetry"; import { resolveStartupInitialRoute, type RootRouteName, } from "./appContainerPolicy"; import { preloadStartupIconFonts } from "./preloadStartupIconFonts"; const positionsRootTelemetry = telemetry.scope("positions.root"); const operationRootTelemetry = telemetry.scope("operation.root"); const navigationRootTelemetry = telemetry.scope("nav.root"); const navigationTabTelemetry = telemetry.scope("nav.tab"); type RootTabPressEvent = EventArg<"tabPress", true>; type RootNavigationSource = "ready" | "change"; /** * AppContainerの起動・navigation・タブ状態処理をまとめたcontroller。 * NavigationContainerやTabのJSXはApps.tsx側に残し、処理とレイアウトの * 境界だけをここで固定する。 */ export function useAppContainerController() { const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo(); const { selectedLine } = useTrainMenu(); const { width, height } = useWindowDimensions(); const [isExtraWindowOpen, setIsExtraWindowOpen] = useState(false); const [startupInitialRoute, setStartupInitialRoute] = useState(null); const [hasVisitedPositions, setHasVisitedPositions] = useState(false); const [hasVisitedInformation, setHasVisitedInformation] = useState(false); const [currentRootRoute, setCurrentRootRoute] = useState(null); const [navigationReady, setNavigationReady] = useState(false); const [startupIconFontsLoaded, setStartupIconFontsLoaded] = useState(false); const startupPrewarmAttemptedRef = useRef(false); const informationStartupPrewarmAttemptedRef = useRef(false); const lastRootNavStateRef = useRef(""); useEffect(() => { let cancelled = false; void (async () => { await waitForStartupNavigationResolution(); if (cancelled) return; let startPageSetting: unknown = null; if (!startupExplicitTargetRef.current) { try { startPageSetting = await AS.getItem(STORAGE_KEYS.START_PAGE); } catch { // Keep the default route when the setting is unset or unreadable. } } if (cancelled) return; const nextInitialRoute = resolveStartupInitialRoute({ hasExplicitTarget: startupExplicitTargetRef.current, startPageSetting, }); setStartupInitialRoute(nextInitialRoute); setHasVisitedPositions(nextInitialRoute === "positions"); navigationRootTelemetry.breadcrumb({ message: "startup initial root route resolved", data: { startupExplicitTarget: startupExplicitTargetRef.current, initialRoute: nextInitialRoute, }, }); })(); return () => { cancelled = true; }; }, []); const applyRootNavigationState = useCallback( (state: any, source: RootNavigationSource) => { const activeRoute = state?.routes?.[state?.index ?? 0]; const hasExtra = (activeRoute?.state?.index ?? 0) > 0; const nestedState = activeRoute?.state; const nestedRoute = nestedState?.routes?.[nestedState.index ?? 0]?.name ?? null; const signature = JSON.stringify({ rootIndex: state?.index ?? 0, rootRoute: activeRoute?.name ?? null, nestedIndex: nestedState?.index ?? 0, nestedRoute, hasExtra, }); if (lastRootNavStateRef.current !== signature) { lastRootNavStateRef.current = signature; navigationRootTelemetry.breadcrumb({ message: source === "ready" ? "root state initialized" : "root state change", data: { rootIndex: state?.index ?? 0, rootRoute: activeRoute?.name ?? null, nestedIndex: nestedState?.index ?? 0, nestedRoute, hasExtra, source, }, }); } const rootRouteName = (activeRoute?.name ?? null) as RootRouteName; if (rootRouteName) { lastObservedRootRouteRef.current = rootRouteName; } recordAppLifecycleRootNavigation({ rootIndex: state?.index ?? 0, rootRoute: rootRouteName, nestedIndex: nestedState?.index ?? 0, nestedRoute, source, }); telemetry.tag("root_tab", rootRouteName ?? "unknown"); telemetry.context("root_navigation", { rootIndex: state?.index ?? 0, rootRoute: rootRouteName, nestedIndex: nestedState?.index ?? 0, nestedRoute, hasExtra, source, operationOrientation: width > height ? "landscape" : "portrait", hasVisitedPositions, hasVisitedInformation, }); setCurrentRootRoute(rootRouteName); setIsExtraWindowOpen(hasExtra); }, [hasVisitedInformation, hasVisitedPositions, height, width], ); const handleNavigationReady = useCallback(() => { navigationRootTelemetry.info("navigation ready"); applyRootNavigationState( rootNavigationRef.getRootState?.() ?? rootNavigationRef.getState?.(), "ready", ); setNavigationReady(true); }, [applyRootNavigationState]); const handleNavigationStateChange = useCallback( (state: any) => { applyRootNavigationState(state, "change"); }, [applyRootNavigationState], ); const handleRootTabPress = useCallback( (routeName: keyof RootTabParamList, event: RootTabPressEvent) => { const rootState = rootNavigationRef.getState(); const activeRootRouteName = (rootState?.routes?.[rootState.index ?? 0]?.name as | keyof RootTabParamList | undefined) ?? currentRootRoute ?? null; const leavingUnstablePositions = routeName !== "positions" && positionsLifecycleRef.current.isUnstable; const blockingUnstableExit = routeName !== "positions" && positionsLifecycleRef.current.blockTabExit; navigationTabTelemetry.breadcrumb({ message: blockingUnstableExit ? "root tabPress blocked while positions unstable" : leavingUnstablePositions ? "root tabPress intercepted for unstable positions" : "root tabPress", data: { route: routeName, currentRootRoute, activeRootRouteName, unstablePositions: positionsLifecycleRef.current.isUnstable, blockTabExit: positionsLifecycleRef.current.blockTabExit, }, }); if (blockingUnstableExit) { event.preventDefault(); positionsLifecycleRef.current.resetBeforeLeave?.(); requestAnimationFrame(() => { setTimeout(() => { rootNavigationRef.navigate(routeName); }, 0); }); return; } if (leavingUnstablePositions) { event.preventDefault(); positionsLifecycleRef.current.resetBeforeLeave?.(); requestAnimationFrame(() => { setTimeout(() => { rootNavigationRef.navigate(routeName); }, 0); }); positionsRootTelemetry.breadcrumb({ message: "positions root exit reset and continued while session unstable", data: { route: routeName, }, }); } }, [currentRootRoute], ); const handleRootTabFocus = useCallback((routeName: keyof RootTabParamList) => { navigationTabTelemetry.breadcrumb({ message: "root tab focus", data: { route: routeName }, }); }, []); const handleRootTabBlur = useCallback((routeName: keyof RootTabParamList) => { navigationTabTelemetry.breadcrumb({ message: "root tab blur", data: { route: routeName }, }); }, []); const handleRootTabState = useCallback( (routeName: keyof RootTabParamList, stateType: string) => { navigationTabTelemetry.breadcrumb({ message: "root tab state", data: { route: routeName, stateType, }, }); }, [], ); const activatePositionsFromTabPress = useCallback(() => { positionsRootTelemetry.info("positions root tabPress activation"); setHasVisitedPositions(true); }, []); const activatePositionsFromFocus = useCallback(() => { positionsRootTelemetry.info("positions root focus activation"); setHasVisitedPositions(true); }, []); const preservePositionsOnBlur = useCallback(() => { positionsRootTelemetry.info("positions root blur preserved"); }, []); const activateInformationFromTabPress = useCallback(() => { operationRootTelemetry.info("operation root tabPress activation"); setHasVisitedInformation(true); }, []); const activateInformationFromFocus = useCallback(() => { operationRootTelemetry.info("operation root focus activation"); setHasVisitedInformation(true); }, []); const preserveInformationOnBlur = useCallback(() => { operationRootTelemetry.info("operation root blur preserved"); }, []); const [fontLoaded] = useFonts({ "JR-Nishi": require("../assets/fonts/jr-nishi.otf"), "JR-WEST-PLUS": require("../assets/fonts/JR-WEST-PLUS.otf"), Zou: require("../assets/fonts/DelaGothicOne-Regular.ttf"), "JNR-font": require("../assets/fonts/JNRfont_pict.ttf"), DiaPro: require("../assets/fonts/DiaPro-Regular.otf"), }); useEffect(() => { let cancelled = false; void preloadStartupIconFonts().then(() => { if (!cancelled) setStartupIconFontsLoaded(true); }); return () => { cancelled = true; }; }, []); useEffect(() => { if (!navigationReady || !fontLoaded) return; if (startupPrewarmAttemptedRef.current) return; if (hasVisitedPositions) { startupPrewarmAttemptedRef.current = true; return; } if (currentRootRoute === null || currentRootRoute === "positions") return; startupPrewarmAttemptedRef.current = true; positionsRootTelemetry.breadcrumb({ message: "positions startup prewarm scheduled", data: { currentRootRoute, }, }); let cancelled = false; const timer = setTimeout(() => { if (cancelled || hasVisitedPositions) return; positionsRootTelemetry.breadcrumb({ message: "positions startup prewarm activated", data: { currentRootRoute, }, }); setHasVisitedPositions(true); }, 500); return () => { cancelled = true; clearTimeout(timer); }; }, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]); useEffect(() => { if (!navigationReady || !fontLoaded) return; if (informationStartupPrewarmAttemptedRef.current) return; if (hasVisitedInformation) { informationStartupPrewarmAttemptedRef.current = true; return; } if (currentRootRoute === null || currentRootRoute === "information") return; informationStartupPrewarmAttemptedRef.current = true; operationRootTelemetry.breadcrumb({ message: "operation startup prewarm scheduled", data: { currentRootRoute, }, }); let cancelled = false; const timer = setTimeout(() => { if (cancelled || hasVisitedInformation) return; operationRootTelemetry.breadcrumb({ message: "operation startup prewarm activated", data: { currentRootRoute, }, }); setHasVisitedInformation(true); }, 500); return () => { cancelled = true; clearTimeout(timer); }; }, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]); return { areaInfo, areaIconBadgeText, isInfo, selectedLine, isExtraWindowOpen, startupInitialRoute, hasVisitedPositions, hasVisitedInformation, currentRootRoute, navigationReady, fontLoaded, startupIconFontsLoaded, shouldPreloadPositions: navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedPositions, shouldPreloadInformation: navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedInformation, handleNavigationReady, handleNavigationStateChange, handleRootTabPress, handleRootTabFocus, handleRootTabBlur, handleRootTabState, activatePositionsFromTabPress, activatePositionsFromFocus, preservePositionsOnBlur, activateInformationFromTabPress, activateInformationFromFocus, preserveInformationOnBlur, }; } export type AppContainerController = ReturnType< typeof useAppContainerController >;