400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
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";
|
|
|
|
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<RootRouteName>(null);
|
|
const [hasVisitedPositions, setHasVisitedPositions] = useState(false);
|
|
const [hasVisitedInformation, setHasVisitedInformation] = useState(false);
|
|
const [currentRootRoute, setCurrentRootRoute] =
|
|
useState<RootRouteName>(null);
|
|
const [navigationReady, setNavigationReady] = 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(() => {
|
|
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,
|
|
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
|
|
>;
|