163 changed files with 1904 additions and 6746 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Verify
on:
pull_request:
permissions:
contents: read
jobs:
verify:
name: Typecheck, lint, and unit tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use the project Node version
uses: actions/setup-node@v4
with:
node-version: 24.9.0
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Required verification
run: yarn verify
+1 -2
View File
@@ -10,8 +10,7 @@
"Read(//home/ubuntu/.qwen/debug/**)",
"Bash(curl *)",
"mcp__sentry__search_events",
"mcp__sentry__find_organizations",
"Skill(jr-shikoku-architecture)"
"mcp__sentry__find_organizations"
]
}
}
+1 -18
View File
@@ -7,15 +7,8 @@ import { useAppLifecycleObservability } from "./lib/observability/useAppLifecycl
import { useAppBootstrap } from "./lib/useAppBootstrap";
import { useStartupDeepLinkNavigation } from "./lib/useStartupDeepLinkNavigation";
import { AppRootLayout } from "./components/AppRootLayout";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import {
markStartup,
markStartupOnce,
scheduleStartupReports,
} from "./lib/devStartupPerformance";
initializeSentry();
markStartupOnce("app-module-ready", "app module ready");
LogBox.ignoreLogs([
"ViewPropTypes will be removed",
@@ -42,19 +35,9 @@ if (Platform.OS === "android") {
}
export default wrapWithSentry(function App() {
markStartupOnce("app-render", "App render");
scheduleStartupReports();
useAppLifecycleObservability({ nativeScreensMode, rootTabSceneMode });
useAppBootstrap();
useStartupDeepLinkNavigation();
React.useEffect(() => {
markStartup("App effect committed");
}, []);
return (
<AppErrorBoundary scope="root">
<AppRootLayout />
</AppErrorBoundary>
);
return <AppRootLayout />;
});
+118 -207
View File
@@ -18,7 +18,6 @@ import TNDView from "./ndView";
import { initIcon } from "./lib/initIcon";
import { Top } from "./Top";
import { MenuPage } from "./MenuPage";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import "./components/ActionSheetComponents/sheets";
import { rootNavigationRef } from "./lib/rootNavigation";
import WebView from "react-native-webview";
@@ -27,17 +26,10 @@ import type { RootTabParamList } from "@/types/navigation";
import { useThemeColors } from "./lib/theme";
import { telemetry } from "./lib/observability/telemetry";
import { useAppContainerController } from "./lib/useAppContainerController";
import { useCurrentTrainPolling } from "./stateBox/useCurrentTrain";
import {
getTabBarAppearance,
TabBarBackground,
} from "./components/AppContainer/TabBarBackground";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "./lib/devStartupPerformance";
import { PositionsStartupReadyContext } from "./lib/positionsStartupContext";
const startupPreloadTelemetry = telemetry.scope("startup.preload");
const positionsRootTelemetry = telemetry.scope("positions.root");
@@ -46,24 +38,27 @@ const operationRootTelemetry = telemetry.scope("operation.root");
const Tab = createBottomTabNavigator<RootTabParamList>();
const HiddenStartupPreloadWebViews = ({
shouldPreloadPositions,
shouldPreloadInformation,
onInformationLoadStart,
onInformationLoadSettled,
}: {
shouldPreloadPositions: boolean;
shouldPreloadInformation: boolean;
onInformationLoadStart: () => void;
onInformationLoadSettled: () => void;
}) => {
const [positionsLoaded, setPositionsLoaded] = useState(false);
const [informationLoaded, setInformationLoaded] = useState(false);
useEffect(() => {
telemetry.context("startup_hidden_preload", {
shouldPreloadPositions,
shouldPreloadInformation,
positionsLoaded,
informationLoaded,
});
}, [
informationLoaded,
positionsLoaded,
shouldPreloadInformation,
shouldPreloadPositions,
]);
const hiddenStyle = useMemo(
@@ -79,26 +74,34 @@ const HiddenStartupPreloadWebViews = ({
[],
);
const showPositions = shouldPreloadPositions && !positionsLoaded;
const showInformation = shouldPreloadInformation && !informationLoaded;
useEffect(() => {
markStartupState(
"hidden-startup-preload-state",
"hidden startup preload state",
{
showInformation,
informationLoaded,
},
);
}, [
informationLoaded,
showInformation,
]);
if (!showInformation) return null;
if (!showPositions && !showInformation) return null;
return (
<View pointerEvents="none" style={hiddenStyle}>
{showPositions ? (
<WebView
source={{ uri: "https://train.jr-shikoku.co.jp/" }}
originWhitelist={["https://train.jr-shikoku.co.jp"]}
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", true);
startupPreloadTelemetry.info("positions hidden preload loadStart");
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
startupPreloadTelemetry.info("positions hidden preload loadEnd");
setPositionsLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
startupPreloadTelemetry.error("positions hidden preload error");
}}
/>
) : null}
{showInformation ? (
<WebView
source={{ uri: "https://www.jr-shikoku.co.jp/info/" }}
@@ -106,18 +109,15 @@ const HiddenStartupPreloadWebViews = ({
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
onInformationLoadStart();
setAppLifecycleWebViewActive("startup_hidden_operation", true);
startupPreloadTelemetry.info("operation hidden preload loadStart");
}}
onLoadEnd={() => {
onInformationLoadSettled();
setAppLifecycleWebViewActive("startup_hidden_operation", false);
startupPreloadTelemetry.info("operation hidden preload loadEnd");
setInformationLoaded(true);
}}
onError={() => {
onInformationLoadSettled();
setAppLifecycleWebViewActive("startup_hidden_operation", false);
startupPreloadTelemetry.error("operation hidden preload error");
}}
@@ -143,11 +143,6 @@ const DeferredPositionsRoot = (props: DeferredRootProps) => {
const [hasActivated, setHasActivated] = useState(false);
useEffect(() => {
markStartupState("positions-root-gate", "positions root gate state", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
telemetry.context("positions_root_gate", {
focused: isRootFocused,
activated: hasActivated,
@@ -157,19 +152,12 @@ const DeferredPositionsRoot = (props: DeferredRootProps) => {
useEffect(() => {
if (!shouldActivate || hasActivated) return;
markStartup("positions root activation scheduled", {
focused: isRootFocused,
});
positionsRootTelemetry.breadcrumb({
message: "positions root activation",
data: {
reason: "first_root_visit",
},
});
markStartupOnce(
"positions-root-activated",
"positions root activation committed",
);
setHasActivated(true);
}, [hasActivated, shouldActivate]);
@@ -177,11 +165,7 @@ const DeferredPositionsRoot = (props: DeferredRootProps) => {
return <View style={{ flex: 1 }} />;
}
return (
<AppErrorBoundary scope="positions">
<Top />
</AppErrorBoundary>
);
return <Top />;
};
const PositionsTabScreen = memo(() => {
@@ -202,11 +186,6 @@ const DeferredInformationRoot = (props: DeferredRootProps) => {
const [hasActivated, setHasActivated] = useState(false);
useEffect(() => {
markStartupState("operation-root-gate", "operation root gate state", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
telemetry.context("operation_root_gate", {
focused: isRootFocused,
activated: hasActivated,
@@ -216,19 +195,12 @@ const DeferredInformationRoot = (props: DeferredRootProps) => {
useEffect(() => {
if (!shouldActivate || hasActivated) return;
markStartup("operation root activation scheduled", {
focused: isRootFocused,
});
operationRootTelemetry.breadcrumb({
message: "operation root activation",
data: {
reason: isRootFocused ? "first_focus" : "background_prewarm",
},
});
markStartupOnce(
"operation-root-activated",
"operation root activation committed",
);
setHasActivated(true);
}, [hasActivated, isRootFocused, shouldActivate]);
@@ -236,19 +208,9 @@ const DeferredInformationRoot = (props: DeferredRootProps) => {
return <View style={{ flex: 1 }} />;
}
return (
<AppErrorBoundary scope="information">
<TNDView />
</AppErrorBoundary>
);
return <TNDView />;
};
const MenuTabScreen = () => (
<AppErrorBoundary scope="topMenu">
<MenuPage />
</AppErrorBoundary>
);
const InformationTabScreen = memo(() => {
const { shouldActivate, isRootFocused } = useContext(
InformationRootGateContext,
@@ -263,7 +225,6 @@ const InformationTabScreen = memo(() => {
InformationTabScreen.displayName = "InformationTabScreen";
export function AppContainer() {
markStartupOnce("app-container-render", "AppContainer render");
const controller = useAppContainerController();
const {
areaInfo,
@@ -274,47 +235,14 @@ export function AppContainer() {
startupInitialRoute,
hasVisitedPositions,
hasVisitedInformation,
positionsInitialLoadReady,
currentRootRoute,
fontLoaded,
startupIconFontsLoaded,
shouldPreloadPositions,
shouldPreloadInformation,
handlePositionsInitialLoadReady,
handlePositionsStackReady,
handlePositionsWebViewLoadStarted,
handleOperationWebViewLoadStarted,
handleOperationWebViewLoadSettled,
positionsStackReady,
positionsWebViewLoadStarted,
operationWebViewPreloadActive,
operationWebViewLoadStarted,
operationWebViewLoadSettled,
} = controller;
// トップメニューの列車情報だけは、画面のmountや走行位置WebViewを待たず
// 既存のCurrentTrainProviderを購読して初回取得を開始する。
useCurrentTrainPolling(
startupInitialRoute === "topMenu" &&
(currentRootRoute === null || currentRootRoute === "topMenu"),
);
const { isDark } = useThemeColors();
const operationWebViewPreloadPending =
operationWebViewPreloadActive;
useEffect(() => {
markStartupState("app-container-gate", "AppContainer gate state", {
fontLoaded,
startupIconFontsLoaded,
startupInitialRoute,
navigationReady: controller.navigationReady,
});
}, [
controller.navigationReady,
fontLoaded,
startupIconFontsLoaded,
startupInitialRoute,
]);
const linking = {
prefixes: ["jrshikoku://"],
@@ -356,7 +284,7 @@ export function AppContainer() {
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
},
});
if (!fontLoaded || !startupInitialRoute) {
if (!fontLoaded || !startupIconFontsLoaded || !startupInitialRoute) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
@@ -371,119 +299,102 @@ export function AppContainer() {
onReady={controller.handleNavigationReady}
onStateChange={controller.handleNavigationStateChange}
>
<PositionsStartupReadyContext.Provider
<PositionsRootGateContext.Provider
value={{
stackReady: positionsStackReady,
webViewLoadStarted: positionsWebViewLoadStarted,
initialLoadReady: positionsInitialLoadReady,
operationWebViewPreloadPending,
operationWebViewLoadStarted,
operationWebViewLoadSettled,
notifyInitialLoadReady: handlePositionsInitialLoadReady,
notifyStackReady: handlePositionsStackReady,
notifyWebViewLoadStarted: handlePositionsWebViewLoadStarted,
notifyOperationWebViewLoadStarted: handleOperationWebViewLoadStarted,
notifyOperationWebViewLoadSettled: handleOperationWebViewLoadSettled,
shouldActivate: hasVisitedPositions,
isRootFocused: currentRootRoute === "positions",
}}
>
<PositionsRootGateContext.Provider
<InformationRootGateContext.Provider
value={{
shouldActivate: hasVisitedPositions,
isRootFocused: currentRootRoute === "positions",
shouldActivate: hasVisitedInformation,
isRootFocused: currentRootRoute === "information",
}}
>
<InformationRootGateContext.Provider
value={{
shouldActivate: hasVisitedInformation,
isRootFocused: currentRootRoute === "information",
<Tab.Navigator
id="rootTabs"
detachInactiveScreens={false}
initialRouteName={startupInitialRoute}
screenListeners={({ route }) => ({
tabPress: (event: EventArg<"tabPress", true>) => {
controller.handleRootTabPress(route.name, event);
},
focus: () => {
controller.handleRootTabFocus(route.name);
},
blur: () => {
controller.handleRootTabBlur(route.name);
},
state: (event) => {
controller.handleRootTabState(route.name, event.type);
},
})}
screenOptions={({ route }) => {
const tabBarBackgroundProps = {
routeName: route.name,
selectedLine,
isDark,
isExtraWindowOpen,
};
const {
defaultBg,
activeTintColor,
inactiveTintColor,
} = getTabBarAppearance(tabBarBackgroundProps);
return {
lazy: false,
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: activeTintColor,
tabBarInactiveTintColor: inactiveTintColor,
tabBarStyle: { backgroundColor: "transparent" },
tabBarBackground: () => (
<TabBarBackground {...tabBarBackgroundProps} />
),
};
}}
>
<Tab.Navigator
id="rootTabs"
detachInactiveScreens={false}
initialRouteName={startupInitialRoute}
screenListeners={({ route }) => ({
tabPress: (event: EventArg<"tabPress", true>) => {
controller.handleRootTabPress(route.name, event);
},
focus: () => {
controller.handleRootTabFocus(route.name);
},
blur: () => {
controller.handleRootTabBlur(route.name);
},
state: (event) => {
controller.handleRootTabState(route.name, event.type);
},
})}
screenOptions={({ route }) => {
const tabBarBackgroundProps = {
routeName: route.name,
selectedLine,
isDark,
isExtraWindowOpen,
};
const {
defaultBg,
activeTintColor,
inactiveTintColor,
} = getTabBarAppearance(tabBarBackgroundProps);
return {
lazy: false,
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: activeTintColor,
tabBarInactiveTintColor: inactiveTintColor,
tabBarStyle: { backgroundColor: "transparent" },
tabBarBackground: () => (
<TabBarBackground {...tabBarBackgroundProps} />
),
};
<Tab.Screen
{...getTabProps(
"positions",
"走行位置",
"bar-chart",
"AntDesign",
)}
component={PositionsTabScreen}
listeners={{
tabPress: controller.activatePositionsFromTabPress,
focus: controller.activatePositionsFromFocus,
blur: controller.preservePositionsOnBlur,
}}
>
<Tab.Screen
{...getTabProps(
"positions",
"走行位置",
"bar-chart",
"AntDesign",
)}
component={PositionsTabScreen}
listeners={{
tabPress: controller.activatePositionsFromTabPress,
focus: controller.activatePositionsFromFocus,
blur: controller.preservePositionsOnBlur,
}}
/>
<Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
component={MenuTabScreen}
/>
/>
<Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
component={MenuPage}
/>
<Tab.Screen
{...getTabProps(
"information",
"運行情報",
"train",
"Ionicons",
areaInfo ? areaIconBadgeText : undefined,
isInfo,
)}
component={InformationTabScreen}
listeners={{
tabPress: controller.activateInformationFromTabPress,
focus: controller.activateInformationFromFocus,
blur: controller.preserveInformationOnBlur,
}}
/>
</Tab.Navigator>
</InformationRootGateContext.Provider>
</PositionsRootGateContext.Provider>
</PositionsStartupReadyContext.Provider>
<Tab.Screen
{...getTabProps(
"information",
"運行情報",
"train",
"Ionicons",
areaInfo ? areaIconBadgeText : undefined,
isInfo,
)}
component={InformationTabScreen}
listeners={{
tabPress: controller.activateInformationFromTabPress,
focus: controller.activateInformationFromFocus,
blur: controller.preserveInformationOnBlur,
}}
/>
</Tab.Navigator>
</InformationRootGateContext.Provider>
</PositionsRootGateContext.Provider>
<HiddenStartupPreloadWebViews
shouldPreloadPositions={shouldPreloadPositions}
shouldPreloadInformation={shouldPreloadInformation}
onInformationLoadStart={handleOperationWebViewLoadStarted}
onInformationLoadSettled={handleOperationWebViewLoadSettled}
/>
</NavigationContainer>
);
+11 -36
View File
@@ -119,7 +119,6 @@ export default ({ route }) => {
uri,
useExitButton = true,
importRecordingDownloads = false,
onExit,
} = route.params;
const { goBack } = useNavigation();
const { fixed } = useThemeColors();
@@ -133,8 +132,6 @@ export default ({ route }) => {
DEFAULT_JR_DATA_SYSTEM_ENV,
);
const [resolvedUri, setResolvedUri] = React.useState("");
const [webViewSourceUri, setWebViewSourceUri] = React.useState("");
const resolvedUriRef = React.useRef("");
const [isEnvironmentReady, setIsEnvironmentReady] = React.useState(false);
const hasAlerted = React.useRef(false);
const [isLoading, setIsLoading] = React.useState(true);
@@ -151,11 +148,6 @@ export default ({ route }) => {
const blankCountRef = React.useRef(0);
const remount = React.useCallback(() => {
// 現在のURLは復元用に使うが、通常のナビゲーションではsourceへ戻さない。
// sourceの更新はWebViewの再読込になるため、フィルター操作中に行うと状態が消える。
if (resolvedUriRef.current) {
setWebViewSourceUri(resolvedUriRef.current);
}
lastPongAt.current = Date.now(); // remount直後に誤検知しないようリセット
maxBodyLenRef.current = 0;
blankCountRef.current = 0;
@@ -180,12 +172,11 @@ export default ({ route }) => {
const nextUri = importRecordingDownloads
? rawUri
: rewriteJrDataSystemUrl(rawUri, nextEnvironment);
const initialUri = importRecordingDownloads
? nextUri
: withJrDataSystemIconQuery(nextUri, iconSetting);
resolvedUriRef.current = initialUri;
setResolvedUri(initialUri);
setWebViewSourceUri(initialUri);
setResolvedUri(
importRecordingDownloads
? nextUri
: withJrDataSystemIconQuery(nextUri, iconSetting),
);
setIsEnvironmentReady(true);
};
@@ -199,9 +190,6 @@ export default ({ route }) => {
}, [iconSetting, uri, importRecordingDownloads]);
const handleReload = () => {
if (resolvedUriRef.current) {
setWebViewSourceUri(resolvedUriRef.current);
}
lastPongAt.current = Date.now();
setHasError(false);
hasErrorRef.current = false;
@@ -231,9 +219,7 @@ export default ({ route }) => {
stack.pop();
const previousUrl = stack[stack.length - 1];
if (!previousUrl) return false;
resolvedUriRef.current = previousUrl;
setResolvedUri(previousUrl);
setWebViewSourceUri(previousUrl);
syncCanGoBack(false);
remount();
return true;
@@ -247,14 +233,6 @@ export default ({ route }) => {
return handlePseudoBack();
}, [handlePseudoBack]);
const handleExit = React.useCallback(() => {
try {
void onExit?.();
} finally {
goBack();
}
}, [goBack, onExit]);
const handleImportRecordingText = React.useCallback(
async (content: string) => {
try {
@@ -299,20 +277,20 @@ export default ({ route }) => {
if (canGoBack) {
if (handleWebViewBack()) return true;
}
handleExit();
goBack();
return true;
};
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
return () => subscription.remove();
}, [canGoBack, handleExit, handleWebViewBack])
}, [canGoBack, goBack, handleWebViewBack])
);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
{isEnvironmentReady && (
<WebView
key={webViewKey}
source={{ uri: webViewSourceUri }}
source={{ uri: resolvedUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
setSupportMultipleWindows={false}
@@ -376,9 +354,7 @@ export default ({ route }) => {
iconSetting,
);
if (rewrittenUrl !== request.url) {
resolvedUriRef.current = rewrittenUrl;
setResolvedUri(rewrittenUrl);
setWebViewSourceUri(rewrittenUrl);
return false;
}
}
@@ -399,7 +375,6 @@ export default ({ route }) => {
rewriteJrDataSystemUrl(navState.url, selectedEnvironment),
iconSetting,
);
resolvedUriRef.current = nextUrl;
setResolvedUri((current) => (current === nextUrl ? current : nextUrl));
if (nextUrl !== "https://unyohub.2pd.jp/integration/succeeded.php") {
pushHistoryEntry(nextUrl);
@@ -473,7 +448,7 @@ export default ({ route }) => {
return;
}
if (type === "back") return handleWebViewBack();
if (type === "windowClose") return handleExit();
if (type === "windowClose") return goBack();
}}
/>
)}
@@ -491,13 +466,13 @@ export default ({ route }) => {
<Text style={wvStyles.reloadButtonText}>再読み込み</Text>
</TouchableOpacity>
{useExitButton && (
<TouchableOpacity style={wvStyles.backButton} onPress={handleExit}>
<TouchableOpacity style={wvStyles.backButton} onPress={goBack}>
<Text style={wvStyles.backButtonText}>閉じる</Text>
</TouchableOpacity>
)}
</View>
)}
{useExitButton && !hasError && <BigButton onPress={handleExit} string="閉じる" />}
{useExitButton && !hasError && <BigButton onPress={goBack} string="閉じる" />}
</View>
);
};
+6 -38
View File
@@ -8,6 +8,7 @@ import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { logger } from "@/utils/logger";
import TrainBase from "@/components/trainbaseview";
import HowTo from "@/howto";
import { Menu } from "@/menu";
import News from "@/components/news";
import Setting from "@/components/Settings/settings";
@@ -21,11 +22,6 @@ import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import GeneralWebView from "@/GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { telemetry } from "@/lib/observability/telemetry";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "@/lib/devStartupPerformance";
import type { StackNavigationProp } from "@react-navigation/stack";
import type {
RootTabParamList,
@@ -35,7 +31,6 @@ const Stack = createStackNavigator<TopMenuStackParamList>();
const topMenuScreenTelemetry = telemetry.scope("topMenu.screen");
export function MenuPage() {
markStartupOnce("top-menu-render", "top menu render");
const { height, width } = useWindowDimensions();
const { verticalScale } = useResponsive();
const tabBarHeight = useBottomTabBarHeight();
@@ -47,7 +42,6 @@ export function MenuPage() {
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
useEffect(() => {
markStartupOnce("top-menu-mounted", "top menu mounted");
topMenuScreenTelemetry.info("top menu mounted");
return () => {
topMenuScreenTelemetry.info("top menu unmounted");
@@ -55,9 +49,6 @@ export function MenuPage() {
}, []);
useEffect(() => {
markStartupState("top-menu-focus", "top menu focus state", {
focused: isFocused,
});
topMenuScreenTelemetry.breadcrumb({
message: isFocused ? "top menu focused" : "top menu blurred",
data: {
@@ -69,40 +60,16 @@ export function MenuPage() {
useEffect(() => {
//ニュース表示
markStartup("top menu startup storage checks start");
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
.then((d) => {
markStartup("top menu news status read end", {
hasValue: d !== null && d !== undefined,
isCurrent: d === news,
});
if (d !== news) {
markStartup("top menu startup navigation attempt", {
target: "news",
reason: "news_status",
});
navigation.navigate("topMenu", { screen: "news" });
}
if (d !== news) navigation.navigate("topMenu", { screen: "news" });
})
.catch(() => {
markStartup("top menu news status read error");
markStartup("top menu startup navigation attempt", {
target: "news",
reason: "news_status_error",
});
navigation.navigate("topMenu", { screen: "news" });
});
.catch(() => navigation.navigate("topMenu", { screen: "news" }));
AS.getItem(STORAGE_KEYS.ICON_SETTING)
.then((isSetIcon) => {
markStartup("top menu icon setting read end", {
hasValue: isSetIcon !== null && isSetIcon !== undefined,
});
if (isSetIcon === "true") SheetManager.show("TrainIconUpdate");
})
.catch((error) => {
markStartup("top menu icon setting read error");
logger.error("Error fetching icon setting:", error);
});
.catch((error) => logger.error("Error fetching icon setting:", error));
return undefined;
}, []);
@@ -155,7 +122,7 @@ export function MenuPage() {
}
}
scrollRef.current?.scrollTo({
y: Math.max(0, mapHeightRef.current - verticalScale(80)),
y: mapHeightRef.current - verticalScale(80),
animated: true,
});
setMapMode(false);
@@ -242,6 +209,7 @@ export function MenuPage() {
options={{ ...optionData, gestureEnabled: false }}
component={AllTrainDiagramView}
/>
<Stack.Screen name="howto" options={optionData} component={HowTo} />
<Stack.Screen
name="generalWebView"
options={optionData}
+56 -147
View File
@@ -1,109 +1,73 @@
# JR四国 列車位置情報アプリ
JR四国の列車リアルタイム位置情報を表示するReact Nativeアプリケーション(非公式)。
JR四国の列車リアルタイム位置情報を表示するReact Nativeアプリケーション。
## 🚀 技術スタック
> 正確なバージョンは宣言値を `package.json`、解決値を `yarn.lock` で確認してください。
- **フレームワーク**: Expo SDK 57 / React Native 0.86系 / React 19系(New Architecture)
- **フレームワーク**: React Native (Expo SDK 52)
- **言語**: TypeScript / JavaScript
- **ナビゲーション**: React Navigation 7(bottom tabs + stack。Expo Routerは未使用)
- **状態管理**: React Context API + カスタムフック(`stateBox/`)
- **ストレージ**: AsyncStorage、expo-sqlite
- **地図表示**: WebView + JavaScript Injection、react-native-maps
- **シート**: react-native-actions-sheet、@gorhom/bottom-sheet
- **状態管理**: React Context API + カスタムフック
- **ストレージ**: AsyncStorage
- **地図表示**: WebView + JavaScript Injection
- **日付処理**: dayjs
- **アニメーション / ジェスチャー**: react-native-reanimated、react-native-gesture-handler(v2系)
- **監視**: Sentry
- **ウィジェット**: react-native-android-widget(Android)、`@bacons/apple-targets` によるWidgetKit拡張(iOS)
- **アニメーション**: react-native-reanimated
## 📦 プロジェクト構造
```
jrshikoku/
├── components/ # Reactコンポーネント
│ ├── Apps/ # 走行位置画面
│ ├── Apps/ # アプリケーション機能
│ │ └── FixedPositionBox/
│ │ └── hooks/ # カスタムフック
│ ├── AndroidWidget/ # Androidウィジェット
│ ├── ActionSheetComponents/
│ ├── atom/ # 基本UIコンポーネント
│ ├── Menu/ # メニュー関連
│ ├── Settings/ # 設定画面
│ └── TrainMenu/ # 列車メニュー
├── stateBox/ # グローバル状態管理(Context + hooks)
├── stateBox/ # グローバル状態管理
├── lib/ # ユーティリティライブラリ
│ ├── domain/ # 駅・列車identityのdomain型
│ ├── eachTrainInfoCoreLib/ # 列車情報処理
│ ├── observability/ # Sentry・テレメトリ
│ ├── theme/ # カラーテーマ
│ └── webview/ # WebView注入用データ
│ └── eachTrainInfoCoreLib/ # 列車情報処理
├── constants/ # 定数定義
│ ├── intervals.ts # 時間間隔定数
│ ├── api.ts # APIエンドポイント
│ ├── storage.ts # StorageKeys
│ └── index.ts
├── modules/ # ローカルExpoモジュール
│ ├── expo-felica-reader/ # FeliCa読み取り(iOS/Android)
│ └── expo-live-activity/ # Live Activities(iOS)
├── targets/widget/ # iOS WidgetKit拡張
├── types/ # TypeScript型定義
├── utils/ # ユーティリティ関数
│ ├── logger.ts # ロギング
│ └── seUtils.ts # SE判定処理
├── tests/ # pure関数のテスト(node:test)
├── docs/ # 設計・changelog・検証記録
│ └── architecture/ # domain契約・ADR
├── assets/ # 静的リソース
└── config/ # 設定ファイル
```
`android/` と `ios/` はprebuildで生成されるため、gitでは管理していません。
## 🛠️ セットアップ
### 必要環境
- Node.js(Expo SDK 57が対応するLTS。`yarn expo:doctor` で確認)
- Yarn(lockfileは `yarn.lock` のみ)
- EAS CLI(ビルドする場合)
- Node.js 18以上
- npm または yarn
- Expo CLI
### インストールと起動
### インストール
```bash
# 依存関係のインストール(postinstallでpatch-packageが実行されます)
yarn install
# 依存関係のインストール
npm install
# 開発サーバー起動(dev client)
yarn start
# 開発サーバー起動
npm start
# ネイティブビルドして実行
yarn ios
yarn android
```
# iOS実行
npm run ios
### 検証
# Android実行
npm run android
```bash
yarn typecheck # tsc --noEmit
yarn lint # expo lint
yarn test # tests/*.test.ts
yarn verify # 上記3つをまとめて実行
yarn expo:doctor # Expo依存関係の整合性チェック
```
### 依存ライブラリのライセンス一覧
`dependencies` を追加・更新したら、アプリ内の「オープンソースライセンス」画面で使う一覧を再生成してください。再生成を忘れると `yarn test` が失敗します。
```bash
yarn licenses:generate # assets/licenses/oss-licenses.json を更新
# Web実行
npm run web
```
## 📝 開発ガイドライン
開発エージェント向けの詳細なルールは [AGENTS.md](./AGENTS.md)、domain契約は [docs/architecture/](./docs/architecture/) を参照してください。
### 定数の使用
```typescript
@@ -156,31 +120,18 @@ AsyncStorage.getItem(STORAGE_KEYS.BUS_AND_TRAIN);
### 列車位置追跡
- リアルタイム列車位置表示
- 遅延情報の表示(遅延速報EX)
- 遅延情報の表示
- 次駅・着駅の予測
- 列車追従・駅固定モード(iOSではLive Activitiesに対応)
- 走行位置の記録と再生
### 駅情報
- 時刻表表示
- 駅詳細情報
- お気に入り駅登録・並び替え
- お気に入り駅登録
### 運行情報・通知
- JR四国の運行情報表示
- プッシュ通知(遅延速報EX、運行情報など)
- サウンドによる案内(β)
### ICカード
- FeliCa対応ICカードの残高・履歴読み取り
### カスタマイズ(設定画面)
- 起動時に表示する画面、レイアウト設定
- カラーテーマ(ライト / 端末依存 / ダーク)
- アイコン設定、ランチャーアイコン変更
- サウンド設定(β)、運行情報設定(β)
- 通知設定、情報ソース設定、接続ステータス
- ウィジェット設定(Android)
### カスタマイズ
- アイコン設定
- レイアウト設定
- 通知設定
## 🔧 カスタムフック
@@ -193,22 +144,14 @@ AsyncStorage.getItem(STORAGE_KEYS.BUS_AND_TRAIN);
- `useTrainDataWithThrough`: 通過駅を含む列車データ生成
- `useDestinationStation`: 行先駅データの管理
### グローバル状態フック(`stateBox/`)
### グローバル状態フック
- `useAllTrainDiagram`: 全列車ダイヤデータ
- `useAreaInfo`: 運行情報スナップショット(エリア情報)
- `useBusAndTrainData`: バス・列車データ
- `useCurrentTrain`: 現在の列車位置データ
- `useDeviceOrientationChange`: 画面の向き
- `useElesite`: えれサイト運用データ
- `useCurrentTrain`: 現在選択中の列車
- `useFavoriteStation`: お気に入り駅
- `useNotifications`: プッシュ通知と通知タップ時のルーティング
- `useStationList`: 全駅リスト
- `useTopMenu`: トップメニュー状態
- `useTrainDelayData`: 遅延情報
- `useTrainMenu`: 列車メニュー状態
- `useUnyohub`: 鉄道運用Hub運用データ
- `useUserPosition`: ユーザーの現在地
## 📊 リファクタリング実績
@@ -222,24 +165,17 @@ AsyncStorage.getItem(STORAGE_KEYS.BUS_AND_TRAIN);
## 🚦 API エンドポイント
エンドポイントは `constants/api.ts` の `API_ENDPOINTS` に集約しています。一覧と各URLは同ファイルを参照してください。
```typescript
// constants/api.ts(抜粋)
const BASE_URL = 'https://jr-shikoku-api-data-storage.haruk.in';
// constants/api.ts
export const API_ENDPOINTS = {
DIAGRAM_TODAY: `${BASE_URL}/tmp/diagram-today.json`,
OPERATION_INFO: `${BASE_URL}/operation-info/jr-shikoku/latest.json`,
DELAY_INFO: `${BASE_URL}/derived/delays/latest.json`,
CURRENT_POSITIONS: `${BASE_URL}/tmp/currentPositions.json`,
// ...
TRAIN_DATA_API: 'https://api.haruk.in/dev/jrshikoku/trainList',
DIAGRAM_TODAY: 'https://api.haruk.in/dev/jrshikoku/diagram/today',
STATION_LIST: 'https://storage.haruk.in/s/station.json',
// ... 他8個
};
```
データ取得の全体像は [docs/data-fetch-map.md](./docs/data-fetch-map.md) を参照してください。
## 🔗 外部からのアプリ起動(ディープリンク)
## � 外部からのアプリ起動(ディープリンク)
本アプリは複数の方法で外部から起動・画面遷移できます。
@@ -249,14 +185,14 @@ export const API_ENDPOINTS = {
| URL | 遷移先 |
|---|---|
| `jrshikoku://open/felica`(`felicahistorypage` も可) | FeliCa履歴ページ |
| `jrshikoku://open/felica` | FeliCa履歴ページ |
| `jrshikoku://open/traininfo` | 遅延速報EX |
| `jrshikoku://open/operation` | 運行情報 |
| `jrshikoku://open/settings` | 設定 |
| `jrshikoku://open/topmenu` | トップメニュー |
| `jrshikoku://positions/apps` | 走行位置 |
URLパターンの定義は `lib/startupDeepLink.ts`、遷移処理は `lib/useStartupDeepLinkNavigation.ts` の `routeFromUrl` で実装されています。
URLの処理は `App.tsx` の `routeFromUrl` で実装されています。
### Androidウィジェット
@@ -268,32 +204,15 @@ URLパターンの定義は `lib/startupDeepLink.ts`、遷移処理は `lib/useS
| `JR_shikoku_apps_shortcut` | クイックアクセス(各機能へのショートカットタイル) |
| `JR_shikoku_felica_balance` | ICカード残高表示 |
| `JR_shikoku_info` | 運行情報 |
| `JR_shikoku_train_strange` | 怪レい列車 |
ウィジェットのタップ時は `jrshikoku://` スキームでアプリ内画面へ遷移します。
配置可能なウィジェットは `app.json` の `react-native-android-widget` プラグイン設定で登録し、描画は `components/AndroidWidget/widget-task-handler.tsx` で行います。
#### 未公開: 怪レい列車ウィジェット(`JR_shikoku_train_strange`)
公開する予定はありますが、現在は未完成のため `app.json` に登録しておらず、ホーム画面には配置できません。
- `components/AndroidWidget/StrangeTrainWidget.tsx` と `widget-task-handler.tsx` に描画処理はありますが、表示内容は固定文の仮実装です。
- ウィジェットに表示するデータの取得先(エンドポイント)がまだ用意されていません。
- タップ先は仮で `jrshikoku://open/traininfo`(遅延速報EX)になっています。
- 公開時は、データ取得の実装、プレビュー画像(`assets/widgetResource/`)の追加、`app.json` への登録が必要です。登録はネイティブ設定の変更になるため、OTAではなく新しいビルドで配布する必要があります。
怪レい列車の通知(通知設定、通知アクション `strange-train`)は、ウィジェットとは別に実装されています。
実装: `components/AndroidWidget/widget-task-handler.tsx`
### iOSウィジェット(WidgetKit)
WidgetKit拡張として、次のホーム画面ウィジェットを提供しています。
- ショートカット
- ICカード残高
- 遅延速報
- 運行情報
Live Activitiesは、駅固定(StationLock)と列車追従(TrainFollow)に対応しています。
実装: `targets/widget/`
WidgetKit拡張としてホーム画面ウィジェットを提供しています。Live Activities にも対応しています。
設定: `targets/widget/Info.plist`
### プッシュ通知
@@ -309,40 +228,30 @@ Live Activitiesは、駅固定(StationLock)と列車追従(TrainFollow)
### NFC(FeliCa)
NFC-Fを利用した交通系ICカードの読み取りに、iOS・Androidとも対応しています。アプリ内からスキャンを開始する形式です。
実装: `modules/expo-felica-reader/`([7nohe/expo-felica-reader](https://github.com/7nohe/expo-felica-reader) をもとに改変、MIT)
NFC-Fを利用した交通系ICカードの読み取りに対応しています(Android)。アプリ内からスキャンを開始する形式です。
実装: `modules/expo-felica-reader/`
## 📱 ビルド
EASのprofileとchannelは `eas.json` で管理しています。主なprofileは次のとおりです。
| profile | 用途 |
|---|---|
| `development` | dev client(internal配布) |
| `preview` | 内部配布 |
| `production<version>` / `beta<version>` | バージョン別の本番・ベータ(例: `production7.2`、`beta7.2`) |
## �📱 ビルド
```bash
eas build --platform android --profile <profile>
eas build --platform ios --profile <profile>
# APKビルド(Android)
eas build --platform android
# IPAビルド(iOS)
eas build --platform ios
```
runtimeVersionの方針は [docs/architecture/adr/001-runtime-version-policy.md](./docs/architecture/adr/001-runtime-version-policy.md) を参照してください。
ネイティブ設定(`app.json` のプラグイン、permission、entitlement、ローカルモジュールなど)を変更した場合は、OTAではなく新しいビルドが必要です。
## 🤝 貢献
プルリクエストを歓迎します。大きな変更を行う場合は、まずissueを開いて変更内容を議論してください。
## 📄 ライセンス
本リポジトリは非公開です。無断での複製・再配布を禁止します(All Rights Reserved)。
- 同梱しているフォント・音声・ロゴなどの素材は、それぞれの権利者の規約に従います。
- 依存ライブラリのライセンスは、各パッケージの記載に従います。一覧はアプリの「設定 > オープンソースライセンス」で確認できます。
- `modules/expo-felica-reader/` は、Daiki Urata氏の [expo-felica-reader](https://github.com/7nohe/expo-felica-reader) 0.1.0(MIT License)をもとに改変したものです。ライセンス表記は `modules/expo-felica-reader/LICENSE` を参照してください。
本アプリはJR四国とは無関係の非公式アプリです。「JR四国」などの名称・商標は各権利者に帰属します。
[ライセンス情報をここに記載]
## 👤 開発者
harukin/Xprocess
[開発者情報をここに記載]
## 🔗 関連リンク
+9 -39
View File
@@ -5,56 +5,33 @@ import type { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
import { useColorScheme } from "react-native";
import Apps from "./components/Apps";
import TrainBase from "./components/trainbaseview";
import HowTo from "./howto";
import News from "./components/news";
import TrainMenu from "./components/trainMenu";
import { FavoriteList } from "./components/FavoriteList";
import { optionData } from "./lib/stackOption";
import { useCurrentTrainPolling, useCurrentTrainView } from "./stateBox/useCurrentTrain";
import {
useCustomTrainData,
useOperationLogs,
} from "./stateBox/useAllTrainDiagram";
import { useTrainMenu } from "./stateBox/useTrainMenu";
import { AS } from "./storageControl";
import { news } from "./config/newsUpdate";
import { InteractionManager, Platform, View } from "react-native";
import { InteractionManager, Linking, Platform, View } from "react-native";
import GeneralWebView from "./GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
import { telemetry } from "./lib/observability/telemetry";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "./lib/devStartupPerformance";
import { usePositionsStartupReady } from "./lib/positionsStartupContext";
import type { PositionsStackParamList, RootTabParamList } from "@/types/navigation";
const Stack = createStackNavigator<PositionsStackParamList>();
const positionsStackTelemetry = telemetry.scope("positions.stack");
export const Top = () => {
markStartupOnce("positions-stack-render", "positions Top render");
const { webview } = useCurrentTrainView();
const navigation = useNavigation<BottomTabNavigationProp<RootTabParamList>>();
const { navigate, addListener } = navigation;
const isTabFocused = useIsFocused();
const { notifyStackReady } = usePositionsStartupReady();
useCurrentTrainPolling(isTabFocused);
const { activateCustomTrainData } = useCustomTrainData();
const { activateOperationLogs } = useOperationLogs();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const [hasActivatedStack, setHasActivatedStack] = useState(false);
useEffect(() => {
if (!isTabFocused) return;
activateCustomTrainData();
activateOperationLogs();
}, [
activateCustomTrainData,
activateOperationLogs,
isTabFocused,
]);
//地図用
const { mapSwitch } = useTrainMenu();
const mapSwitchRef = useRef(mapSwitch);
@@ -77,7 +54,6 @@ export const Top = () => {
}, []);
useEffect(() => {
markStartupOnce("positions-stack-mounted", "positions stack mounted");
positionsStackTelemetry.info("positions stack mounted");
return () => {
positionsStackTelemetry.info("positions stack unmounted");
@@ -86,9 +62,6 @@ export const Top = () => {
useEffect(() => {
if (hasActivatedStack) return;
markStartup("positions stack activation scheduled", {
focused: isTabFocused,
});
positionsStackTelemetry.breadcrumb({
message: "positions stack activation scheduled",
data: {
@@ -98,10 +71,6 @@ export const Top = () => {
let cancelled = false;
const activate = () => {
if (cancelled) return;
markStartupOnce(
"positions-stack-activated",
"positions stack activation committed",
);
positionsStackTelemetry.breadcrumb({
message: "positions stack activation committed",
data: {
@@ -109,7 +78,6 @@ export const Top = () => {
},
});
setHasActivatedStack(true);
notifyStackReady();
};
if (isTabFocused) {
@@ -127,13 +95,9 @@ export const Top = () => {
cancelled = true;
clearTimeout(timer);
};
}, [hasActivatedStack, isTabFocused, notifyStackReady]);
}, [hasActivatedStack, isTabFocused]);
useEffect(() => {
markStartupState("positions-stack-gate", "positions stack gate state", {
focused: isTabFocused,
activated: hasActivatedStack,
});
telemetry.context("positions_stack", {
focused: isTabFocused,
activated: hasActivatedStack,
@@ -167,6 +131,11 @@ export const Top = () => {
stackIndex: stackNav?.getState()?.index ?? null,
},
});
if (Platform.OS === "web") {
Linking.openURL("https://train.jr-shikoku.co.jp/");
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
return;
}
if (!hasActivatedStack || !stackNav) {
e?.preventDefault?.();
positionsStackTelemetry.breadcrumb({
@@ -270,6 +239,7 @@ export const Top = () => {
options={{ ...optionData, gestureEnabled: false }}
component={StationDiagramView}
/>
<Stack.Screen name="howto" options={optionData} component={HowTo} />
<Stack.Screen
name="generalWebView"
options={optionData}
+2 -1
View File
@@ -5,7 +5,8 @@
"scheme": "jrshikoku",
"platforms": [
"ios",
"android"
"android",
"web"
],
"version": "7.2",
"userInterfaceStyle": "automatic",
File diff suppressed because one or more lines are too long
@@ -9,7 +9,6 @@ export const EachTrainInfo = ({ payload }: SheetProps<"EachTrainInfo">) => {
const actionSheetRef = useRef(null);
const maxHeight = useSheetMaxHeight();
const [sheetOpened, setSheetOpened] = useState(false);
useCurrentTrainPolling(sheetOpened);
const handleOpen = () => {
@@ -32,6 +32,7 @@ export const DataConnectedButton: FC<{
openTrainInfo(time);
}}
key={station + time}
>
<View style={{ flexDirection: "row", backgroundColor: colors.backgroundTertiary }}>
<View
@@ -1,9 +1,9 @@
import React, { FC } from "react";
import { Platform, View, Text, TouchableWithoutFeedback } from "react-native";
import type { Dayjs } from "dayjs";
import { View, Text, TouchableWithoutFeedback } from "react-native";
import dayjs from "dayjs";
import { setClockTime } from "@/lib/timeUtils";
import lineColorList from "@/assets/originData/lineColorList";
import type { trainDataType } from "@/lib/trainPositionTextArray";
import lineColorList from "../../../assets/originData/lineColorList";
import { trainDataType } from "@/lib/trainPositionTextArray";
import { getStopListColors } from "./colorScheme";
import {
isCanceledSe,
@@ -12,89 +12,90 @@ import {
parseSeString,
} from "@/utils/seUtils";
import type { SeTypes } from "@/types";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
type TrainDelay = trainDataType["delay"];
type seTypes =
| "発編"
| "着編"
| "通編"
| "頃編"
| "発"
| "着"
| "休編"
| "休発"
| "休着"
| "休発編"
| "休着編"
| "通休編"
| "通発休編"
| "通着休編"
| string;
export type EachStopData = readonly [
station: string,
se: SeTypes,
time: string,
platformNum?: string,
];
type ResponsiveScale = {
fontScale: (size: number) => number;
moderateScale: (size: number, factor?: number) => number;
type currentTrainDataType = {
Index: number;
num: string;
delay: "入線" | number | undefined;
Pos: string;
PosNum: string | number;
Direction: number;
Type: string;
Line: string;
};
type StationInfo = { StationName: string; StationNumber: string | null };
type props = {
stationNumberMap: ReadonlyMap<string, readonly string[]>;
parsedTrainData: readonly EachStopData[];
clockBase: Dayjs;
borderColor: string;
textOnPrimary: string;
isDark: boolean;
trainDelay: TrainDelay;
fontScale: ResponsiveScale["fontScale"];
moderateScale: ResponsiveScale["moderateScale"];
i: string;
index: number;
stationList: StationInfo[][];
points: boolean;
currentTrainData?: currentTrainDataType;
openStationACFromEachTrainInfo?: (station: string) => void;
showThrew: boolean;
array: string[];
isNotService?: boolean;
};
const EachStopListComponent: FC<props> = ({
stationNumberMap,
parsedTrainData,
clockBase,
borderColor,
textOnPrimary,
isDark,
trainDelay,
fontScale,
moderateScale,
export const EachStopList: FC<props> = ({
i,
index,
stationList,
points,
currentTrainData,
openStationACFromEachTrainInfo,
showThrew,
array,
isNotService = false,
}) => {
// console.log({
// trainNum,
// index,
// points,
// //openStationACFromEachTrainInfo,
// showThrew,
// //array,
// isNotService,
// });
const currentData = parsedTrainData[index];
if (!currentData) return null;
const [station, se, time, platformNum] = currentData; // 阿波池田,発,6:21,1
let beforeSameStationData: EachStopData | null = null;
const { colors: themeColors, isDark } = useThemeColors();
const { fontScale, moderateScale } = useResponsive();
const [station, se, time, platformNum] = i.split(",") as [
string,
seTypes,
string,
string
]; // 阿波池田,発,6:21,1
let beforeSameStationData = null;
// 発(通常発・休発・休発編)の場合、前の着(通常着・休着・休着編)と統合する
if (se.includes("発")) {
if (index > 0) {
const beforeData = parsedTrainData[index - 1];
const beforeData = array[index - 1].split(",") as [string, seTypes, string];
// 前が着(通常着でも休着でも)の場合は統合
if (beforeData && beforeData[0] == station && beforeData[1].includes("着")) {
if (beforeData[0] == station && beforeData[1].includes("着")) {
beforeSameStationData = beforeData;
}
}
}
let afterSameStationData: EachStopData | null = null;
let afterSameStationData = null;
// 着(通常着・休着・休着編)の場合、次の発(通常発・休発・休発編)と統合される(非表示)
if (se.includes("着")) {
const afterData = parsedTrainData[index + 1];
const afterData = array[index + 1]?.split(",") as [string, seTypes, string];
// 次が発(通常発でも休発でも)なら、この着を非表示にして次の発で両方表示
if (afterData && afterData[0] == station && afterData[1].includes("発")) {
afterSameStationData = afterData;
return <></>;
}
}
if (station.includes(".")) {
if(station.includes(".")){
return <></>;
}
if (!showThrew) {
@@ -106,11 +107,25 @@ const EachStopListComponent: FC<props> = ({
if (se == "通発休編") return null;
if (se == "通着休編") return null;
}
const StationNumbers = stationNumberMap.get(station) ?? [];
const Stations = stationList
.map((a) => a.filter((d) => d.StationName == station))
.reduce((newArray, e) => newArray.concat(e), []);
/*Array [
Object {
"Station_JP": "佐古",
"StationNumber": "T01",
},
Object {
"Station_JP": "佐古",
"StationNumber": "B01",
},
] */
const StationNumbers = Stations.filter((d) => d.StationNumber != null).map(
(d) => d.StationNumber as string
);
const trimmedPlatformNum = platformNum?.trim();
const hasPlatformNum = !!trimmedPlatformNum;
const isPlatformNumNumeric =
hasPlatformNum && /^\d+$/.test(trimmedPlatformNum);
const isPlatformNumNumeric = hasPlatformNum && /^\d+$/.test(trimmedPlatformNum);
const platformDisplay = hasPlatformNum
? isPlatformNumNumeric
? parseInt(trimmedPlatformNum, 10) === 0
@@ -126,7 +141,9 @@ const EachStopListComponent: FC<props> = ({
const isCanceled = isCanceledSe(se);
const isCommunity = isCommunitySe(se);
const isDelayed =
trainDelay !== undefined && trainDelay !== "入線" && trainDelay > 0;
currentTrainData?.delay !== undefined &&
currentTrainData?.delay !== "入線" &&
currentTrainData?.delay > 0;
const colors = getStopListColors(
isThrough,
@@ -134,7 +151,7 @@ const EachStopListComponent: FC<props> = ({
isCanceled,
isDelayed,
isNotService,
isDark,
isDark
);
// 打ち消し線用の通常色(遅延していない時の色)
const normalColors = getStopListColors(
@@ -143,7 +160,7 @@ const EachStopListComponent: FC<props> = ({
isCanceled,
false,
isNotService,
isDark,
isDark
);
// beforeSameStationData用の色設定
@@ -162,7 +179,7 @@ const EachStopListComponent: FC<props> = ({
isCanceled,
isDelayed,
isNotService,
isDark,
isDark
);
const beforeNormalColors = getStopListColors(
beforeIsThrough,
@@ -170,47 +187,19 @@ const EachStopListComponent: FC<props> = ({
isCanceled,
false,
isNotService,
isDark,
isDark
);
beforeTimeTextColor = beforeColors.timeText;
beforeNormalTimeTextColor = beforeNormalColors.timeText;
}
const hasDoubleTime = !!beforeSameStationData || !!afterSameStationData;
const beforeTimeText = beforeSameStationData ? (
<TimeText
isDouble={hasDoubleTime}
isBefore={true}
delay={trainDelay}
se={beforeSameStationData[1]}
time={beforeSameStationData[2]}
textColor={beforeTimeTextColor}
normalTextColor={beforeNormalTimeTextColor}
clockBase={clockBase}
fontScale={fontScale}
moderateScale={moderateScale}
/>
) : null;
const mainTimeText = (
<TimeText
isDouble={hasDoubleTime}
delay={trainDelay}
se={se}
time={time}
textColor={colors.timeText}
normalTextColor={normalColors.timeText}
clockBase={clockBase}
fontScale={fontScale}
moderateScale={moderateScale}
/>
);
return (
<TouchableWithoutFeedback
onPress={() =>
openStationACFromEachTrainInfo &&
openStationACFromEachTrainInfo(station)
}
key={station + se + time}
>
<View
style={{
@@ -225,56 +214,50 @@ const EachStopListComponent: FC<props> = ({
position: "relative",
marginHorizontal: 15,
flexDirection: "row",
alignSelf: "stretch",
height: "101%",
}}
>
{StationNumbers.map((stn) => (
<StationNumbersBox
key={stn}
stn={stn}
se={se}
fontScale={fontScale}
textColor={textOnPrimary}
/>
))}
>
{StationNumbers.map((stn, index) => (
<StationNumbersBox stn={stn} se={se} key={index} />
))}
</View>
{points && (
<View
style={{
position: "relative",
width: 0,
alignSelf: "stretch",
alignItems: "flex-end",
justifyContent: "center",
}}
>
<View style={{ position: "relative", height: 25 }}>
<Text
style={{
fontSize: fontScale(20),
position: "absolute",
textAlignVertical: "center",
left: -15,
top: "auto",
}}
>
🚊
</Text>
</View>
<View
style={{
position: "relative",
flexDirection: "column",
width: 0,
height: "100%",
alignItems: "flex-end",
}}
>
<View style={{ flex: 1 }} />
<View style={{ position: "relative", height: 25 }}>
<Text
style={{
fontSize: fontScale(20),
position: "absolute",
textAlignVertical: "center",
left: -15,
top: "auto",
}}
>
{points && "🚊"}
</Text>
</View>
)}
<View style={{ flex: 1 }} />
</View>
<View
style={{
padding: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: borderColor,
borderBottomColor: themeColors.borderLight,
flex: 1,
}}
>
<View style={{ flexDirection: "column" }}>
<View style={{ flex: 1 }} />
<Text
style={{
fontSize: fontScale(20),
@@ -295,55 +278,64 @@ const EachStopListComponent: FC<props> = ({
{platformDisplay}
</Text>
)}
</Text>
{hasDoubleTime ? (
<View style={{ flexDirection: "column", alignItems: "flex-end" }}>
{beforeTimeText}
{mainTimeText}
</View>
) : (
mainTimeText
)}
</Text>
<View style={{ flex: 1 }} />
</View>
<View style={{ flex: 1 }} />
<View style={{ flexDirection: "column", alignItems: "flex-end" }}>
{beforeSameStationData && (
<TimeText
isDouble={!!beforeSameStationData || !!afterSameStationData}
isBefore={true}
currentTrainData={currentTrainData}
se={beforeSameStationData[1]}
time={beforeSameStationData[2]}
key={"before" + beforeSameStationData[2]}
textColor={beforeTimeTextColor}
normalTextColor={beforeNormalTimeTextColor}
/>
)}
<TimeText
isDouble={!!beforeSameStationData || !!afterSameStationData}
currentTrainData={currentTrainData}
se={se}
time={time}
key={se + time}
textColor={colors.timeText}
normalTextColor={normalColors.timeText}
/>
</View>
</View>
</View>
</TouchableWithoutFeedback>
);
};
export const EachStopList = React.memo(EachStopListComponent);
const TimeTextComponent: FC<{
const TimeText: FC<{
isBefore?: boolean;
isDouble: boolean;
delay: TrainDelay;
currentTrainData: trainDataType;
se: string;
time: string;
textColor: string;
normalTextColor: string;
clockBase: Dayjs;
} & ResponsiveScale> = ({
}> = ({
isDouble,
delay,
currentTrainData,
se,
time,
isBefore = false,
textColor,
normalTextColor,
clockBase,
fontScale,
moderateScale,
}) => {
const [seString, seType] = parseSeString(se);
const { fontScale, moderateScale } = useResponsive();
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
opacity: isBefore ? 0.5 : 1,
}}
>
{time !== "" && !!delay && delay != "入線" && delay != 0 && (
<View style={{ flexDirection: "row", alignItems: "center", opacity: isBefore ? 0.5 : 1 }}>
{!!currentTrainData?.delay &&
currentTrainData?.delay != "入線" &&
currentTrainData?.delay != 0 && (
<Text
style={{
fontSize: fontScale(isBefore ? 14 : 15),
@@ -362,15 +354,12 @@ const TimeTextComponent: FC<{
)}
<StationTimeBox
isBefore={isBefore}
delay={delay}
delay={currentTrainData?.delay}
textColor={textColor}
seType={seType}
se={se}
time={time}
isDouble={isDouble}
clockBase={clockBase}
fontScale={fontScale}
moderateScale={moderateScale}
/>
<Text
style={{
@@ -386,15 +375,10 @@ const TimeTextComponent: FC<{
</View>
);
};
const TimeText = React.memo(TimeTextComponent);
const StationNumbersBoxComponent: FC<{
stn: string;
se: SeTypes;
textColor: string;
fontScale: ResponsiveScale["fontScale"];
}> = (props) => {
const { stn, se, textColor, fontScale } = props;
const StationNumbersBox: FC<{ stn: string; se: seTypes }> = (props) => {
const { stn, se } = props;
const { fixed } = useThemeColors();
const { fontScale } = useResponsive();
const lineColor = lineColorList[stn.charAt(0)];
const hasThrew =
se == "通過" ||
@@ -407,74 +391,44 @@ const StationNumbersBoxComponent: FC<{
? "80"
: "";
const backgroundColor = `${lineColor}${hasThrew}`;
const stationNumberLabel = `${stn.charAt(0)}\n${stn.slice(1)}`;
const stationNumberTextStyle = {
color: textColor,
textAlign: "center" as const,
fontSize: fontScale(10),
fontWeight: "bold" as const,
};
if (Platform.OS === "ios") {
return (
<View
return (
<View style={{ backgroundColor, flex: 1 }} key={stn}>
<View style={{ flex: 1 }} />
<Text
style={{
backgroundColor,
flex: 1,
justifyContent: "center",
color: fixed.textOnPrimary,
textAlign: "center",
fontSize: fontScale(10),
fontWeight: "bold",
}}
>
<Text style={stationNumberTextStyle}>{stationNumberLabel}</Text>
</View>
);
}
return (
<Text
style={{
...stationNumberTextStyle,
backgroundColor,
flex: 1,
textAlignVertical: "center",
}}
>
{stationNumberLabel}
</Text>
{stn.charAt(0)}
{"\n"}
{stn.slice(1)}
</Text>
<View style={{ flex: 1 }} />
</View>
);
};
type StationTimeBoxType = {
isBefore?: boolean;
delay: TrainDelay;
delay: "入線" | number | undefined;
textColor: string;
seType: SeTypes;
seType: seTypes;
se: string;
time: string;
isDouble: boolean;
clockBase: Dayjs;
} & ResponsiveScale;
};
const StationNumbersBox = React.memo(StationNumbersBoxComponent);
const StationTimeBoxComponent: FC<StationTimeBoxType> = (props) => {
const {
delay,
textColor,
seType,
se,
const StationTimeBox: FC<StationTimeBoxType> = (props) => {
const { delay, textColor, seType, se, time, isDouble, isBefore } = props;
const { fontScale, moderateScale } = useResponsive();
const dates = setClockTime(
dayjs(),
time,
isDouble,
isBefore,
clockBase,
fontScale,
moderateScale,
} = props;
const timeData = setClockTime(
clockBase,
time,
delay == "入線" || delay == undefined ? 0 : delay,
)?.format("HH:mm") ?? null;
delay == "入線" || delay == undefined ? 0 : delay
);
return (
<Text
@@ -486,9 +440,7 @@ const StationTimeBoxComponent: FC<StationTimeBoxType> = (props) => {
fontStyle: seType == "community" ? "italic" : "normal",
}}
>
{se.includes("通") && time == "" ? "レ" : (timeData ?? "")}
{se.includes("通") && time == "" ? "レ" : dates?.format("HH:mm") ?? ""}
</Text>
);
};
const StationTimeBox = React.memo(StationTimeBoxComponent);
@@ -1,14 +1,23 @@
import React, { FC } from "react";
import { View } from "react-native";
import { TrainDataView } from "./TrainDataView";
import type { TrainDataViewHeaderProps } from "./TrainDataView";
type props = TrainDataViewHeaderProps;
export const LongHeader: FC<props> = ({
import { trainDataType } from "@/lib/trainPositionTextArray";
import type { NavigateFunction } from "@/types";
type props = {
currentTrainData: trainDataType;
currentTrainDataList?: trainDataType[];
currentPosition: string[] | undefined;
currentPositionList?: string[][];
nearTrainIDList: string[];
openTrainInfo: (f: string) => void;
navigate: NavigateFunction;
}
export const LongHeader:FC<props> = ({
currentTrainData,
currentTrainDataList,
nearTrainIDList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}) => {
@@ -22,13 +31,14 @@ export const LongHeader: FC<props> = ({
}}
>
<TrainDataView
nearTrainIDList={nearTrainIDList}
currentTrainData={currentTrainData}
currentTrainDataList={currentTrainDataList}
currentPosition={currentPosition}
currentPositionList={currentPositionList}
nearTrainIDList={nearTrainIDList}
openTrainInfo={openTrainInfo}
navigate={navigate}
key={"LongHeader"}
/>
</View>
);
@@ -8,194 +8,131 @@ type stateBox = {
currentTrainData: trainDataType | undefined;
title: string;
style?: ViewStyle;
mode?: 0 | 1 | 2;
mode?: number;
};
export const PositionBox: FC<stateBox> = (props) => {
const { currentTrainData, title, style, mode } = props;
const { fontScale } = useResponsive();
const mainFontSize = mode == 2 ? fontScale(16) : fontScale(23);
const {
currentTrainData,
title,
style,
mode,
} = props;
const { colors } = useThemeColors();
const { officialPosition } = currentTrainData || {};
const { fontScale } = useResponsive();
const {officialPosition} = currentTrainData || {};
const isStationPosition = officialPosition?.positionType === "station";
const platformNumber = isStationPosition
? officialPosition?.platform
: undefined;
const lineNumber = isStationPosition ? officialPosition?.track : undefined;
const platformDescription = officialPosition?.note || ``;
// Notes describe the current position even when it is a prediction or a
// section between stations. Platform/track remain station-only fields, but
// the note itself must not be hidden by position_type.
const platformDescription = officialPosition?.note;
let firstText = "";
let secondText = "";
let marginText = "";
let externalText = "";
const { isBetween, Pos: PosData } = trainPosition(currentTrainData);
if (isBetween === true) {
const { from, to } = PosData;
const firstText = from;
const secondText = to;
const marginText = mode == 2 ? "→" : "↓";
return (
firstText = from;
secondText = to;
marginText = mode == 2 ? "→" : "↓";
} else {
const { Pos } = PosData;
if (Pos !== "") {
firstText = Pos;
if (platformNumber) {
secondText = `${platformNumber}番乗り場`;
if (lineNumber) {
externalText = `${lineNumber}番線`;
}
} else if (lineNumber) {
secondText = `${lineNumber}番線`;
}
}
}
return (
<View style={{ ...(mode == 2 ? boxStyle2 : boxStyle), backgroundColor: colors.surface, ...style }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{ flexShrink: 1, fontSize: fontScale(12), color: colors.textAccent }}
>
{title}
</Text>
<View style={{ flex: 1 }} />
<View
style={{
...(mode == 2 ? boxStyle2 : boxStyle),
backgroundColor: colors.surface,
...style,
minWidth: 0,
flexShrink: 1,
flexDirection: mode == 2 ? "row" : "column",
}}
>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
fontSize: fontScale(12),
color: colors.textAccent,
}}
>
{title}
</Text>
<View style={{ flex: 1 }} />
<View
style={{
minWidth: 0,
flexShrink: 1,
flexDirection: mode == 2 ? "row" : "column",
}}
>
{firstText && (
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
textAlign: "right",
fontSize: mainFontSize,
color: colors.textAccent,
}}
style={[
{ flexShrink: 1, minWidth: 0 },
mode == 2
? { fontSize: fontScale(16), textAlign: "right" as const }
: isBetween
? { fontSize: fontScale(23), textAlign: "right" as const }
: { fontSize: fontScale(23), textAlign: "right" as const },
{ color: colors.textAccent },
]}
>
{firstText}
</Text>
)}
{marginText && (
<Text style={{ color: colors.textAccent, textAlign: "right" }}>
{marginText}
</Text>
)}
{secondText && (
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
textAlign: "right",
fontSize: mainFontSize,
color: colors.textAccent,
}}
style={[
{ flexShrink: 1, minWidth: 0 },
mode == 2
? { fontSize: fontScale(16), textAlign: "right" as const }
: isBetween
? { fontSize: fontScale(23), textAlign: "right" as const }
: { fontSize: fontScale(14), textAlign: "right" as const },
{ color: colors.textAccent },
]}
>
{secondText}
</Text>
</View>
{platformDescription && (
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
textAlign: "right",
fontSize: fontScale(10),
color: colors.textAccent,
}}
>
{" " + platformDescription}
</Text>
</View>
)}
</View>
);
} else {
const { Pos } = PosData;
const subFontSize = mode == 2 ? fontScale(16) : fontScale(14);
const firstText = Pos !== "" ? Pos : "";
let secondText = "";
let externalText = "";
if (platformNumber) {
secondText = `${platformNumber}番乗り場`;
externalText = lineNumber ? `${lineNumber}番線` : "";
} else if (lineNumber) {
secondText = `${lineNumber}番線`;
}
return (
<View
style={{
...(mode == 2 ? boxStyle2 : boxStyle),
backgroundColor: colors.surface,
...style,
}}
>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
fontSize: fontScale(12),
color: colors.textAccent,
}}
>
{title}
</Text>
<View style={{ flex: 1 }} />
<View
style={{
minWidth: 0,
flexShrink: 1,
flexDirection: mode == 2 ? "row" : "column",
}}
>
{(platformDescription || externalText) && (
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
fontSize: mainFontSize,
textAlign: "right",
...(mode == 2
? { fontSize: fontScale(16), textAlign: "right" as const }
: { fontSize: fontScale(23), textAlign: "right" as const }),
fontSize: fontScale(10),
color: colors.textAccent,
}}
>
{firstText}
</Text>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
fontSize: subFontSize,
textAlign: "right",
color: colors.textAccent,
}}
>
{secondText}
{" " + externalText}
{" " + platformDescription}
</Text>
</View>
{(platformDescription || externalText) && (
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
textAlign: "right",
fontSize: fontScale(10),
color: colors.textAccent,
}}
>
{" " + externalText}
{" " + platformDescription}
</Text>
</View>
)}
</View>
);
}
)}
</View>
);
};
const boxStyle: ViewStyle = {
flex: 1,
@@ -1,27 +1,12 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import type { trainDataType } from "@/lib/trainPositionTextArray";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
type Props = {
trainDelay: trainDataType["delay"];
showThrew: boolean;
setShowThrew: React.Dispatch<React.SetStateAction<boolean>>;
haveThrough: boolean;
};
export const ScrollStickyContent = ({
trainDelay,
showThrew,
setShowThrew,
haveThrough,
}: Props) => {
export const ScrollStickyContent = (props) => {
const { currentTrainData, showThrew, setShowThrew, haveThrough } = props;
const { colors } = useThemeColors();
const { fontScale } = useResponsive();
const hasNumericDelay =
typeof trainDelay === "number" && !Number.isNaN(trainDelay);
const hasDelay = hasNumericDelay && trainDelay !== 0;
return (
<View
style={{
@@ -42,7 +27,7 @@ export const ScrollStickyContent = ({
<Text style={{ fontSize: fontScale(20), color: colors.text }}>停車駅</Text>
<View style={{ flex: 1 }} />
<View style={{ flexDirection: "row" }}>
{hasDelay && (
{!isNaN(currentTrainData?.delay) && currentTrainData?.delay != 0 && (
<Text
style={{
fontSize: fontScale(15),
@@ -59,7 +44,11 @@ export const ScrollStickyContent = ({
<Text
style={{
fontSize: fontScale(20),
color: hasDelay ? colors.textError : colors.text,
color: isNaN(currentTrainData?.delay)
? colors.text
: currentTrainData?.delay == 0
? colors.text
: colors.textError,
width: 60,
}}
>
@@ -1,14 +1,23 @@
import React, { FC } from "react";
import { View } from "react-native";
import { TrainDataView } from "./TrainDataView";
import type { TrainDataViewHeaderProps } from "./TrainDataView";
type props = TrainDataViewHeaderProps;
export const ShortHeader: FC<props> = ({
import { trainDataType } from "@/lib/trainPositionTextArray";
import type { NavigateFunction } from "@/types";
type props = {
currentTrainData: trainDataType;
currentTrainDataList?: trainDataType[];
currentPosition: string[] | undefined;
currentPositionList?: string[][];
nearTrainIDList: string[];
openTrainInfo: (f: string) => void;
navigate: NavigateFunction;
}
export const ShortHeader:FC<props> = ({
currentTrainData,
currentTrainDataList,
nearTrainIDList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}) => {
@@ -22,14 +31,15 @@ export const ShortHeader: FC<props> = ({
}}
>
<TrainDataView
nearTrainIDList={nearTrainIDList}
mode={2}
currentTrainData={currentTrainData}
currentTrainDataList={currentTrainDataList}
currentPosition={currentPosition}
currentPositionList={currentPositionList}
mode={2}
nearTrainIDList={nearTrainIDList}
openTrainInfo={openTrainInfo}
navigate={navigate}
key={"ShortHeader"}
/>
</View>
);
@@ -1,5 +1,5 @@
import React, { FC } from "react";
import { View, Text, ViewStyle } from "react-native";
import React, { CSSProperties, FC } from "react";
import { View, Text, StyleProp, ViewStyle } from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
@@ -7,7 +7,7 @@ type stateBox = {
text: string;
title: string;
style?: ViewStyle;
mode?: 0 | 1 | 2;
mode?: number;
endText?: string;
};
export const StateBox: FC<stateBox> = (props) => {
@@ -1,166 +1,63 @@
import React, { useState, FC } from "react";
import React, { useState, useEffect, FC } from "react";
import {
ScrollView,
Text,
View,
TouchableOpacity,
ViewStyle,
useWindowDimensions,
} from "react-native";
import { ScrollView } from "react-native-actions-sheet";
import { StateBox } from "./StateBox";
import { PositionBox } from "./PositionBox";
import { useCurrentTrainView } from "@/stateBox/useCurrentTrain";
import { useCurrentTrainView } from "../../../stateBox/useCurrentTrain";
import { SheetManager } from "react-native-actions-sheet";
import type { trainDataType } from "@/lib/trainPositionTextArray";
import { trainDataType } from "@/lib/trainPositionTextArray";
import { useCustomTrainData } from "@/stateBox/useAllTrainDiagram";
import { customTrainDataDetector } from "@/components/custom-train-data";
import type { NavigateFunction } from "@/types";
import { stackAwareNavigate } from "@/lib/rootNavigation";
import { OfficialPositionEditButton } from "@/components/OfficialPositionEditButton";
import { lineDisplayName } from "@/lib/lineDisplayName";
export type TrainDataViewData = {
type props = {
currentTrainData: trainDataType | undefined;
currentTrainDataList: trainDataType[];
currentPosition: string[];
currentPositionList: string[][];
};
export type TrainDataViewMode = 0 | 1 | 2;
export type TrainDataViewControls = {
currentTrainDataList?: trainDataType[];
currentPosition: string[] | undefined;
currentPositionList?: string[][];
showLineLabel?: boolean;
nearTrainIDList: string[];
openTrainInfo: (f: string) => void;
mode?: 0 | 1 | 2; //0:通常,1:コンパクト,2:横並び
navigate: NavigateFunction;
};
export type TrainDataViewHeaderProps = TrainDataViewData &
TrainDataViewControls;
type TrainDataViewProps = TrainDataViewHeaderProps & {
mode?: TrainDataViewMode; // 0:通常, 1:コンパクト, 2:横並び
};
export const TrainDataView: FC<TrainDataViewProps> = ({
const TrainDataViewRow: FC<props> = ({
currentTrainData,
currentTrainDataList,
currentPosition,
currentPositionList,
...controls
}) => {
const { width: windowWidth } = useWindowDimensions();
const rows = currentTrainDataList?.length
? currentTrainDataList
: [currentTrainData];
const positions = currentPositionList?.length
? currentPositionList
: [currentPosition ?? []];
const [activeIndex, setActiveIndex] = useState(0);
const [pageWidth, setPageWidth] = useState(windowWidth);
const hasMultipleRows = rows.length > 1;
const visibleActiveIndex = Math.min(
activeIndex,
Math.max(rows.length - 1, 0),
);
return (
<View
onLayout={(event) => {
const nextWidth = event.nativeEvent.layout.width;
setPageWidth((previous) =>
previous === nextWidth ? previous : nextWidth,
);
}}
style={{ flex: 1, width: "100%" }}
>
<ScrollView
horizontal
pagingEnabled={hasMultipleRows}
scrollEnabled={hasMultipleRows}
nestedScrollEnabled
directionalLockEnabled
showsHorizontalScrollIndicator={false}
style={{ flex: 1, width: "100%" }}
contentContainerStyle={{ flexGrow: 1, alignItems: "stretch" }}
onMomentumScrollEnd={(event) => {
const width = event.nativeEvent.layoutMeasurement.width || pageWidth;
if (!width) return;
setActiveIndex(
Math.max(
0,
Math.min(
rows.length - 1,
Math.round(event.nativeEvent.contentOffset.x / width),
),
),
);
}}
>
{rows.map((d, index) => (
<RenderRow
{...controls}
train={d}
position={positions[index] ?? []}
pageWidth={pageWidth}
showLineLabel={rows.length > 1}
index={index}
key={index}
/>
))}
</ScrollView>
{hasMultipleRows && (
<Text style={{ alignSelf: "center", fontSize: 11 }}>
{visibleActiveIndex + 1} / {rows.length}
</Text>
)}
</View>
);
};
type RenderRowProps = TrainDataViewRowProps & {
pageWidth: number;
index: number;
};
const RenderRow = (props: RenderRowProps) => {
const { pageWidth, index, ...rowProps } = props;
return (
<View
style={{
width: pageWidth || "100%",
flexGrow: 0,
flexShrink: 0,
alignSelf: "stretch",
}}
>
<TrainDataViewRow {...rowProps} />
</View>
);
};
type TrainDataViewRowProps = TrainDataViewControls & {
train: trainDataType | undefined;
position: string[];
showLineLabel: boolean;
mode?: TrainDataViewMode;
};
const TrainDataViewRow: FC<TrainDataViewRowProps> = ({
train,
nearTrainIDList,
position,
showLineLabel,
nearTrainIDList,
openTrainInfo,
mode = 0,
navigate,
}) => {
const trainNum = train?.num ?? "";
const currentTrainData = train;
const currentPosition = position;
const { width } = useWindowDimensions();
const { width} = useWindowDimensions();
const { setInjectData } = useCurrentTrainView();
const { allCustomTrainData } = useCustomTrainData();
const onLine = !!currentPosition?.toString().length;
const lineLabel = lineDisplayName(currentTrainData?.Line);
const [trainNumber, setTrainNumber] = useState(currentTrainData?.num);
useEffect(() => {
const { train_number_override } = customTrainDataDetector(
currentTrainData?.num,
allCustomTrainData,
);
if (train_number_override) {
setTrainNumber(train_number_override);
} else {
setTrainNumber(currentTrainData?.num);
}
}, [currentTrainData?.num, allCustomTrainData]);
const columnStyle = {
width: width / 2,
display: "flex",
@@ -194,7 +91,7 @@ const TrainDataViewRow: FC<TrainDataViewRowProps> = ({
display: "flex",
}}
>
<View style={{ flexDirection: "row", flex: 1, ...columnStyle }}>
<View style={{ flexDirection: "row",flex:1,...columnStyle }}>
<TouchableOpacity
style={{ flex: 1, minWidth: 0, flexDirection: "row" }}
//disabled={!onLine}
@@ -202,7 +99,7 @@ const TrainDataViewRow: FC<TrainDataViewRowProps> = ({
if (!currentTrainData) return;
setInjectData({
type: "train",
value: trainNum,
value: currentTrainData?.num,
fixed: true,
});
stackAwareNavigate("positions");
@@ -242,11 +139,14 @@ const TrainDataViewRow: FC<TrainDataViewRowProps> = ({
</View>
<View
style={{
flexDirection: mode == 2 ? "row" : "column",
flex: 1,
...columnStyle,
}}
style={
{
flexDirection: mode == 2 ? "row" : "column",
flex: 1,
...columnStyle
}
}
>
<View style={rightItemStyle}>
<StateBox
@@ -274,7 +174,7 @@ const TrainDataViewRow: FC<TrainDataViewRowProps> = ({
}}
>
{nearTrainIDList.length == 0 ? (
<StateBox mode={mode} title="列番" text={trainNum} />
<StateBox mode={mode} title="列番" text={trainNumber} />
) : (
<StateBox
mode={mode}
@@ -292,3 +192,82 @@ const TrainDataViewRow: FC<TrainDataViewRowProps> = ({
</View>
);
};
export const TrainDataView: FC<props> = ({
currentTrainData,
currentTrainDataList,
currentPosition,
currentPositionList,
...rest
}) => {
const rows = currentTrainDataList?.length
? currentTrainDataList
: [currentTrainData];
const positions = currentPositionList?.length
? currentPositionList
: [currentPosition ?? []];
const [activeIndex, setActiveIndex] = useState(0);
const [pageWidth, setPageWidth] = useState(0);
useEffect(() => {
setActiveIndex((index) => Math.min(index, Math.max(rows.length - 1, 0)));
}, [rows.length]);
const renderRow = (train: trainDataType | undefined, index: number) => (
<View
key={`${train?.Line ?? "unknown"}-${String(train?.PosNum ?? "")}-${index}`}
style={{
width: pageWidth || "100%",
flexGrow: 0,
flexShrink: 0,
alignSelf: "stretch",
}}
>
<TrainDataViewRow
{...rest}
currentTrainData={train}
currentPosition={positions[index] ?? []}
showLineLabel={rows.length > 1}
/>
</View>
);
const hasMultipleRows = rows.length > 1;
return (
<View
onLayout={(event) => setPageWidth(event.nativeEvent.layout.width)}
style={{ flex: 1, width: "100%" }}
>
<ScrollView
horizontal
pagingEnabled={hasMultipleRows}
scrollEnabled={hasMultipleRows}
nestedScrollEnabled
showsHorizontalScrollIndicator={false}
style={{ flex: 1, width: "100%" }}
contentContainerStyle={{ flexGrow: 1, alignItems: "stretch" }}
onMomentumScrollEnd={(event) => {
const width = event.nativeEvent.layoutMeasurement.width || pageWidth;
if (!width) return;
setActiveIndex(
Math.max(
0,
Math.min(
rows.length - 1,
Math.round(event.nativeEvent.contentOffset.x / width),
),
),
);
}}
>
{rows.map(renderRow)}
</ScrollView>
{hasMultipleRows && (
<Text style={{ alignSelf: "center", fontSize: 11 }}>
{activeIndex + 1} / {rows.length}
</Text>
)}
</View>
);
};
@@ -1,5 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import React, { useEffect, useRef, useState } from "react";
import {
View,
Text,
@@ -10,9 +9,9 @@ import {
Linking,
} from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { getTrainType } from "@/lib/getTrainType";
import { getTrainType } from "../../lib/getTrainType";
import { customTrainDataDetector } from "../custom-train-data";
import { EachStopList, type EachStopData } from "./EachTrainInfo/EachStopList";
import { EachStopList } from "./EachTrainInfo/EachStopList";
import { DataConnectedButton } from "./EachTrainInfo/DataConnectedButton";
import { DynamicHeaderScrollView } from "../DynamicHeaderScrollView";
import { LongHeader } from "./EachTrainInfo/LongHeader";
@@ -20,13 +19,10 @@ import { ShortHeader } from "./EachTrainInfo/ShortHeader";
import { ScrollStickyContent } from "./EachTrainInfo/ScrollStickyContent";
import { ShowSpecialTrain } from "./EachTrainInfo/ShowSpecialTrain";
import { HeaderText } from "./EachTrainInfoCore/HeaderText";
import { useStationList } from "@/stateBox/useStationList";
import { useCurrentTrainData } from "@/stateBox/useCurrentTrain";
import { useStationList } from "../../stateBox/useStationList";
import { useCurrentTrainData } from "../../stateBox/useCurrentTrain";
import { useThemeColors } from "@/lib/theme";
import {
useCustomTrainData,
useOperationLogs,
} from "@/stateBox/useAllTrainDiagram";
import { useCustomTrainData } from "@/stateBox/useAllTrainDiagram";
import { useResponsive } from "@/lib/responsive";
// Custom hooks
@@ -47,11 +43,6 @@ type EachTrainInfoCoreProps = {
sheetOpened?: boolean;
};
const parseEachStopData = (data: string): EachStopData => {
const [station = "", se = "", time = "", platformNum] = data.split(",");
return [station, se, time, platformNum];
};
export const EachTrainInfoCore = ({
trainNum,
openStationACFromEachTrainInfo,
@@ -60,56 +51,21 @@ export const EachTrainInfoCore = ({
sheetOpened = false,
}: EachTrainInfoCoreProps) => {
const from = fromValue ?? "";
const { allCustomTrainData, activateCustomTrainData } = useCustomTrainData();
const { activateOperationLogs } = useOperationLogs();
const { colors, fixed, isDark } = useThemeColors();
const { verticalScale, moderateScale, fontScale } = useResponsive();
const { stationList } = useStationList();
const { allCustomTrainData } = useCustomTrainData();
const { colors, fixed } = useThemeColors();
const { verticalScale, moderateScale } = useResponsive();
const { height } = useWindowDimensions();
const { getCurrentStationData } = useCurrentTrainData();
const { stationList } = useStationList();
useEffect(() => {
if (!sheetOpened) return;
activateCustomTrainData();
activateOperationLogs();
}, [
activateCustomTrainData,
activateOperationLogs,
sheetOpened,
]);
const stationNumberMap = useMemo(() => {
const map = new Map<string, string[]>();
for (const lineStations of stationList) {
for (const station of lineStations) {
if (station.StationNumber == null) continue;
const numbers = map.get(station.StationName);
if (numbers) {
numbers.push(station.StationNumber);
} else {
map.set(station.StationName, [station.StationNumber]);
}
}
}
return map;
}, [stationList]);
const scrollRef = useRef<any>(null);
// Custom hooks for data management
const { trainData, setTrainData, trueTrainID } =
useTrainDiagramData(trainNum);
const { trainDataWithThrough, haveThrough } = useThroughStations(trainData);
const {
showHeadStation,
showTailStation,
includedTrainIDs,
extendToHeadStation,
extendToTailStation,
} = useExtendedStations(trainNum, trainData, setTrainData);
const { headStation, tailStation, nearTrainIDList } = useNearbyTrains(
trainNum,
trainData,
includedTrainIDs,
);
const stopStationIDList = useStopStationIDs(trainDataWithThrough);
const {
@@ -121,15 +77,12 @@ export const EachTrainInfoCore = ({
pointsDisplay,
} = useTrainPosition(trainNum, stopStationIDList);
const parsedTrainData = useMemo<readonly EachStopData[]>(
() =>
trainDataWithThrough.map(parseEachStopData),
[trainDataWithThrough],
);
const clockBase = useMemo(
() => dayjs(),
[trainDataWithThrough, currentTrainData?.delay],
);
const {
showHeadStation,
showTailStation,
extendToHeadStation,
extendToTailStation,
} = useExtendedStations(trainData, setTrainData);
// UI state
// 走行中の列車は初期状態から通過駅を表示する(後から showThrew を true に変更すると
@@ -137,11 +90,15 @@ export const EachTrainInfoCore = ({
const [showThrew, setShowThrew] = useState(
() => !!getCurrentStationData(trainNum),
);
const [isJumped, setIsJumped] = useState(false);
// Auto scroll to current position
useAutoScroll(
points,
trainDataWithThrough,
scrollRef,
isJumped,
setIsJumped,
sheetOpened,
);
@@ -175,133 +132,6 @@ export const EachTrainInfoCore = ({
}, 200);
});
};
const renderTrainDataItem = (item: string, index: number) => {
const stopType = parsedTrainData[index]?.[1] ?? "";
if (stopType === "増" || stopType === "解") {
return (
<DataConnectedButton
i={item}
openTrainInfo={openTrainInfo}
/>
);
}
if (stopType.includes(".")) return null;
return (
<EachStopList
stationNumberMap={stationNumberMap}
parsedTrainData={parsedTrainData}
clockBase={clockBase}
borderColor={colors.borderLight}
textOnPrimary={fixed.textOnPrimary}
isDark={isDark}
trainDelay={currentTrainData?.delay}
fontScale={fontScale}
moderateScale={moderateScale}
index={index}
points={pointsDisplay?.[index] || false}
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
showThrew={showThrew}
isNotService={customTrainType.data === "notService"}
/>
);
};
const listHeader = (
<>
{customTrainType.data === "notService" && (
<Text
style={{
backgroundColor: colors.surface,
color: colors.text,
fontWeight: "bold",
}}
>
この列車には乗車できません。
</Text>
)}
{headStation.length > 0 &&
headStation.map(
(item) =>
!showHeadStation.includes(item.id) && (
<TouchableOpacity
onPress={() =>
extendToHeadStation(item.station, item.dia, item.id)
}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={item.id + "-head"}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の始発駅」を表示
</Text>
</TouchableOpacity>
),
)}
<ShowSpecialTrain
isTrainDataNothing={trainData.length === 0}
setTrainData={setTrainData}
trueTrainID={trueTrainID}
/>
{!trainData.length && (
<TouchableOpacity
onPress={() =>
Linking.openURL("https://twitter.com/search?q=" + trainNum)
}
style={[
styles.twitterSearchButton,
{
borderColor: colors.textAccent,
backgroundColor: colors.backgroundOverlay,
},
]}
>
<Text style={[styles.extendStationText, { color: colors.text }]}>
Twitterで検索
</Text>
</TouchableOpacity>
)}
</>
);
const listFooter = (
<>
<Text style={[styles.customDataNote, { color: colors.textSecondary }]}>
時刻が斜体,青色になっている時刻はコミュニティで追加されている独自データです。
</Text>
{tailStation.length > 0 &&
tailStation.map(
({ station, dia, id }) =>
!showTailStation.includes(id) && (
<TouchableOpacity
onPress={() => extendToTailStation(station, dia, id)}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={id + "-tail"}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の終着駅」を表示
</Text>
</TouchableOpacity>
),
)}
<View
style={[
styles.bottomSpacer,
{ borderBottomColor: colors.borderLight },
]}
/>
</>
);
return (
<View
style={{
@@ -329,11 +159,14 @@ export const EachTrainInfoCore = ({
trainData={trainData}
showHeadStation={showHeadStation}
showTailStation={showTailStation}
headStation={headStation}
tailStation={tailStation}
navigate={navigate}
from={from}
fontLoaded={true}
scrollRef={scrollRef}
/>
<DynamicHeaderScrollView
from={from}
styles={styles as any}
@@ -350,47 +183,145 @@ export const EachTrainInfoCore = ({
shortHeader={
<ShortHeader
{...{
openTrainInfo,
navigate,
nearTrainIDList,
currentTrainData,
currentTrainDataList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}}
/>
}
longHeader={
<LongHeader
{...{
openTrainInfo,
navigate,
currentTrainData,
nearTrainIDList,
currentTrainDataList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}}
/>
}
topStickyContent={
<ScrollStickyContent
{...{
trainDelay: currentTrainData?.delay,
showThrew,
setShowThrew,
haveThrough,
}}
{...{ currentTrainData, showThrew, setShowThrew, haveThrough }}
/>
}
>
{listHeader}
{trainDataWithThrough.map((item, index) => (
<React.Fragment key={`${trainNum}-${index}`}>
{renderTrainDataItem(item, index)}
</React.Fragment>
))}
{listFooter}
{customTrainType.data === "notService" && (
<Text
style={{
backgroundColor: colors.surface,
color: colors.text,
fontWeight: "bold",
}}
>
この列車には乗車できません。
</Text>
)}
{headStation.length > 0 &&
headStation.map(
(item, index) =>
!showHeadStation.includes(index) && (
<TouchableOpacity
onPress={() =>
extendToHeadStation(item.station, item.dia, index)
}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={`${item.station}-head${index}`}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の始発駅」を表示
</Text>
</TouchableOpacity>
),
)}
<ShowSpecialTrain
isTrainDataNothing={trainData.length === 0}
setTrainData={setTrainData}
trueTrainID={trueTrainID}
/>
{!trainData.length && (
<TouchableOpacity
onPress={() =>
Linking.openURL(`https://twitter.com/search?q=${trainNum}`)
}
style={[
styles.twitterSearchButton,
{
borderColor: colors.textAccent,
backgroundColor: colors.backgroundOverlay,
},
]}
>
<Text style={[styles.extendStationText, { color: colors.text }]}>
Twitterで検索
</Text>
</TouchableOpacity>
)}
{trainDataWithThrough.map((item, index, array) =>
item.split(",")[1] === "増" || item.split(",")[1] === "解" ? (
<DataConnectedButton
i={item}
key={`${item}-data`}
openTrainInfo={openTrainInfo}
/>
) : item.split(",")[1].includes(".") ? (
<React.Fragment key={`${item}-skip`} />
) : (
<EachStopList
i={item}
index={index}
stationList={stationList as any}
points={pointsDisplay?.[index] || false}
currentTrainData={currentTrainData as any}
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
showThrew={showThrew}
array={array}
isNotService={customTrainType.data === "notService"}
key={`${item}-stop`}
/>
),
)}
<Text style={[styles.customDataNote, { color: colors.textSecondary }]}>
時刻が斜体,青色になっている時刻はコミュニティで追加されている独自データです。
</Text>
{tailStation.length > 0 &&
tailStation.map(
({ station, dia }, index) =>
!showTailStation.includes(index) && (
<TouchableOpacity
onPress={() => extendToTailStation(station, dia, index)}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={`${station}-tail${index}`}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の終着駅」を表示
</Text>
</TouchableOpacity>
),
)}
<View
style={[
styles.bottomSpacer,
{ borderBottomColor: colors.borderLight },
]}
/>
</DynamicHeaderScrollView>
</View>
);
@@ -1,7 +1,7 @@
import React, { FC, useMemo } from "react";
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName";
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
import { TrainIconStatus } from "./trainIconStatus";
import { OneManText } from "./HeaderTextParts/OneManText";
import { customTrainDataDetector } from "@/components/custom-train-data";
@@ -26,8 +26,10 @@ import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
type Props = {
trainNum: string;
trainData: string[];
showHeadStation: string[];
showTailStation: string[];
showHeadStation: number[];
showTailStation: number[];
headStation: { id: string }[];
tailStation: { id: string }[];
navigate: NavigateFunction;
from: string;
fontLoaded: boolean;
@@ -44,6 +46,8 @@ export const HeaderText: FC<Props> = ({
trainData,
showHeadStation,
showTailStation,
headStation,
tailStation,
navigate,
from,
scrollRef,
@@ -305,9 +309,9 @@ export const HeaderText: FC<Props> = ({
</TouchableOpacity>
<View style={{ flex: 1 }} />
<Text style={{ ...textConfig, fontSize: fontScale(17), color: fixed.textOnPrimary }}>
{showHeadStation.map((trainID) => `${trainID} + `)}
{showHeadStation.map((d) => `${headStation[d].id} + `)}
{trainNum}
{showTailStation.map((trainID) => ` + ${trainID}`)}
{showTailStation.map((d) => ` + ${tailStation[d].id}`)}
</Text>
<MaterialCommunityIcons
name="database"
@@ -2,7 +2,6 @@ import React, { ComponentProps, FC } from "react";
import { Image, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { TrainIconEntry } from "@/lib/trainIconEntries";
import { normalizeVehicleInfoUrl } from "@/lib/vehicleInfoUrl";
type StackVariant = "header" | "list";
type StatusIcon = ComponentProps<typeof Ionicons>;
@@ -88,7 +87,7 @@ export const TrainIconStack: FC<Props> = ({
<TouchableOpacity
key={`${trainIcon}-${index}`}
onPress={() => onPressEntry(entry, index)}
disabled={!normalizeVehicleInfoUrl(entry.vehicle_info_url)}
disabled={!entry.vehicle_info_url}
>
{content}
</TouchableOpacity>
@@ -1,46 +1,33 @@
import { useEffect, MutableRefObject, useRef } from 'react';
import { useEffect, MutableRefObject } from 'react';
export const useAutoScroll = (
points: boolean[] | undefined,
trainDataWithThrough: string[],
scrollRef: MutableRefObject<any>,
isJumped: boolean,
setIsJumped: (value: boolean) => void,
sheetOpened: boolean = false
) => {
const jumpedRef = useRef(false);
const trainDataRef = useRef(trainDataWithThrough);
useEffect(() => {
if (trainDataRef.current !== trainDataWithThrough) {
trainDataRef.current = trainDataWithThrough;
jumpedRef.current = false;
}
// ActionSheetのスプリングアニメーション完了後まで待機
if (!sheetOpened || jumpedRef.current || !points?.length || !scrollRef) return;
if (!sheetOpened || isJumped || !points?.length || !scrollRef) return;
const currentPositionIndex = points.findIndex((d) => d === true);
if (currentPositionIndex === -1) return;
// 5駅以内の場合はスクロールしない
if (currentPositionIndex < 5) {
jumpedRef.current = true;
setIsJumped(true);
return;
}
const scrollPosition = currentPositionIndex * 44 - 50;
const timer = setTimeout(() => {
if (typeof scrollRef.current?.scrollToOffset === 'function') {
scrollRef.current.scrollToOffset({
offset: scrollPosition,
animated: true,
});
} else {
scrollRef.current?.scrollTo({ y: scrollPosition, animated: true });
}
jumpedRef.current = true;
scrollRef.current?.scrollTo({ y: scrollPosition, animated: true });
setIsJumped(true);
}, 100);
return () => clearTimeout(timer);
}, [sheetOpened, points, trainDataWithThrough, scrollRef]);
}, [sheetOpened, points, trainDataWithThrough, scrollRef, isJumped, setIsJumped]);
};
@@ -1,85 +1,29 @@
import { useState } from 'react';
import { openBackTrainInfo } from '@/lib/eachTrainInfoCoreLib/openBackTrainInfo';
type ExtendedState = {
trainNum: string;
headTrainIDs: string[];
tailTrainIDs: string[];
includedTrainIDs: string[];
};
export const useExtendedStations = (trainData, setTrainData) => {
const [showHeadStation, setShowHeadStation] = useState([]);
const [showTailStation, setShowTailStation] = useState([]);
const initialExtendedState = (trainNum: string): ExtendedState => ({
trainNum,
headTrainIDs: [],
tailTrainIDs: [],
includedTrainIDs: trainNum ? [trainNum] : [],
});
export const useExtendedStations = (
trainNum: string,
trainData: string[],
setTrainData: (data: string[]) => void,
) => {
const [extendedState, setExtendedState] = useState(() =>
initialExtendedState(trainNum),
);
const currentState =
extendedState.trainNum === trainNum
? extendedState
: initialExtendedState(trainNum);
const extendToHeadStation = (
station: string,
dia: string[],
trainID: string,
) => {
const extendToHeadStation = (station, dia, index) => {
const array = openBackTrainInfo(station, trainData, dia);
if (!array) return;
setTrainData(array);
setExtendedState((previous) => {
const state =
previous.trainNum === trainNum
? previous
: initialExtendedState(trainNum);
return {
...state,
headTrainIDs: [trainID, ...state.headTrainIDs],
includedTrainIDs: state.includedTrainIDs.includes(trainID)
? state.includedTrainIDs
: [...state.includedTrainIDs, trainID],
};
});
setShowHeadStation((prev) => [...prev, index]);
};
const extendToTailStation = (
station: string,
dia: string[],
trainID: string,
) => {
const extendToTailStation = (station, dia, index) => {
const array = openBackTrainInfo(station, trainData, dia);
if (!array) return;
setTrainData(array);
setExtendedState((previous) => {
const state =
previous.trainNum === trainNum
? previous
: initialExtendedState(trainNum);
return {
...state,
tailTrainIDs: [...state.tailTrainIDs, trainID],
includedTrainIDs: state.includedTrainIDs.includes(trainID)
? state.includedTrainIDs
: [...state.includedTrainIDs, trainID],
};
});
setShowTailStation((prev) => [...prev, index]);
};
return {
showHeadStation: currentState.headTrainIDs,
showTailStation: currentState.tailTrainIDs,
includedTrainIDs: currentState.includedTrainIDs,
showHeadStation,
showTailStation,
extendToHeadStation,
extendToTailStation,
};
@@ -1,61 +1,27 @@
import { useMemo } from "react";
import { useState, useEffect } from "react";
import { useBusAndTrainData } from "@/stateBox/useBusAndTrainData";
type InfluencedTrain = { id: string; TrainData: string[] };
type BoundaryTrain = { station: string; dia: string[]; id: string };
export const useNearbyTrains = (
trainNum: string,
trainData: string[],
includedTrainIDs: string[],
) => {
export const useNearbyTrains = (trainNum, trainData) => {
const { getInfluencedTrainData } = useBusAndTrainData();
const [headStation, setHeadStation] = useState([]);
const [tailStation, setTailStation] = useState([]);
const [nearTrainIDList, setNearTrainIDList] = useState([]);
return useMemo(() => {
if (!trainNum || !trainData.length) {
return { headStation: [], tailStation: [], nearTrainIDList: [] };
}
useEffect(() => {
if (!trainNum || !trainData.length) return;
const nearTrainList = getInfluencedTrainData(trainNum) ?? [];
if (!nearTrainList.length) {
return { headStation: [], tailStation: [], nearTrainIDList: [] };
}
const nearTrainList = getInfluencedTrainData(trainNum);
if (!nearTrainList.length) return;
// Opening any train in a multi-train service should allow the whole
// connected chain to be extended. The direct list is still kept for the
// existing header action; boundary candidates come from its transitive
// connections.
const connectedTrainByID = new Map<string, InfluencedTrain>();
const visitedTrainIDs = new Set<string>([trainNum]);
const pendingTrainIDs = [trainNum];
const trainDataArray = nearTrainList.map((d) => d.TrainData);
const trainIDs = nearTrainList.map((d) => d.id);
setNearTrainIDList(trainIDs);
while (pendingTrainIDs.length > 0) {
const currentTrainID = pendingTrainIDs.shift();
if (!currentTrainID) continue;
const connectedTrains = getInfluencedTrainData(currentTrainID) ?? [];
connectedTrains.forEach((connectedTrain) => {
if (!connectedTrain?.id || visitedTrainIDs.has(connectedTrain.id)) {
return;
}
visitedTrainIDs.add(connectedTrain.id);
connectedTrainByID.set(connectedTrain.id, connectedTrain);
pendingTrainIDs.push(connectedTrain.id);
});
}
const includedTrainIDSet = new Set(includedTrainIDs);
const candidateTrains = Array.from(connectedTrainByID.values()).filter(
(train) => !includedTrainIDSet.has(train.id),
);
const trainDataArray = candidateTrains.map((d) => d.TrainData);
const trainIDs = candidateTrains.map((d) => d.id);
const [firstStation] = trainData[0].split(",");
const [lastStation] = trainData[trainData.length - 1].split(",");
const head: BoundaryTrain[] = [];
const tail: BoundaryTrain[] = [];
const head = [];
const tail = [];
trainDataArray.forEach((data, i) => {
data.forEach((d) => {
@@ -81,10 +47,9 @@ export const useNearbyTrains = (
});
});
return {
headStation: head,
tailStation: tail,
nearTrainIDList: nearTrainList.map((d) => d.id),
};
}, [getInfluencedTrainData, includedTrainIDs, trainData, trainNum]);
setHeadStation(head);
setTailStation(tail);
}, [trainData, trainNum, getInfluencedTrainData]);
return { headStation, tailStation, nearTrainIDList };
};
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useState, useEffect } from 'react';
import { useStationList } from '@/stateBox/useStationList';
const computeStopStationIDs = (data: string[], stationList: any[][]): string[][] =>
@@ -13,9 +13,14 @@ const computeStopStationIDs = (data: string[], stationList: any[][]): string[][]
export const useStopStationIDs = (trainDataWithThrough: string[]) => {
const { stationList } = useStationList();
// station IDも表示データから直接導出し、effect由来の追加renderをなくす。
return useMemo(
() => computeStopStationIDs(trainDataWithThrough, stationList),
[trainDataWithThrough, stationList],
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [stopStationIDList, setStopStationIDList] = useState<string[][]>(() =>
computeStopStationIDs(trainDataWithThrough, stationList)
);
useEffect(() => {
setStopStationIDList(computeStopStationIDs(trainDataWithThrough, stationList));
}, [trainDataWithThrough, stationList]);
return stopStationIDList;
};
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { useState, useEffect } from 'react';
import { lineListPair, stationIDPair } from '@/lib/getStationList';
import { useStationList } from '@/stateBox/useStationList';
import { OriginalStationList } from '@/lib/CommonTypes';
const computeThroughStationsUnmeasured = (
const computeThroughStations = (
trainData: string[],
stationList: any[][],
originalStationList: OriginalStationList
@@ -103,20 +103,14 @@ const computeThroughStationsUnmeasured = (
export const useThroughStations = (trainData) => {
const { originalStationList, stationList } = useStationList();
// 純粋な派生値なのでstate/effectで一度古いリストを描画してから更新しない。
return useMemo(
() => computeThroughStations(trainData, stationList, originalStationList),
[trainData, stationList, originalStationList],
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [state, setState] = useState(() =>
computeThroughStations(trainData, stationList, originalStationList)
);
useEffect(() => {
setState(computeThroughStations(trainData, stationList, originalStationList));
}, [trainData, stationList, originalStationList]);
return { trainDataWithThrough: state.trainDataWithThrough, haveThrough: state.haveThrough };
};
function computeThroughStations(
trainData: string[],
stationList: any[][],
originalStationList: OriginalStationList,
): { trainDataWithThrough: string[]; haveThrough: boolean } {
return computeThroughStationsUnmeasured(
trainData,
stationList,
originalStationList,
);
}
@@ -1,11 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { useState, useEffect } from 'react';
import { useTrainDiagram } from '@/stateBox/useAllTrainDiagram';
import { searchSpecialTrain } from '@/lib/eachTrainInfoCoreLib/searchSpecialTrain';
const parseTrainData = (
trainNum: string,
trainList: Record<string, string>,
) => {
const parseTrainData = (trainNum: string, trainList: Record<string, string>) => {
if (!trainNum) return { data: [], trueIDs: [] };
const TD = trainList[trainNum];
if (!TD) {
@@ -17,30 +14,28 @@ const parseTrainData = (
export const useTrainDiagramData = (trainNum) => {
const { allTrainDiagram: trainList } = useTrainDiagram();
const parsed = useMemo(
() => parseTrainData(trainNum, trainList),
[trainNum, trainList],
);
const [manualData, setManualData] = useState<{
trainNum: string;
data: string[];
} | null>(null);
const [isManuallyExtended, setIsManuallyExtended] = useState(false);
// ダイヤ本体は入力から直接導出する。列車切替時に前の列車を一度描画して
// effectで新しい配列へ差し替える経路をなくし、リストの余分な再描画を防ぐ。
const trainData =
manualData?.trainNum === trainNum ? manualData.data : parsed.data;
// 初回レンダリング時にコンテキストから同期的にデータを取得することで
// ActionSheetのアニメーション中に高さが変わるのを防ぐ
const [trainData, setTrainData] = useState(() => parseTrainData(trainNum, trainList).data);
const [trueTrainID, setTrueTrainID] = useState(() => parseTrainData(trainNum, trainList).trueIDs);
const setTrainDataExtended = useCallback(
(data: string[]) => {
setManualData({ trainNum, data });
},
[trainNum],
);
useEffect(() => {
if (!trainNum) return;
return {
trainData,
setTrainData: setTrainDataExtended,
trueTrainID: parsed.trueIDs,
// 手動で拡張されている場合は上書きしない
if (isManuallyExtended) return;
const { data, trueIDs } = parseTrainData(trainNum, trainList);
setTrueTrainID(trueIDs);
setTrainData(data);
}, [trainNum, trainList, isManuallyExtended]);
const setTrainDataExtended = (data) => {
setTrainData(data);
setIsManuallyExtended(true);
};
return { trainData, setTrainData: setTrainDataExtended, trueTrainID };
};
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useState, useEffect } from 'react';
import { AS } from '@/storageControl';
import { STORAGE_KEYS } from '@/constants';
import { logger } from '@/utils/logger';
@@ -7,6 +7,7 @@ import { useTrainMenu } from '@/stateBox/useTrainMenu';
import type { trainDataType } from '@/lib/trainPositionTextArray';
import { lineIdFromStationNumber } from '@/lib/checkDuplicateTrainData';
import { findReversalPoints } from '@/lib/eachTrainInfoCoreLib/findReversalPoints';
export const useTrainPosition = (
trainNum: string,
stopStationIDList: string[][]
@@ -17,9 +18,13 @@ export const useTrainPosition = (
getPosition,
} = useCurrentTrainData();
const { updatePermission } = useTrainMenu();
const [currentTrainData, setCurrentTrainData] = useState<trainDataType | undefined>();
const [currentTrainDataList, setCurrentTrainDataList] = useState<trainDataType[]>([]);
const [currentPosition, setCurrentPosition] = useState<string[]>([]);
const [currentPositionList, setCurrentPositionList] = useState<string[][]>([]);
const [trainPositionSwitch, setTrainPositionSwitch] = useState<string>('false');
const currentTrainDataList = useMemo(() => {
useEffect(() => {
const candidates = getCurrentStationDataList(trainNum);
const routeLineCounts = new Map<string, number>();
for (const stationNumber of stopStationIDList.flat()) {
@@ -30,11 +35,14 @@ export const useTrainPosition = (
.sort(([, countA], [, countB]) => countB - countA)
.map(([line]) => line);
const selected = getCurrentStationData(trainNum, routeLineIds);
return updatePermission
const visible = updatePermission
? candidates
: selected
? [selected]
: [];
logger.debug('Train position data:', visible);
setCurrentTrainDataList(visible);
setCurrentTrainData(visible[0]);
}, [
getCurrentStationData,
getCurrentStationDataList,
@@ -42,16 +50,15 @@ export const useTrainPosition = (
trainNum,
updatePermission,
]);
const currentTrainData = currentTrainDataList[0];
useEffect(() => {
logger.debug('Train position data:', currentTrainDataList);
}, [currentTrainDataList]);
if (!stopStationIDList.length || !currentTrainDataList.length) {
setCurrentPositionList([]);
setCurrentPosition([]);
return;
}
const currentPositionList = useMemo(() => {
if (!stopStationIDList.length || !currentTrainDataList.length) return [];
return currentTrainDataList.map((train) => {
const positions = currentTrainDataList.map((train) => {
const position = getPosition(train);
const next = position ? [...position] : [];
if (next.length > 1) {
@@ -72,8 +79,9 @@ export const useTrainPosition = (
}
return next;
});
}, [currentTrainDataList, getPosition, stopStationIDList]);
const currentPosition = currentPositionList[0] ?? [];
setCurrentPositionList(positions);
setCurrentPosition(positions[0] ?? []);
}, [currentTrainDataList, stopStationIDList, getPosition]);
useEffect(() => {
AS.getItem(STORAGE_KEYS.TRAIN_POSITION_SWITCH)
@@ -99,21 +107,15 @@ export const useTrainPosition = (
return merged;
};
const points = useMemo(
() =>
trainPositionSwitch === 'true'
? mergePoints(true)
: stopStationIDList.map(() => false),
[currentPositionList, stopStationIDList, trainPositionSwitch],
);
const points =
trainPositionSwitch === 'true'
? mergePoints(true)
: stopStationIDList.map(() => false);
const pointsDisplay = useMemo(
() =>
trainPositionSwitch === 'true'
? mergePoints(false)
: stopStationIDList.map(() => false),
[currentPositionList, stopStationIDList, trainPositionSwitch],
);
const pointsDisplay =
trainPositionSwitch === 'true'
? mergePoints(false)
: stopStationIDList.map(() => false);
return {
currentTrainData,
@@ -2,14 +2,13 @@ import React, { ComponentProps, FC, useEffect, useMemo, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { SheetManager } from "react-native-actions-sheet";
import { customTrainDataDetector } from "@/components/custom-train-data";
import { customTrainDataDetector } from "../../custom-train-data";
import { useCustomTrainData } from "@/stateBox/useAllTrainDiagram";
import { useInterval } from "@/lib/useInterval";
import type { NavigateFunction } from "@/types";
import { OperationLogs } from "@/lib/CommonTypes";
import type { IconDisplayMode } from "@/lib/iconDisplayMode";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { normalizeVehicleInfoUrl } from "@/lib/vehicleInfoUrl";
import { TrainIconStack } from "./TrainIconStack";
type GlyphNames = ComponentProps<typeof Ionicons>["name"];
@@ -123,22 +122,11 @@ export const TrainIconStatus: FC<Props> = (props) => {
hidden={!!anpanmanStatus && showIcon}
statusIcon={anpanmanStatus}
onPressEntry={(entry) => {
const vehicleInfoUrl = normalizeVehicleInfoUrl(entry.vehicle_info_url);
if (!vehicleInfoUrl) return;
void SheetManager.hide("EachTrainInfo")
.then(() => {
navigate("generalWebView", {
uri: vehicleInfoUrl,
useExitButton: true,
});
})
.catch(() => {
navigate("generalWebView", {
uri: vehicleInfoUrl,
useExitButton: true,
});
});
navigate("howto", {
info: entry.vehicle_info_url,
goTo: from == "LED" ? "menu" : from,
});
SheetManager.hide("EachTrainInfo");
}}
/>
);
@@ -1,7 +1,7 @@
import React, { FC, useEffect, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import { SheetManager } from "react-native-actions-sheet";
import { getType } from "@/lib/eachTrainInfoCoreLib/getType";
import { getType } from "../../../lib/eachTrainInfoCoreLib/getType";
import { useThemeColors } from "@/lib/theme";
import type { NavigateFunction } from "@/types";
@@ -19,7 +19,7 @@ import LottieView from "lottie-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import { useTrainDelayData } from "@/stateBox/useTrainDelayData";
import { useTrainDelayData } from "../../stateBox/useTrainDelayData";
import { BottomButtons } from "./JRSTraInfo/BottomButtons";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
@@ -127,7 +127,7 @@ export const JRSTraInfo = () => {
autoPlay
loop
style={{ width: 150, height: 150, backgroundColor: colors.surface }}
source={require("@/assets/51690-loading-diamonds.json")}
source={require("../../assets/51690-loading-diamonds.json")}
/>
</View>
) : delayData ? (
@@ -67,7 +67,7 @@ export const Social = () => {
<ListItem
bottomDivider
onPress={() => Linking.openURL("tel:0570-00-4592")}
// @ts-expect-error RNEUIのListItem型はComponentに渡したTouchableScaleのpropsを含まない
// @ts-ignore: TouchableScale props passed through Component
friction={90}
tension={100}
activeScale={0.95}
@@ -83,7 +83,7 @@ export const Social = () => {
0570-00-4592(8:00〜20:00 年中無休)
</ListItem.Subtitle>
</ListItem.Content>
{/* @ts-expect-error RNEUIのListItem.Chevron型はcolorを公開していないが、実装はIconへ渡す */}
{/* @ts-ignore: ListItem.Chevron doesn't expose color prop */}
<ListItem.Chevron color={"white"} />
</ListItem>
<ScrollView
@@ -154,7 +154,7 @@ export const Social = () => {
bottomDivider
onPress={() => Linking.openURL(d.url)}
key={d.url}
// @ts-expect-error RNEUIのListItem型はComponentに渡したTouchableScaleのpropsを含まない
// @ts-ignore: TouchableScale props passed through Component
friction={90} //
tension={100} // These props are passed to the parent component (here TouchableScale)
activeScale={0.95} //
@@ -9,11 +9,11 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import type { SheetProps } from "react-native-actions-sheet";
import Sign from "@/components/駅名表/Sign";
import Sign from "../../components/駅名表/Sign";
import { getPDFViewURL } from "@/lib/getPdfViewURL";
import { useBusAndTrainData } from "@/stateBox/useBusAndTrainData";
import { AS } from "@/storageControl";
import { getPDFViewURL } from "../../lib/getPdfViewURL";
import { useBusAndTrainData } from "../../stateBox/useBusAndTrainData";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { StationMapButton } from "./StationDeteilView/StationMapButton";
import { TrainBusButton } from "./StationDeteilView/TrainBusButton";
@@ -103,9 +103,10 @@ export const StationDeteilView = ({ payload }: SheetProps<"StationDetailView">)
oP={() => {
usePDFView == "true"
? Linking.openURL(station.StationTimeTable)
: navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
: navigate("howto", {
info,
goTo,
useShow,
});
onExit();
}}
@@ -164,9 +165,10 @@ export const StationDeteilView = ({ payload }: SheetProps<"StationDetailView">)
<TrainBusButton
address={trainBus.address}
press={() => {
navigate("generalWebView", {
uri: trainBus.address,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
navigate("howto", {
info: trainBus.address,
goTo,
useShow,
});
onExit();
}}
@@ -25,10 +25,7 @@ export const 駅構内図:FC<Props> = (props) => {
flex: 1,
}}
onPress={() => {
navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
navigate("howto", { info, goTo, useShow });
onExit();
}}
onLongPress={() => Linking.openURL(info)}
@@ -24,10 +24,7 @@ export const StationTimeTableButton: FC<Props> = (props) => {
onPressButton={() => {
usePDFView == "true"
? Linking.openURL(address)
: navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
: navigate("howto", { info, goTo, useShow });
onExit();
}}
onLongPressButton={() => Linking.openURL(address)}
@@ -20,10 +20,7 @@ export const WebSiteButton: FC<Props> = (Props) => {
icon={<Foundation name="web" color={fixed.textOnPrimary} size={50} />}
flex={1}
onPressButton={() => {
navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
navigate("howto", { info, goTo, useShow });
onExit();
}}
onLongPressButton={() => Linking.openURL(info)}
@@ -12,8 +12,8 @@ import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import icons from "@/assets/icons/icons";
import hubIcons from "@/assets/unyoHubIcons/icons";
import icons from "../../assets/icons/icons";
import hubIcons from "../../assets/unyoHubIcons/icons";
import { getAppIconName } from "expo-alternate-app-icons";
import { AS } from "@/storageControl";
@@ -8,10 +8,10 @@ import {
} from "react-native";
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useCurrentTrainView } from "@/stateBox/useCurrentTrain";
import lineColorList from "@/assets/originData/lineColorList";
import { stationIDPair, lineListPair } from "@/lib/getStationList";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useCurrentTrainView } from "../../stateBox/useCurrentTrain";
import lineColorList from "../../assets/originData/lineColorList";
import { stationIDPair, lineListPair } from "../../lib/getStationList";
import { useThemeColors } from "@/lib/theme";
import { useSheetMaxHeight } from "./useSheetMaxHeight";
+3 -11
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef, FC } from "react";
import React, { useState, useRef, FC } from "react";
import {
Animated,
View,
@@ -41,16 +41,8 @@ export const AllTrainDiagramView: FC = () => {
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
const tabBarHeight = useBottomTabBarHeight();
const { keyList, allTrainDiagram } = useTrainDiagram();
const { allCustomTrainData, activateCustomTrainData } = useCustomTrainData();
const {
getTodayOperationByTrainId,
activateOperationLogs,
} = useOperationLogs();
useEffect(() => {
activateCustomTrainData();
activateOperationLogs();
}, [activateCustomTrainData, activateOperationLogs]);
const { allCustomTrainData } = useCustomTrainData();
const { getTodayOperationByTrainId } = useOperationLogs();
const [input, setInput] = useState(""); // 文字入力
const [useStationName, setUseStationName] = useState(false);
const [useRegex, setUseRegex] = useState(false);
@@ -1,8 +1,8 @@
import React from "react";
import dayjs from "dayjs";
import { FlexWidget, OverlapWidget, SvgWidget, TextWidget } from "react-native-android-widget";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "../../constants";
import { WidgetColors, widgetLightColors } from "./widget-theme";
type LastFelicaSnapshot = {
+1 -1
View File
@@ -3,7 +3,7 @@ import { FlexWidget, TextWidget } from "react-native-android-widget";
import { getDelayData } from "./TraInfoEXWidget";
import { getInfoString } from "./InfoWidget";
import { getFelicaQuickAccessData } from "./FelicaQuickAccessWidget";
import { isAvailable as isNfcAvailable } from "@/modules/expo-felica-reader/src";
import { isAvailable as isNfcAvailable } from "../../modules/expo-felica-reader/src";
import { WidgetColors, widgetLightColors } from "./widget-theme";
export async function getShortcutData() {
@@ -8,8 +8,16 @@ import {
import { ShortcutWidget, getShortcutData } from "./ShortcutWidget";
import { StrangeTrainWidget, getStrangeTrainData } from "./StrangeTrainWidget";
import { widgetLightColors, widgetDarkColors } from "./widget-theme";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "../../constants";
export const nameToWidget = {
JR_shikoku_train_info: TraInfoEXWidget,
Info_Widget: InfoWidget,
JR_shikoku_apps_shortcut: ShortcutWidget,
JR_shikoku_felica_balance: FelicaQuickAccessWidget,
JR_shikoku_train_strange: StrangeTrainWidget,
};
type ColorThemePref = "light" | "system" | "dark";
+4 -4
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useRef } from "react";
import { Animated, StyleSheet, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import type { RootTabParamList } from "@/types/navigation";
import lineColorList from "@/assets/originData/lineColorList";
import { stationIDPair } from "@/lib/getStationList";
import { fixedColors } from "@/lib/theme/colors";
import type { RootTabParamList } from "../../types/navigation";
import lineColorList from "../../assets/originData/lineColorList";
import { stationIDPair } from "../../lib/getStationList";
import { fixedColors } from "../../lib/theme/colors";
export type TabBarBackgroundProps = {
routeName: keyof RootTabParamList;
-250
View File
@@ -1,250 +0,0 @@
import {
Component,
useContext,
useState,
useSyncExternalStore,
type ErrorInfo,
type ReactNode,
} from "react";
import { Platform, ScrollView, Text, TouchableOpacity, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as Updates from "expo-updates";
import Constants from "expo-constants";
import {
SafeAreaInsetsContext,
initialWindowMetrics,
} from "react-native-safe-area-context";
import { captureTelemetryException } from "@/lib/observability/sentry";
import { formatErrorReport } from "@/lib/errorReport";
import {
ErrorBoundaryTestError,
disarmErrorBoundaryTest,
getArmedErrorBoundaryTestScope,
subscribeErrorBoundaryTest,
} from "@/lib/errorBoundaryTest";
type Props = {
/** 捕捉位置。Sentryのtagとエラー報告に出る("root"、タブ名など) */
scope: string;
children: ReactNode;
};
type State = {
error: unknown;
hasError: boolean;
componentStack: string | null;
sentryEventId: string | null;
occurredAt: Date | null;
};
const initialState: State = {
error: null,
hasError: false,
componentStack: null,
sentryEventId: null,
occurredAt: null,
};
/**
* 子の描画中に起きた例外を捕捉し、白画面の代わりにエラー画面を出す。
*
* イベントハンドラや非同期処理、ネイティブのクラッシュは捕捉できない。
* エラー画面はテーマContextなど壊れた可能性のある仕組みに依存せず、固定色で描く。
*/
export class AppErrorBoundary extends Component<Props, State> {
state: State = initialState;
static getDerivedStateFromError(error: unknown): Partial<State> {
return { error, hasError: true, occurredAt: new Date() };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
const componentStack = info.componentStack ?? null;
const isTest = error instanceof ErrorBoundaryTestError;
// 再表示で同じテストエラーが再発しないよう、捕捉した時点で解除する
if (isTest) disarmErrorBoundaryTest();
let sentryEventId: string | null = null;
try {
sentryEventId = captureTelemetryException(error, {
tags: {
error_boundary: this.props.scope,
error_boundary_test: String(isTest),
},
contexts: { react: { componentStack } },
});
} catch {
// Sentry送信の失敗でエラー画面の表示まで失敗させない
}
this.setState({ componentStack, sentryEventId });
}
private reset = () => this.setState(initialState);
render() {
if (!this.state.hasError) {
return (
<>
<ErrorBoundaryTestTrigger scope={this.props.scope} />
{this.props.children}
</>
);
}
const report = formatErrorReport({
scope: this.props.scope,
error: this.state.error,
componentStack: this.state.componentStack,
sentryEventId: this.state.sentryEventId,
occurredAt: this.state.occurredAt ?? new Date(),
environment: {
platform: Platform.OS,
platformVersion: String(Platform.Version),
appVersion: Constants.expoConfig?.version,
runtimeVersion: Updates.runtimeVersion,
updateId: Updates.updateId,
},
});
return (
<ErrorFallback
isRoot={this.props.scope === "root"}
report={report}
onRetry={this.reset}
/>
);
}
}
/** 研究ツールから指定された scope のときだけ、描画中に例外を投げる。 */
const ErrorBoundaryTestTrigger = ({ scope }: { scope: string }) => {
const armedScope = useSyncExternalStore(
subscribeErrorBoundaryTest,
getArmedErrorBoundaryTestScope,
);
if (armedScope === scope) throw new ErrorBoundaryTestError(scope);
return null;
};
const colors = {
background: "#f5f5f5",
text: "#222222",
subText: "#555555",
reportBackground: "#ffffff",
border: "#dddddd",
primary: "#0099cc",
onPrimary: "#ffffff",
};
const ErrorFallback = ({
isRoot,
report,
onRetry,
}: {
isRoot: boolean;
report: string;
onRetry: () => void;
}) => {
const [copied, setCopied] = useState(false);
// ルートはSafeAreaProviderの外側なので、起動時の値で代替する
const insets =
useContext(SafeAreaInsetsContext) ?? initialWindowMetrics?.insets;
const copyReport = () => {
Clipboard.setStringAsync(report)
.then(() => setCopied(true))
.catch(() => setCopied(false));
};
const reloadApp = () => {
// 開発ビルドなどでreloadできない場合は、再表示で代替する
Updates.reloadAsync().catch(onRetry);
};
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
paddingTop: (insets?.top ?? 0) + 16,
paddingHorizontal: 16,
// タブ内はタブバーの上に描かれるので、下の余白はルートだけ確保する
paddingBottom: (isRoot ? insets?.bottom ?? 0 : 0) + 16,
}}
>
<Text style={{ color: colors.text, fontSize: 18, fontWeight: "bold" }}>
{isRoot ? "アプリでエラーが発生しました" : "この画面でエラーが発生しました"}
</Text>
<Text style={{ color: colors.subText, fontSize: 14, marginTop: 8 }}>
{isRoot
? "再読み込みで復帰できる場合があります。"
: "再表示で復帰できる場合があります。他のタブは引き続き使えます。"}
{"\n"}
不具合の報告には、下の「エラー内容をコピー」を使ってください。
</Text>
<View style={{ flexDirection: "row", marginTop: 16, gap: 8 }}>
<FallbackButton
label={isRoot ? "再読み込み" : "再表示"}
onPress={isRoot ? reloadApp : onRetry}
primary
/>
<FallbackButton
label={copied ? "コピーしました" : "エラー内容をコピー"}
onPress={copyReport}
/>
</View>
<ScrollView
style={{
flex: 1,
marginTop: 16,
backgroundColor: colors.reportBackground,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 6,
}}
contentContainerStyle={{ padding: 12 }}
>
<Text selectable style={{ color: colors.text, fontSize: 12 }}>
{report}
</Text>
</ScrollView>
</View>
);
};
const FallbackButton = ({
label,
onPress,
primary = false,
}: {
label: string;
onPress: () => void;
primary?: boolean;
}) => (
<TouchableOpacity
accessibilityRole="button"
onPress={onPress}
style={{
flex: 1,
minHeight: 44,
borderRadius: 6,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 12,
backgroundColor: primary ? colors.primary : colors.reportBackground,
borderColor: colors.primary,
borderWidth: 1,
}}
>
<Text
style={{
color: primary ? colors.onPrimary : colors.primary,
fontSize: 14,
fontWeight: "bold",
}}
>
{label}
</Text>
</TouchableOpacity>
);
+1 -3
View File
@@ -2,7 +2,7 @@ import React from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { SheetProvider } from "react-native-actions-sheet";
import { AppContainer } from "@/AppContainer";
import { AppContainer } from "../Apps";
import { AllTrainDiagramProvider } from "../stateBox/useAllTrainDiagram";
import { NotificationProvider } from "../stateBox/useNotifications";
import { UserPositionProvider } from "../stateBox/useUserPosition";
@@ -16,7 +16,6 @@ import { BusAndTrainDataProvider } from "../stateBox/useBusAndTrainData";
import { DeviceOrientationChangeProvider } from "../stateBox/useDeviceOrientationChange";
import { buildProvidersTree } from "../lib/providerTreeProvider";
import { AppThemeProvider } from "../lib/theme";
import { markStartupOnce } from "../lib/devStartupPerformance";
import StatusbarDetect from "../StatusbarDetect";
import "./ActionSheetComponents/sheets";
@@ -35,7 +34,6 @@ const ProviderTree = buildProvidersTree([
]);
export function AppRootLayout(): React.ReactElement {
markStartupOnce("root-layout-render", "AppRootLayout render");
return (
<AppThemeProvider>
<DeviceOrientationChangeProvider>
-3
View File
@@ -26,7 +26,6 @@ import { MapsButton } from "./Apps/MapsButton";
import { ReloadButton } from "./Apps/ReloadButton";
import { useStationList } from "../stateBox/useStationList";
import { positionsLifecycleRef } from "../lib/rootNavigation";
import { usePositionsStartupReady } from "../lib/positionsStartupContext";
import { FixedPositionBox } from "./Apps/FixedPositionBox";
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
@@ -44,7 +43,6 @@ export default function Apps() {
const { mapSwitch, selectedLine } = useTrainMenu();
const isDark = useColorScheme() === "dark";
const isFocused = useIsFocused();
const { notifyInitialLoadReady } = usePositionsStartupReady();
const [hasActivatedScreen, setHasActivatedScreen] = React.useState(false);
const [hasActivatedWebView, setHasActivatedWebView] = React.useState(false);
const [hasStableWebViewSession, setHasStableWebViewSession] =
@@ -334,7 +332,6 @@ export default function Apps() {
<AppsWebView
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
onInitialLoadReady={() => {
notifyInitialLoadReady();
if (hasStableWebViewSession) return;
positionsScreenTelemetry.info(
"positions webview became stable",
@@ -57,7 +57,7 @@ import { STORAGE_KEYS } from "@/constants";
// ── 遅延速報サウンド設定 ──
// 将来のファイル差し替えに備えて定数で管理
const DELAY_ANNOUNCEMENT_SOUND = require("@/assets/sound/delay-support.wav");
const DELAY_ANNOUNCEMENT_SOUND = require("../../../assets/sound/delay-support.wav");
// 遅延案内の再生間隔: 120〜180 秒をランダムで選択(2〜3分ごと)
const getAnnouncementIntervalMs = () =>
(Math.floor(Math.random() * 60) + 120) * 1000;
@@ -409,6 +409,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
goTo: "menu",
onExit: () => SheetManager.hide("StationDetailView"),
};
//@ts-ignore
SheetManager.show("StationDetailView", { payload });
}}
>
+1 -1
View File
@@ -7,7 +7,7 @@ import {
TextStyle,
} from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useResponsive } from "@/lib/responsive";
+2 -2
View File
@@ -4,8 +4,8 @@ import { Ionicons } from "@expo/vector-icons";
import { useThemeColors } from "@/lib/theme";
import * as Updates from "expo-updates";
import Constants from "expo-constants";
import { useCurrentTrainView } from "@/stateBox/useCurrentTrain";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useCurrentTrainView } from "../../stateBox/useCurrentTrain";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useResponsive } from "@/lib/responsive";
+3 -3
View File
@@ -9,9 +9,9 @@ import {
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import dayjs from "dayjs";
import { useTrainRecording } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
import { useTrainRecording } from "../../stateBox/useTrainMenu";
import { useThemeColors } from "../../lib/theme";
import { useResponsive } from "../../lib/responsive";
/**
* 再生中に走行位置WebViewの上部に表示するタイムラインコントローラー。
+3 -3
View File
@@ -2,9 +2,9 @@ import React, { FC, useEffect, useRef, useState } from "react";
import { View, Text, AppState, InteractionManager } from "react-native";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTrainRecording } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
import { useTrainRecording } from "../../stateBox/useTrainMenu";
import { useThemeColors } from "../../lib/theme";
import { useResponsive } from "../../lib/responsive";
const KEEP_AWAKE_TAG = "recording-status-bar";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useResponsive } from "@/lib/responsive";
+12 -105
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native";
import { WebView } from "react-native-webview";
@@ -8,44 +8,27 @@ import {
lineListPair,
stationIDPair,
stationNamePair,
} from "@/lib/getStationList";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
} from "../../lib/getStationList";
import { checkDuplicateTrainData } from "../../lib/checkDuplicateTrainData";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import {
useCurrentTrainData,
useCurrentTrainView,
} from "@/stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "@/stateBox/useDeviceOrientationChange";
} from "../../stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
import { SheetManager } from "react-native-actions-sheet";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
useCustomTrainData,
useOperationLogs,
useTrainDiagram,
} from "@/stateBox/useAllTrainDiagram";
import { useStationList } from "@/stateBox/useStationList";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useStationList } from "../../stateBox/useStationList";
import { useThemeColors } from "@/lib/theme";
import { useWebViewRemount } from "@/lib/useWebViewRemount";
import { generateMockUpdateScript } from "@/lib/mockApi/webviewXhrInterceptor";
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
import { telemetry } from "@/lib/observability/telemetry";
import { setAppLifecycleWebViewActive } from "@/lib/observability/appLifecycleCrashSentinel";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "@/lib/devStartupPerformance";
import { usePositionsStartupReady } from "@/lib/positionsStartupContext";
import {
generateNativeWebViewDataBootstrapScript,
NATIVE_WEBVIEW_BOOTSTRAP_MAX_AGE_MS,
type NativeWebViewDataBootstrap,
} from "@/lib/webViewInjectjavascript";
const positionsWebViewTelemetry = telemetry.scope("positions.webview");
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
markStartupOnce("positions-webview-render", "positions WebView render");
const initialPositionsUrl = "https://train.jr-shikoku.co.jp/sp.html";
const { currentTrain } = useCurrentTrainData();
const { webview } = useCurrentTrainView();
@@ -54,7 +37,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
const { favoriteStation } = useFavoriteStation();
const { isLandscape } = useDeviceOrientationChange();
const { isDark } = useThemeColors();
const { notifyWebViewLoadStarted } = usePositionsStartupReady();
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const { originalStationList, stationList, getInjectJavascriptAddress } =
useStationList();
@@ -67,58 +49,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
mockApiFeatureEnabled,
mockTrainPositions,
} = useTrainMenu();
const { allCustomTrainData, dataReadyAt: customTrainDataReadyAt } =
useCustomTrainData();
const { todayOperation, dataReadyAt: operationLogsReadyAt } =
useOperationLogs();
const { allTrainDiagram, dataReadyAt: trainDiagramReadyAt } =
useTrainDiagram();
// Capture one native snapshot for this WebView instance. Keeping the large
// train payload in a ref avoids serializing it again on each 30-second poll.
const nativeBootstrapScriptRef = useRef<string | null>(null);
if (nativeBootstrapScriptRef.current === null) {
const now = Date.now();
const isFresh = (readyAt: number | null): readyAt is number =>
typeof readyAt === "number" &&
now >= readyAt &&
now - readyAt <= NATIVE_WEBVIEW_BOOTSTRAP_MAX_AGE_MS;
const nativeBootstrap: NativeWebViewDataBootstrap = { version: 1 };
let hasFreshData = false;
if (isFresh(customTrainDataReadyAt)) {
nativeBootstrap.trainDataList = {
data: allCustomTrainData,
updatedAt: customTrainDataReadyAt,
};
hasFreshData = true;
}
if (isFresh(operationLogsReadyAt)) {
nativeBootstrap.operationList = {
data: todayOperation,
updatedAt: operationLogsReadyAt,
};
hasFreshData = true;
}
if (isFresh(trainDiagramReadyAt)) {
nativeBootstrap.trainDiagramData2 = {
data: allTrainDiagram,
updatedAt: trainDiagramReadyAt,
};
hasFreshData = true;
}
nativeBootstrapScriptRef.current = generateNativeWebViewDataBootstrapScript(
hasFreshData ? nativeBootstrap : null,
);
}
const webViewBeforeContentLoaded = useMemo(
() =>
[nativeBootstrapScriptRef.current, injectJavascriptBeforeContentLoaded]
.filter(Boolean)
.join("\n"),
[injectJavascriptBeforeContentLoaded],
);
const addWebViewBreadcrumb = (
message: string,
data?: Record<string, string | number | boolean | null | undefined>
@@ -187,7 +117,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
useEffect(() => {
markStartupOnce("positions-webview-mounted", "positions WebView mounted");
setAppLifecycleWebViewActive("positions_main", true);
return () => setAppLifecycleWebViewActive("positions_main", false);
}, []);
@@ -216,9 +145,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
}, [mockApiFeatureEnabled, remount]);
useEffect(() => {
markStartupState("positions-webview-focus", "positions WebView focus state", {
focused: isFocused,
});
focusedRef.current = isFocused;
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
landscape: isLandscape,
@@ -299,8 +225,9 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
addWebViewBreadcrumb("webview navigation", { url });
switch (true) {
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
if (Platform.OS === "android") navigate("generalWebView", { uri: url });
if (Platform.OS === "android") navigate("howto", { info: url });
webview?.current.goBack();
//Actions.howto();
break;
case url.includes("https://train.jr-shikoku.co.jp/train.html"):
//Actions.trainbase({info: url});
@@ -405,10 +332,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
};
const onLoadStart = () => {
notifyWebViewLoadStarted();
markStartup("positions WebView loadStart", {
focused: focusedRef.current,
});
pingHandlers.onLoadStart?.();
addWebViewBreadcrumb("webview loadStart", {
focused: focusedRef.current,
@@ -417,9 +340,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
};
const onLoadEnd = () => {
markStartup("positions WebView loadEnd", {
focused: focusedRef.current,
});
pingHandlers.onLoadEnd?.();
addWebViewBreadcrumb("webview loadEnd", {
focused: focusedRef.current,
@@ -429,11 +349,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
});
if (!initialLoadReadyNotifiedRef.current) {
initialLoadReadyNotifiedRef.current = true;
markStartupOnce(
"positions-webview-initial-ready",
"positions WebView initial load ready",
{ focused: focusedRef.current },
);
addWebViewBreadcrumb("webview initial load ready", {
focused: focusedRef.current,
});
@@ -453,10 +368,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
addWebViewBreadcrumb("webview initial inject scheduled", {
focused: focusedRef.current,
});
markStartup("positions WebView initial inject scheduled", {
focused: focusedRef.current,
favoriteCount: favoriteStation.length,
});
loadEndTimeoutRef.current = setTimeout(() => {
addWebViewBreadcrumb(
focusedRef.current
@@ -466,10 +377,6 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
focused: focusedRef.current,
}
);
markStartup("positions WebView initial inject run", {
focused: focusedRef.current,
favoriteCount: favoriteStation.length,
});
webview?.current?.injectJavaScript(string);
pendingInitialInjectRef.current = null;
initialInjectDoneRef.current = true;
@@ -519,7 +426,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
onRenderProcessGone={handleRenderProcessGone}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
injectedJavaScriptBeforeContentLoaded={webViewBeforeContentLoaded}
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
injectedJavaScript={injectJavascript}
/>
);
+21 -23
View File
@@ -156,7 +156,6 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
<View {...containerProps}>
<View style={{ zIndex: 1 }}>
<Animated.View
pointerEvents={headerVisible ? "auto" : "none"}
style={[
styles.header,
headerVisible ? shotHeaderStyle.on : shotHeaderStyle.off,
@@ -165,7 +164,6 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
{viewHeader ? <></> : shortHeader}
</Animated.View>
<Animated.View
pointerEvents={headerVisible ? "none" : "auto"}
style={[
styles.header,
headerVisible ? longHeaderStyle.off : longHeaderStyle.on,
@@ -177,27 +175,27 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
{topStickyContent}
</Animated.View>
</View>
<ScrollView
ref={scrollRef}
nestedScrollEnabled
bounces={false}
style={{ zIndex: 0 }}
stickyHeaderIndices={[1]}
scrollEventThrottle={1}
onScroll={onScroll}
>
<View style={{ height: Scroll_Distance, flexDirection: "column" }} />
{topStickyContent && (
<View
style={{
paddingTop: Min_Header_Height + verticalScale(40),
flexDirection: "column",
}}
//index={1}
/>
)}
{children}
</ScrollView>
<ScrollView
ref={scrollRef}
nestedScrollEnabled
bounces={false}
style={{ zIndex: 0 }}
stickyHeaderIndices={[1]}
scrollEventThrottle={1}
onScroll={onScroll}
>
<View style={{ height: Scroll_Distance, flexDirection: "column" }} />
{topStickyContent && (
<View
style={{
paddingTop: Min_Header_Height + verticalScale(40),
flexDirection: "column",
}}
//index={1}
/>
)}
{children}
</ScrollView>
</View>
);
};
+2 -6
View File
@@ -34,7 +34,6 @@ import type { NavigateFunction } from "@/types";
import { telemetry } from "@/lib/observability/telemetry";
import { FavoriteEmptyState } from "./FavoriteEmptyState";
import { StationListLoadingState } from "./StationListLoadingState";
import { markStartupOnce } from "@/lib/devStartupPerformance";
const menuCarouselTelemetry = telemetry.scope("menu.carousel");
@@ -79,11 +78,6 @@ export const CarouselBox = ({
isPositionLoading: boolean;
}) => {
const isAndroid = Platform.OS === "android";
useEffect(() => {
markStartupOnce("menu-carousel-mounted", "menu carousel mounted", {
stationGroupCount: listUpStation.length,
});
}, [listUpStation.length]);
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
const carouselRef = useRef<ICarouselInstance>(null);
@@ -259,9 +253,11 @@ export const CarouselBox = ({
currentStation: listUpStation[listIndex],
navigate,
goTo: "menu",
//@ts-ignore
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
};
//@ts-ignore
SheetManager.show("StationDetailView", { payload });
};
+3 -2
View File
@@ -310,8 +310,9 @@ export const FixedContentBottom = (props) => {
backgroundColor="linear-gradient(120deg, rgba(247,135,54,0.208) 0%, rgba(54,125,247,0.208) 100%)"
flex={1}
onPressButton={() =>
props.navigate("generalWebView", {
uri: "https://xprocess.haruk.in/tags/JR%E5%9B%9B%E5%9B%BD%E5%88%97%E8%BB%8A%E4%BD%8D%E7%BD%AE%E6%83%85%E5%A0%B1%E3%82%A2%E3%83%97%E3%83%AA/",
props.navigate("howto", {
info: "https://xprocess.haruk.in/tags/JR%E5%9B%9B%E5%9B%BD%E5%88%97%E8%BB%8A%E4%BD%8D%E7%BD%AE%E6%83%85%E5%A0%B1%E3%82%A2%E3%83%97%E3%83%AA/",
goTo: "menu",
})
}
>
+1 -6
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React from "react";
import {
View,
Text,
@@ -19,11 +19,6 @@ export const JRSTraInfoBox = () => {
useTrainDelayData();
const { colors, fixed } = useThemeColors();
const { fontScale, moderateScale, verticalScale } = useResponsive();
useEffect(() => {
void refreshDelayData();
}, [refreshDelayData]);
const styles: { [key: string]: StyleProp<ViewStyle> } = {
touch: {
backgroundColor: fixed.primary,
-5
View File
@@ -6,7 +6,6 @@ import { useThemeColors } from "@/lib/theme";
import type { StationProps } from "@/lib/CommonTypes";
import { LED_vision } from "@/components/発車時刻表/LED_vidion";
import { LEDFrame } from "@/components/発車時刻表/LED_Vision_Component/LEDFrame";
import { markStartupOnce } from "@/lib/devStartupPerformance";
type MenuLEDProps = {
station: StationProps[];
@@ -40,10 +39,6 @@ const MenuLEDComponent = ({ station }: MenuLEDProps) => {
const activeStation =
preparedLED.key === stationKey ? preparedLED.station : null;
useEffect(() => {
markStartupOnce("menu-led-mounted", "menu LED mounted");
}, []);
useEffect(() => {
if (preparedLED.key === stationKey) return;
+1 -1
View File
@@ -56,7 +56,7 @@ export const NewsReleaseInfoBox: FC<navigateProps> = ({ navigate }) => {
};
const onPressItem = (d: newsDataType) => {
navigate("generalWebView", { uri: getPDFViewURL(d.url) });
navigate("howto", { info: getPDFViewURL(d.url), goTo: "menu" });
SheetManager.hide("NewsReleaseInfo");
};
+3 -2
View File
@@ -29,8 +29,9 @@ export const SpecialTrainInfoBox: FC<props> = ({ navigate }) => {
}, []);
const onPressItem: (d: specialDataType) => void = (d) => {
navigate("generalWebView", {
uri: getPDFViewURL("https://www.jr-shikoku.co.jp" + d.address),
navigate("howto", {
info: getPDFViewURL("https://www.jr-shikoku.co.jp" + d.address),
goTo: "menu",
});
SheetManager.hide("SpecialTrainInfo");
};
+1 -1
View File
@@ -24,7 +24,7 @@ export const TitleBar = () => {
<TouchableOpacity
onPress={() => Linking.openURL("https://www.jr-shikoku.co.jp")}
>
<Image source={require("@/assets/Header.png")} style={{ width: width, resizeMode: "contain", backgroundColor: colors.background, height: verticalScale(80) }} />
<Image source={require("../../assets/Header.png")} style={{ width: width, resizeMode: "contain", backgroundColor: colors.background, height: verticalScale(80) }} />
</TouchableOpacity>
</View>
);
@@ -1,519 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useThemeColors } from "@/lib/theme";
import { connectionStatusClient } from "@/lib/connectionStatusClient";
import {
getInitialServiceHealthResults,
isDeviceOffline,
runConnectionStatusChecks,
type ConnectionStatusFeatureFlags,
type ServiceHealthResult,
type ServiceHealthStatus,
} from "@/lib/connectionStatus";
import {
DEFAULT_JR_DATA_SYSTEM_ENV,
normalizeJrDataSystemEnvironment,
} from "@/lib/jrDataSystemEnvironment";
const STATUS_TEXT: Record<ServiceHealthStatus, { label: string }> = {
checking: { label: "確認中" },
ok: { label: "正常" },
degraded: { label: "更新遅延" },
error: { label: "接続できません" },
unknown: { label: "判定不能" },
};
const formatTime = (value: string | null | undefined): string => {
if (!value || !Number.isFinite(Date.parse(value))) return "—";
return dayjs(value).format("HH:mm:ss");
};
const formatAge = (value: number | null | undefined): string => {
if (value === null || value === undefined || !Number.isFinite(value)) return "—";
if (value < 60) return Math.max(0, Math.floor(value)) + "秒";
return Math.floor(value / 60) + "分" + Math.floor(value % 60) + "秒";
};
const readStoredBoolean = async (key: string): Promise<boolean> => {
try {
const value = await AS.getItem(key);
return value === true || value === "true";
} catch {
return false;
}
};
const loadConnectionStatusFeatureFlags = async (): Promise<ConnectionStatusFeatureFlags> => {
const [voicepeakEnabled, unyohubEnabled, elesiteEnabled] = await Promise.all([
readStoredBoolean(STORAGE_KEYS.VOICEPEAK_ENABLED),
readStoredBoolean(STORAGE_KEYS.USE_UNYOHUB),
readStoredBoolean(STORAGE_KEYS.USE_ELESITE),
]);
return { voicepeakEnabled, unyohubEnabled, elesiteEnabled };
};
const getStatusColor = (
status: ServiceHealthStatus,
colors: ReturnType<typeof useThemeColors>["colors"],
fixed: ReturnType<typeof useThemeColors>["fixed"],
) => {
switch (status) {
case "ok":
return fixed.buttonGreen;
case "degraded":
return colors.textCaution;
case "error":
return colors.textError;
case "unknown":
return colors.textTertiary;
case "checking":
default:
return colors.textAccent;
}
};
export const ConnectionStatusSettings = () => {
const navigation = useNavigation();
const { colors, fixed } = useThemeColors();
const [services, setServices] = useState<ServiceHealthResult[]>(() =>
getInitialServiceHealthResults(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const [isChecking, setIsChecking] = useState(false);
const [lastCheckedAt, setLastCheckedAt] = useState<string | null>(null);
const [isOffline, setIsOffline] = useState(false);
const [expandedServiceId, setExpandedServiceId] = useState<string | null>(null);
const isMountedRef = useRef(true);
const isCheckingRef = useRef(false);
const runIdRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
const runChecks = useCallback(async () => {
if (isCheckingRef.current) return;
isCheckingRef.current = true;
const runId = ++runIdRef.current;
const controller = new AbortController();
abortControllerRef.current = controller;
const offline = isDeviceOffline();
if (isMountedRef.current) {
setIsChecking(true);
setIsOffline(offline);
setLastCheckedAt(null);
setExpandedServiceId(null);
setServices(getInitialServiceHealthResults(DEFAULT_JR_DATA_SYSTEM_ENV));
}
try {
let nextEnvironment = DEFAULT_JR_DATA_SYSTEM_ENV;
try {
nextEnvironment = normalizeJrDataSystemEnvironment(
await AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV),
);
} catch {
// The default release environment is the same fallback used elsewhere.
}
const featureFlags = await loadConnectionStatusFeatureFlags();
if (!isMountedRef.current || runId !== runIdRef.current) return;
setServices(getInitialServiceHealthResults(nextEnvironment, featureFlags));
await runConnectionStatusChecks({
client: connectionStatusClient,
environment: nextEnvironment,
featureFlags,
signal: controller.signal,
isOffline: () => offline,
onServiceResult: (result) => {
if (!isMountedRef.current || runId !== runIdRef.current) return;
setServices((current) =>
current.map((service) => (service.id === result.id ? result : service)),
);
},
});
if (
isMountedRef.current &&
runId === runIdRef.current &&
!controller.signal.aborted
) {
setLastCheckedAt(new Date().toISOString());
}
} finally {
if (runId === runIdRef.current) {
isCheckingRef.current = false;
abortControllerRef.current = null;
if (isMountedRef.current) setIsChecking(false);
}
}
}, []);
useFocusEffect(
useCallback(() => {
void runChecks();
}, [runChecks]),
);
const hasBlockingProblem = services.some(
(service) =>
service.enabled !== false &&
service.checks.some(
(check) =>
check.role !== "optional" &&
["degraded", "error", "unknown"].includes(check.status),
),
);
const hasOptionalProblem = services.some(
(service) =>
service.enabled !== false &&
service.checks.some(
(check) =>
check.role === "optional" &&
["degraded", "error", "unknown"].includes(check.status),
),
);
const hasProblem = hasBlockingProblem || hasOptionalProblem;
const hasChecking = services.some(
(service) => service.enabled !== false && service.status === "checking",
);
const summary = hasChecking
? "サービスを確認しています"
: hasBlockingProblem
? "一部のサービスで問題が発生しています"
: hasOptionalProblem
? "一部の任意サービスを確認できません"
: "すべてのサービスは正常です";
return (
<View style={{ flex: 1, backgroundColor: fixed.primary }}>
<SheetHeaderItem
title="接続ステータス"
LeftItem={{ title: "< 設定", onPress: () => navigation.goBack() }}
/>
<ScrollView
style={{ flex: 1, backgroundColor: colors.backgroundSecondary }}
contentContainerStyle={{ paddingBottom: 24 }}
refreshControl={
<RefreshControl
refreshing={isChecking}
onRefresh={() => void runChecks()}
/>
}
>
<View
style={{
margin: 12,
padding: 16,
borderRadius: 10,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.borderCard,
}}
>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<MaterialCommunityIcons
name={
hasChecking && isChecking
? "loading"
: hasProblem
? "alert-circle-outline"
: "check-circle-outline"
}
size={28}
color={
hasChecking && isChecking
? colors.textAccent
: hasProblem
? colors.textCaution
: fixed.buttonGreen
}
/>
<Text
style={{
flex: 1,
marginLeft: 10,
color: colors.text,
fontSize: 17,
fontWeight: "700",
}}
>
{summary}
</Text>
</View>
{isOffline ? (
<View
style={{
marginTop: 12,
padding: 10,
borderRadius: 6,
backgroundColor: colors.backgroundTertiary,
}}
>
<Text style={{ color: colors.textError, fontWeight: "600" }}>
インターネットに接続されていません
</Text>
</View>
) : null}
<Text style={{ marginTop: 12, color: colors.textSecondary, fontSize: 13 }}>
最終確認: {lastCheckedAt ? formatTime(lastCheckedAt) : "—"}
</Text>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="接続ステータスを再確認"
disabled={isChecking}
onPress={() => void runChecks()}
style={{
marginTop: 12,
minHeight: 42,
borderRadius: 7,
backgroundColor: isChecking
? colors.backgroundTertiary
: fixed.primary,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 14,
}}
>
{isChecking ? (
<ActivityIndicator color={fixed.textOnPrimary} size="small" />
) : (
<MaterialCommunityIcons
name="refresh"
size={18}
color={fixed.textOnPrimary}
/>
)}
<Text
style={{
marginLeft: 6,
color: isChecking ? colors.textTertiary : fixed.textOnPrimary,
fontWeight: "700",
}}
>
{isChecking ? "確認中…" : "再確認"}
</Text>
</TouchableOpacity>
</View>
<Text
style={{
marginHorizontal: 16,
marginTop: 4,
marginBottom: 6,
color: colors.textTertiary,
fontSize: 13,
}}
>
アプリが利用する公開データを、画面を開いた時と再確認時だけ確認します。
</Text>
<View
style={{
marginHorizontal: 12,
borderRadius: 10,
overflow: "hidden",
backgroundColor: colors.surface,
}}
>
{services.map((service) => (
<ServiceStatusRow
key={service.id}
service={service}
expanded={expandedServiceId === service.id}
onPress={() =>
setExpandedServiceId((current) =>
current === service.id ? null : service.id,
)
}
colors={colors}
fixed={fixed}
/>
))}
</View>
<Text
style={{
marginHorizontal: 16,
marginTop: 12,
color: colors.textTertiary,
fontSize: 12,
lineHeight: 18,
}}
>
判定不能は、接続先の応答形式を確認できない状態です。列車の遅延や運休そのものは、API障害として判定しません。
</Text>
</ScrollView>
</View>
);
};
const ServiceStatusRow = ({
service,
expanded,
onPress,
colors,
fixed,
}: {
service: ServiceHealthResult;
expanded: boolean;
onPress: () => void;
colors: ReturnType<typeof useThemeColors>["colors"];
fixed: ReturnType<typeof useThemeColors>["fixed"];
}) => {
const status = STATUS_TEXT[service.status];
const isDisabled = service.enabled === false;
const displayStatus = isDisabled ? { label: "未使用" } : status;
const statusColor = isDisabled
? colors.textTertiary
: getStatusColor(service.status, colors, fixed);
const latestSuccess = service.checks.find((check) => check.lastSuccessAt);
const latestAge = service.checks.reduce<number | null>((worst, check) => {
if (check.ageSeconds === null || check.ageSeconds === undefined) return worst;
return worst === null ? check.ageSeconds : Math.max(worst, check.ageSeconds);
}, null);
const maxLatency = service.checks.reduce<number | null>((max, check) => {
if (check.latencyMs === undefined) return max;
return max === null ? check.latencyMs : Math.max(max, check.latencyMs);
}, null);
const detailMessage =
service.message ??
service.checks.find(
(check) => check.message && check.status !== "ok",
)?.message ??
"正常";
return (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={service.label + ": " + displayStatus.label}
onPress={onPress}
style={{
paddingHorizontal: 14,
paddingVertical: 13,
borderBottomWidth: 1,
borderBottomColor: colors.borderSecondary,
}}
>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<MaterialCommunityIcons
name={service.icon as any}
size={22}
color={colors.iconSecondary}
/>
<Text
style={{
flex: 1,
marginLeft: 10,
color: colors.text,
fontSize: 16,
fontWeight: "600",
}}
>
{service.label}
</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
{!isDisabled && service.status === "checking" ? (
<ActivityIndicator
size="small"
color={statusColor}
style={{ marginRight: 6 }}
/>
) : (
<View
style={{
width: 9,
height: 9,
borderRadius: 5,
backgroundColor: statusColor,
marginRight: 6,
}}
/>
)}
<Text style={{ color: statusColor, fontWeight: "700" }}>
{displayStatus.label}
</Text>
</View>
</View>
{expanded ? (
<View
style={{
marginTop: 12,
marginLeft: 32,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: colors.borderSecondary,
}}
>
<DetailLine
label="状態"
value={displayStatus.label}
valueColor={statusColor}
colors={colors}
/>
<DetailLine
label="最終データ更新"
value={formatTime(latestSuccess?.lastSuccessAt)}
colors={colors}
/>
<DetailLine
label="データ経過時間"
value={formatAge(latestAge)}
colors={colors}
/>
<DetailLine
label="API応答時間"
value={maxLatency === null ? "—" : maxLatency + " ms"}
colors={colors}
/>
<DetailLine
label="確認時刻"
value={formatTime(service.checks[0]?.checkedAt)}
colors={colors}
/>
<Text style={{ marginTop: 6, color: colors.textSecondary, fontSize: 12 }}>
{detailMessage}
</Text>
</View>
) : null}
</TouchableOpacity>
);
};
const DetailLine = ({
label,
value,
valueColor,
colors,
}: {
label: string;
value: string;
valueColor?: string;
colors: ReturnType<typeof useThemeColors>["colors"];
}) => (
<View style={{ flexDirection: "row", marginBottom: 5 }}>
<Text style={{ width: 125, color: colors.textTertiary, fontSize: 12 }}>
{label}
</Text>
<Text style={{ flex: 1, color: valueColor ?? colors.textSecondary, fontSize: 12 }}>
{value}
</Text>
</View>
);
+1 -1
View File
@@ -12,7 +12,7 @@ import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "@/storageControl";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react";
import Icon from "react-native-vector-icons/Entypo";
import { View, Text, TouchableOpacity, LayoutAnimation } from "react-native";
import lineColorList from "@/assets/originData/lineColorList";
import lineColorList from "../../../assets/originData/lineColorList";
import Ionicons from "react-native-vector-icons/Ionicons";
import { AS } from "@/storageControl";
import { AS } from "../../../storageControl";
import { useThemeColors } from "@/lib/theme";
export const FavoriteSettingsItem = ({ currentStation }) => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet } from "react-native";
import Animated, { useAnimatedRef } from "react-native-reanimated";
import { useNavigation } from "@react-navigation/native";
import Sortable from "react-native-sortables";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { FavoriteSettingsItem } from "./FavoliteSettings/FavoiliteSettingsItem";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { getFavoriteStationKey } from "@/lib/favoriteStationUtils";
+6 -6
View File
@@ -15,21 +15,21 @@ import { BigButton } from "../atom/BigButton";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
import dayjs from "dayjs";
import * as ExpoFelicaReader from "@/modules/expo-felica-reader/src";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import { saveWidgetData } from "@/modules/expo-felica-reader/src";
import type { FelicaCardInfo, FelicaHistoryEntry } from "@/modules/expo-felica-reader/src";
import { AS } from "@/storageControl";
import type { FelicaCardInfo, FelicaHistoryEntry } from "../../modules/expo-felica-reader/src";
import { AS } from "../../storageControl";
// felicaStationMap は巨大 (5900行) なため、遅延ロード
let _lookupFelicaStation: typeof import("@/lib/felicaStationMap").lookupFelicaStation | null = null;
let _lookupFelicaStation: typeof import("../../lib/felicaStationMap").lookupFelicaStation | null = null;
async function getLookupFelicaStation() {
if (!_lookupFelicaStation) {
const mod = await import("@/lib/felicaStationMap");
const mod = await import("../../lib/felicaStationMap");
_lookupFelicaStation = mod.lookupFelicaStation;
}
return _lookupFelicaStation;
}
import { STORAGE_KEYS } from "@/constants";
import { STORAGE_KEYS } from "../../constants";
import { requestWidgetUpdate } from "react-native-android-widget";
import {
FelicaQuickAccessWidget,
+4 -4
View File
@@ -13,10 +13,10 @@ import LottieView from "lottie-react-native";
import { useNavigation } from "@react-navigation/native";
import { ListItem } from "@rneui/themed";
import * as Updates from "expo-updates";
import { AS } from "@/storageControl";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import icons, { series as defaultSeries } from "@/assets/icons/icons";
import hubIcons, { series as hubSeries } from "@/assets/unyoHubIcons/icons";
import icons, { series as defaultSeries } from "../../assets/icons/icons";
import hubIcons, { series as hubSeries } from "../../assets/unyoHubIcons/icons";
import { setAlternateAppIcon, getAppIconName } from "expo-alternate-app-icons";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
@@ -362,7 +362,7 @@ export const LauncherIconSettings = () => {
{!ready ? (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.background }}>
<LottieView
source={require("@/assets/51690-loading-diamonds.json")}
source={require("../../assets/51690-loading-diamonds.json")}
autoPlay
loop
style={{ width: 120, height: 120 }}
+14 -14
View File
@@ -47,17 +47,17 @@ export const LayoutSettings = ({
bool={iconSetting}
setBool={setIconSetting}
firstItem={{
firstImage: require("@/assets/configuration/icon_default.jpg"),
firstImage: require("../../assets/configuration/icon_default.jpg"),
firstText: "本家\n(文字アイコン)",
firstValue: "default",
}}
secondItem={{
secondImage: require("@/assets/configuration/icon_original.jpg"),
secondImage: require("../../assets/configuration/icon_original.jpg"),
secondText: "オリジナル\n(車種アイコン)",
secondValue: "original",
}}
thirdItem={{
thirdImage: require("@/assets/configuration/icon_hub.jpg"),
thirdImage: require("../../assets/configuration/icon_hub.jpg"),
thirdText: "鉄道運用Hub\n(Hubアイコン)",
thirdValue: "hub",
}}
@@ -67,8 +67,8 @@ export const LayoutSettings = ({
str="列車表示"
bool={uiSetting}
setBool={setUiSetting}
falseImage={require("@/assets/configuration/layout_default.jpg")}
trueImage={require("@/assets/configuration/layout_tokyo.jpg")}
falseImage={require("../../assets/configuration/layout_default.jpg")}
trueImage={require("../../assets/configuration/layout_tokyo.jpg")}
falseText={"本家"}
trueText={"オリジナル"}
falseValue="default"
@@ -78,8 +78,8 @@ export const LayoutSettings = ({
str="トップメニュー表示"
bool={mapSwitch}
setBool={setMapSwitch}
falseImage={require("@/assets/configuration/menu_default.jpg")}
trueImage={require("@/assets/configuration/menu_original.jpg")}
falseImage={require("../../assets/configuration/menu_default.jpg")}
trueImage={require("../../assets/configuration/menu_original.jpg")}
falseText={"本家\n(文字&路線リスト)"}
trueText={"オリジナル\n(マップ&お気に入り)"}
/>
@@ -89,8 +89,8 @@ export const LayoutSettings = ({
setBool={setStationMenu}
falseText={"本家\n(文字)"}
trueText={"オリジナル\n(駅名標)"}
falseImage={require("@/assets/configuration/station_default.jpg")}
trueImage={require("@/assets/configuration/station_original.jpg")}
falseImage={require("../../assets/configuration/station_default.jpg")}
trueImage={require("../../assets/configuration/station_original.jpg")}
>
<SimpleSwitch
bool={usePDFView}
@@ -104,25 +104,25 @@ export const LayoutSettings = ({
setBool={setTrainMenu}
falseText={"本家"}
trueText={"オリジナル"}
falseImage={require("@/assets/configuration/train_default.jpg")}
trueImage={require("@/assets/configuration/train_original.jpg")}
falseImage={require("../../assets/configuration/train_default.jpg")}
trueImage={require("../../assets/configuration/train_original.jpg")}
>
<TripleSwitchArea
str={"ヘッダーサイズ"}
bool={headerSize}
setBool={setHeaderSize}
firstItem={{
firstImage: require("@/assets/configuration/train_original_small.jpg"),
firstImage: require("../../assets/configuration/train_original_small.jpg"),
firstText: "小固定",
firstValue: "small",
}}
secondItem={{
secondImage: require("@/assets/configuration/train_original.jpg"),
secondImage: require("../../assets/configuration/train_original.jpg"),
secondText: "既定(可変)",
secondValue: "default",
}}
thirdItem={{
thirdImage: require("@/assets/configuration/train_original.jpg"),
thirdImage: require("../../assets/configuration/train_original.jpg"),
thirdText: "大固定",
thirdValue: "big",
}}
+2 -2
View File
@@ -3,9 +3,9 @@ import { View, Text, TouchableOpacity, ScrollView } from "react-native";
import * as Clipboard from "expo-clipboard";
import { CheckBox } from "@rneui/themed";
import { AS } from "@/storageControl";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useNotification } from "@/stateBox/useNotifications";
import { useNotification } from "../../stateBox/useNotifications";
import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
-208
View File
@@ -1,208 +0,0 @@
import React, { useCallback, useMemo, useState } from "react";
import {
FlatList,
Linking,
Platform,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
import {
filterOssLicensePackages,
resolveOssLicenseText,
type OssLicenseData,
type OssLicensePackage,
} from "@/lib/ossLicenses";
import licenseData from "@/assets/licenses/oss-licenses.json";
const data = licenseData as OssLicenseData;
const keyOf = (pkg: OssLicensePackage) => `${pkg.name}@${pkg.version}`;
export const OssLicenses = () => {
const navigation = useNavigation();
const { colors, fixed } = useThemeColors();
const [query, setQuery] = useState("");
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const packages = useMemo(
() => filterOssLicensePackages(data.packages, query),
[query],
);
const toggle = useCallback((key: string) => {
setExpandedKey((current) => (current === key ? null : key));
}, []);
const renderItem = useCallback(
({ item }: { item: OssLicensePackage }) => (
<LicenseRow
pkg={item}
expanded={expandedKey === keyOf(item)}
onPress={toggle}
/>
),
[expandedKey, toggle],
);
const header = (
<View>
<Text style={{ color: colors.textSecondary, fontSize: 13, padding: 16, lineHeight: 20 }}>
本アプリは以下のオープンソースソフトウェアを利用しています。項目をタップするとライセンス全文を表示します。
</Text>
<SectionHeader title="改変して同梱しているソフトウェア" />
{data.vendored.map((pkg) => (
<LicenseRow
key={keyOf(pkg)}
pkg={pkg}
expanded={expandedKey === keyOf(pkg)}
onPress={toggle}
/>
))}
<SectionHeader title={`依存ライブラリ(${packages.length}件)`} />
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="パッケージ名・ライセンス名で検索"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
style={{
backgroundColor: colors.backgroundInput,
color: colors.text,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 15,
}}
/>
</View>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: fixed.primary }}>
<SheetHeaderItem
title="オープンソースライセンス"
LeftItem={{ title: "< 設定", onPress: () => navigation.goBack() }}
/>
<FlatList
style={{ flex: 1, backgroundColor: colors.backgroundSecondary }}
data={packages}
keyExtractor={keyOf}
renderItem={renderItem}
extraData={expandedKey}
ListHeaderComponent={header}
ListEmptyComponent={
<Text style={{ color: colors.textTertiary, textAlign: "center", padding: 20 }}>
該当するパッケージはありません
</Text>
}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
initialNumToRender={20}
windowSize={11}
/>
</View>
);
};
const LicenseRow = React.memo(
({
pkg,
expanded,
onPress,
}: {
pkg: OssLicensePackage;
expanded: boolean;
onPress: (key: string) => void;
}) => {
const { colors } = useThemeColors();
const text = expanded ? resolveOssLicenseText(data, pkg) : null;
return (
<View
style={{
backgroundColor: colors.surface,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
}}
>
<TouchableOpacity
onPress={() => onPress(keyOf(pkg))}
style={{ flexDirection: "row", alignItems: "center", padding: 14 }}
accessibilityRole="button"
accessibilityState={{ expanded }}
>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontSize: 15 }} numberOfLines={1}>
{pkg.name}
</Text>
<Text style={{ color: colors.textTertiary, fontSize: 12, marginTop: 2 }}>
{pkg.version} ・ {pkg.license}
{pkg.note ? ` ・ ${pkg.note}` : ""}
</Text>
</View>
<MaterialCommunityIcons
name={expanded ? "chevron-up" : "chevron-down"}
size={22}
color={colors.iconSecondary}
/>
</TouchableOpacity>
{expanded && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14 }}>
{pkg.repository && (
<TouchableOpacity
onPress={() => Linking.openURL(pkg.repository!)}
style={{ marginBottom: 10 }}
accessibilityRole="link"
>
<Text style={{ color: colors.textAccent, fontSize: 13 }}>
{pkg.repository}
</Text>
</TouchableOpacity>
)}
<Text
selectable
style={{
color: colors.textSecondary,
fontSize: 11,
lineHeight: 16,
fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
}}
>
{text ?? `このパッケージにはライセンスファイルが同梱されていません(${pkg.license})。`}
</Text>
</View>
)}
</View>
);
},
);
LicenseRow.displayName = "LicenseRow";
const SectionHeader = ({ title }: { title: string }) => {
const { colors } = useThemeColors();
return (
<Text
style={{
fontSize: 13,
fontWeight: "600",
color: colors.textTertiary,
letterSpacing: 0.5,
marginTop: 12,
marginLeft: 16,
marginBottom: 6,
}}
>
{title}
</Text>
);
};
@@ -22,10 +22,6 @@ import {
} from "@/lib/jrDataSystemEnvironment";
import { STORAGE_KEYS } from "@/constants";
import { AS } from "@/storageControl";
import {
ERROR_BOUNDARY_TEST_SCOPES,
armErrorBoundaryTest,
} from "@/lib/errorBoundaryTest";
export const ResearchToolsSettings = () => {
const navigation = useNavigation();
@@ -136,21 +132,6 @@ export const ResearchToolsSettings = () => {
}
};
const confirmErrorBoundaryTest = (scope: string, label: string) => {
Alert.alert(
"エラー画面テスト",
`${label}でエラー画面を表示します。Sentryにテスト用のイベントが1件送信されます。`,
[
{ text: "キャンセル", style: "cancel" },
{
text: "表示する",
style: "destructive",
onPress: () => armErrorBoundaryTest(scope),
},
],
);
};
const openRecordingDatabase = () => {
const uri = `${backendApiBaseUrl}/recorder?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
const params = {
@@ -599,49 +580,6 @@ export const ResearchToolsSettings = () => {
</Text>
</TouchableOpacity>
</View>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
デバッグ: エラー画面テスト
</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
選んだ範囲で意図的に描画エラーを起こし、エラー画面を確認します。タブを選んだ場合は、そのタブを開くとエラー画面が表示されます。
</Text>
<View style={styles.buttonRow}>
{ERROR_BOUNDARY_TEST_SCOPES.map(({ scope, label }) => (
<TouchableOpacity
key={scope}
onPress={() => confirmErrorBoundaryTest(scope, label)}
style={[
styles.neutralButton,
{ backgroundColor: colors.borderSecondary },
]}
>
<Text
style={[
styles.neutralButtonText,
{ color: colors.textPrimary },
]}
>
{label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</>
)}
</ScrollView>
+16 -45
View File
@@ -13,12 +13,11 @@ import { useNavigation } from "@react-navigation/native";
import { ListItem } from "@rneui/themed";
import TouchableScale from "react-native-touchable-scale";
import { SwitchArea } from "../atom/SwitchArea";
import { useNotification } from "@/stateBox/useNotifications";
import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { MaterialCommunityIcons } from "@expo/vector-icons";
const versionCode = "7.2.2";
const versionCode = "7.2.1";
export const SettingTopPage = ({
testNFC,
@@ -43,7 +42,7 @@ export const SettingTopPage = ({
<View style={{ height: 300, padding: 10 }}>
<View style={{ flex: 1 }} />
<Image
source={require("@/assets/Header.png")}
source={require("../../assets/Header.png")}
style={{
aspectRatio: 8.08,
height: undefined,
@@ -89,15 +88,14 @@ export const SettingTopPage = ({
str="起動時に表示する画面"
bool={startPage}
setBool={setStartPage}
falseImage={require("@/assets/configuration/showSetting0.jpg")}
trueImage={require("@/assets/configuration/showSetting1.jpg")}
falseImage={require("../../assets/configuration/showSetting0.jpg")}
trueImage={require("../../assets/configuration/showSetting1.jpg")}
falseText={"リンクメニュー"}
trueText={"列車位置情報"}
/>
<ColorThemePicker value={colorTheme} onChange={setColorTheme} />
<SettingList
string="レイアウト設定"
icon="view-dashboard-outline"
onPress={() =>
navigation.navigate("setting", { screen: "LayoutSettings" })
}
@@ -106,28 +104,24 @@ export const SettingTopPage = ({
<SectionHeader title="カスタマイズ" />
<SettingList
string="お気に入り登録の並び替え"
icon="sort-ascending"
onPress={() =>
navigation.navigate("setting", { screen: "FavoriteSettings" })
}
/>
<SettingList
string="アイコン設定"
icon="image-multiple-outline"
onPress={() =>
navigation.navigate("setting", { screen: "LauncherIconSettings" })
}
/>
<SettingList
string="サウンド設定(β)"
icon="volume-high"
onPress={() =>
navigation.navigate("setting", { screen: "SoundSettings" })
}
/>
<SettingList
string="運行情報設定(β)"
icon="alert-circle-outline"
onPress={() =>
navigation.navigate("setting", { screen: "OperationInfoSettings" })
}
@@ -138,7 +132,6 @@ export const SettingTopPage = ({
string={`通知設定${
expoPushToken == "" ? "(通知設定をオンにしてください)" : "(β)"
}`}
icon="bell-outline"
onPress={() =>
navigation.navigate("setting", { screen: "NotificationSettings" })
}
@@ -146,22 +139,13 @@ export const SettingTopPage = ({
/>
<SettingList
string="情報ソース設定"
icon="database-outline"
onPress={() =>
navigation.navigate("setting", { screen: "DataSourceSettings" })
}
/>
<SettingList
string="接続ステータス"
icon="server-network"
onPress={() =>
navigation.navigate("setting", { screen: "ConnectionStatus" })
}
/>
{showResearchTools && (
<SettingList
string="調査ツール"
icon="tools"
onPress={() =>
navigation.navigate("setting", { screen: "ResearchToolsSettings" })
}
@@ -171,24 +155,21 @@ export const SettingTopPage = ({
<SectionHeader title="その他" />
<SettingList
string="プライバシーポリシー"
icon="shield-lock-outline"
onPress={() =>
Linking.openURL(
"https://xprocess.haruk.in/JR-shikoku-Apps-Common/NativeAppsPrivacyPolicy"
"https://nexcloud.haruk.in/sites/press-harukin/JRShikokuApps/policy"
)
}
/>
<SettingList
string="運営Twitter"
icon="twitter"
onPress={() => Linking.openURL("https://twitter.com/Xprocess_main")}
string="開発情報"
onPress={() =>
Linking.openURL("https://nexcloud.haruk.in/s/2WFEN5oLPGtrRqT")
}
/>
<SettingList
string="オープンソースライセンス"
icon="license"
onPress={() =>
navigation.navigate("setting", { screen: "OssLicenses" })
}
string="運営Twitter"
onPress={() => Linking.openURL("https://twitter.com/Xprocess_main")}
/>
</View>
</ScrollView>
@@ -214,31 +195,21 @@ export const SettingTopPage = ({
);
};
const SettingList = ({
string,
icon,
onPress,
disabled = false,
}: {
string: string;
icon?: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
onPress: () => void;
disabled?: boolean;
}) => {
const SettingList = ({ string, onPress, disabled = false }: { string: string; onPress: () => void; disabled?: boolean }) => {
const { colors } = useThemeColors();
return (
<ListItem
// @ts-expect-error RNEUIのListItem型はComponentに渡したTouchableScaleのpropsを含まない
// @ts-ignore: TouchableScale props passed through Component
activeScale={0.95}
Component={TouchableScale}
bottomDivider
onPress={onPress}
disabled={disabled}
// @ts-ignore: containerStyle type mismatch with TouchableScale Component
containerStyle={{ backgroundColor: colors.surface }}
>
{icon ? <MaterialCommunityIcons name={icon} size={24} color={colors.iconSecondary} /> : null}
<ListItem.Content>
<ListItem.Title numberOfLines={1} style={{ color: colors.text }}>{string}</ListItem.Title>
<ListItem.Title style={{ color: colors.text }}>{string}</ListItem.Title>
</ListItem.Content>
<ListItem.Chevron />
</ListItem>
+2 -2
View File
@@ -9,7 +9,7 @@ import {
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "@/storageControl";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useThemeColors } from "@/lib/theme";
import { Asset } from "expo-asset";
@@ -22,7 +22,7 @@ import {
import { VoicepeakDebugLogSection } from "@/components/Settings/VoicepeakDebugLogSection";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const previewSound = require("@/assets/sound/rikka-test.mp3");
const previewSound = require("../../assets/sound/rikka-test.mp3");
export const SoundSettings = () => {
const { goBack } = useNavigation();
+2 -26
View File
@@ -12,9 +12,9 @@ import {
import type { SettingsStackParamList } from "@/types/navigation";
import { createStackNavigator } from "@react-navigation/stack";
import { pushTransitionOptions } from "@/lib/stackOption";
import * as ExpoFelicaReader from "@/modules/expo-felica-reader/src";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import * as Updates from "expo-updates";
import { AS } from "@/storageControl";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { Switch } from "@rneui/themed";
import { SettingTopPage } from "./SettingTopPage";
@@ -28,8 +28,6 @@ import { ResearchToolsSettings } from "./ResearchToolsSettings";
import { FelicaHistoryPage } from "./FelicaHistoryPage";
import { SoundSettings } from "./SoundSettings";
import { OperationInfoSettings } from "./OperationInfoSettings";
import { ConnectionStatusSettings } from "./ConnectionStatusSettings";
import { OssLicenses } from "./OssLicenses";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
normalizeIconDisplayMode,
@@ -300,28 +298,6 @@ export default function Setting(props) {
/>
)}
</Stack.Screen>
<Stack.Screen
name="ConnectionStatus"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={ConnectionStatusSettings}
/>
<Stack.Screen
name="OssLicenses"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={OssLicenses}
/>
</Stack.Navigator>
);
}
+10 -6
View File
@@ -1,6 +1,6 @@
import * as SplashScreen from "expo-splash-screen";
import { useCallback, useEffect, useRef, useState } from "react";
import { StyleSheet } from "react-native";
import { Platform, StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
@@ -19,22 +19,24 @@ const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1);
// Keep the native splash visible until the React overlay has been mounted.
// This must run at module scope so the native splash is not hidden too early.
void SplashScreen.preventAutoHideAsync().catch(() => {
// The native splash may already be hidden during a reload.
});
if (Platform.OS !== "web") {
void SplashScreen.preventAutoHideAsync().catch(() => {
// The native splash may already be hidden during a reload.
});
}
export default function StartupOverlay() {
const reducedMotion = useReducedMotion();
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
const [visible, setVisible] = useState(true);
const [visible, setVisible] = useState(Platform.OS !== "web");
const hasStarted = useRef(false);
const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationFrame = useRef<number | null>(null);
const finish = useCallback(() => {
if (hasStarted.current) return;
if (Platform.OS === "web" || hasStarted.current) return;
hasStarted.current = true;
if (fallbackTimer.current !== null) {
@@ -78,6 +80,8 @@ export default function StartupOverlay() {
}, [opacity, reducedMotion, scale]);
useEffect(() => {
if (Platform.OS === "web") return;
// A local bundled image normally fires onLoadEnd immediately. The
// fallback keeps a broken image from leaving the native splash forever.
fallbackTimer.current = setTimeout(finish, FALLBACK_DELAY);
@@ -101,10 +101,10 @@ export const ExGridSimpleViewItem: FC<{
const payload = {
currentStation: returnDataBase,
navigate,
goTo: "StationDiagram",
//@ts-ignore
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
};
}; //@ts-ignore
setTimeout(() => SheetManager.show("StationDetailView", { payload }), 50);
} else {
SheetManager.hide("StationDetailView");
@@ -128,6 +128,7 @@ export const ExGridSimpleViewItem: FC<{
from: d.isOperating ? null : "AllTrainIDList",
};
SheetManager.show("EachTrainInfo", {
//@ts-ignore
payload,
onClose: () => {
//alert(data);
+3 -2
View File
@@ -108,10 +108,10 @@ export const ExGridViewItem: FC<{
const payload = {
currentStation: returnDataBase,
navigate,
goTo: "StationDiagram",
//@ts-ignore
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
};
}; //@ts-ignore
setTimeout(() => SheetManager.show("StationDetailView", { payload }), 50);
} else {
SheetManager.hide("StationDetailView");
@@ -132,6 +132,7 @@ export const ExGridViewItem: FC<{
from: d.isOperating ? null :"AllTrainIDList",
};
SheetManager.show("EachTrainInfo", {
//@ts-ignore
payload,
onClose: () => {
//alert(data);
+3 -2
View File
@@ -181,10 +181,10 @@ export const ListViewItem: FC<{
const payload = {
currentStation: returnDataBase,
navigate,
goTo: "StationDiagram",
//@ts-ignore
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
};
}; //@ts-ignore
setTimeout(() => SheetManager.show("StationDetailView", { payload }), 50);
} else {
SheetManager.hide("StationDetailView");
@@ -205,6 +205,7 @@ export const ListViewItem: FC<{
from: "AllTrainIDList",
};
SheetManager.show("EachTrainInfo", {
//@ts-ignore
payload,
onClose: () => {
//alert(data);
@@ -69,16 +69,11 @@ export const StationDiagramView: FC<props> = ({ route }) => {
// フィルタリング:終点路線、種別、行先、関係停車駅
const { keyList, allTrainDiagram } = useTrainDiagram();
const { allCustomTrainData, activateCustomTrainData } = useCustomTrainData();
const { todayOperation, activateOperationLogs } = useOperationLogs();
const { allCustomTrainData } = useCustomTrainData();
const { todayOperation } = useOperationLogs();
const { useUnyohub: unyohubEnabled } = useUnyohub();
const { useElesite: elesiteEnabled } = useElesite();
useEffect(() => {
activateCustomTrainData();
activateOperationLogs();
}, [activateCustomTrainData, activateOperationLogs]);
const { goBack } = useNavigation();
const { colors, fixed } = useThemeColors();
const tabBarHeight = useBottomTabBarHeight();
@@ -366,7 +361,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
autoPlay
loop
style={{ width: 80, height: 80 }}
source={require("@/assets/51690-loading-diamonds.json")}
source={require("../../assets/51690-loading-diamonds.json")}
/>
<Text style={{ color: colors.text, marginTop: 12, fontSize: 14 }}>読み込み中...</Text>
</View>
+1 -1
View File
@@ -38,7 +38,7 @@ export const MapPin: FC<Props> = (props) => {
>
<View style={styles.markerWrap}>
<Image
source={require("@/assets/reccha-small.png")}
source={require("../../assets/reccha-small.png")}
style={styles.markerImage}
resizeMode="contain"
/>
+1 -1
View File
@@ -1,7 +1,7 @@
import React, { FC } from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useThemeColors } from "@/lib/theme";
import lineColorList from "@/assets/originData/lineColorList";
import lineColorList from "../../assets/originData/lineColorList";
type Props = {
+1 -1
View File
@@ -12,7 +12,7 @@ export const SimpleSwitch = ({
setBool,
color,
value,
image = require("@/assets/icons.png"),
image = require("../../assets/icons.png"),
subText = "",
}) => {
const { colors } = useThemeColors();
+2 -2
View File
@@ -223,8 +223,8 @@ export default function TrainMenu({ style }) {
icon="train-car"
flex={1}
onPressButton={() =>
navigate("generalWebView", {
uri: "https://xprocess.haruk.in/JR-shikoku-Apps-Common/info/train-position",
navigate("howto", {
info: "https://xprocess.haruk.in/JR-shikoku-Apps-Common/info/train-position",
})
}
>
+9
View File
@@ -0,0 +1,9 @@
import type { StyleProp, ViewStyle } from "react-native";
type TrainMenuProps = {
style?: StyleProp<ViewStyle>;
};
export default function TrainMenu(_props: TrainMenuProps) {
return null;
}
@@ -2,7 +2,7 @@ import { View, Text } from "react-native";
import {
useCurrentTrainData,
useCurrentTrainView,
} from "@/stateBox/useCurrentTrain";
} from "../../../stateBox/useCurrentTrain";
import LottieView from "lottie-react-native";
import { Ionicons, AntDesign } from "@expo/vector-icons";
import { useStationList } from "@/stateBox/useStationList";
@@ -43,7 +43,7 @@ export const Header = ({ station }) => {
autoPlay
loop
style={{ width: 40, height: 40, marginRight: 30 }}
source={require("@/assets/51690-loading-diamonds.json")}
source={require("../../../assets/51690-loading-diamonds.json")}
/>
) : currentTrainLoading == "error" ? (
<Ionicons
@@ -1,6 +1,6 @@
import { Text } from "react-native";
import { Switch } from "@rneui/themed";
import { AS } from "@/storageControl";
import { AS } from "../../../storageControl";
import { useThemeColors } from "@/lib/theme";
export const SwitchBox = (props) => {
const { value, setValue, setKey, title } = props;
@@ -1,6 +1,6 @@
import React, { FC, useState, useRef, useEffect } from "react";
import { Text, TextStyle, View, TouchableOpacity } from "react-native";
import { useInterval } from "@/lib/useInterval";
import { useInterval } from "../../../lib/useInterval";
import { useThemeColors } from "@/lib/theme";
const descriptionStyle: TextStyle = {
@@ -1,6 +1,6 @@
import React, { FC } from "react";
import { Text, View } from "react-native";
import { getTrainType } from "@/lib/getTrainType";
import { getTrainType } from "../../../lib/getTrainType";
import { useThemeColors } from "@/lib/theme";
import { trainTypeID } from "@/lib/CommonTypes";
type Props = {
@@ -3,7 +3,7 @@ import { Text, TextStyle, View } from "react-native";
import {
trainDataType,
trainPosition,
} from "@/lib/trainPositionTextArray";
} from "../../../lib/trainPositionTextArray";
import { useThemeColors } from "@/lib/theme";
import { OfficialPositionEditButton } from "@/components/OfficialPositionEditButton";
import type { NavigateFunction } from "@/types";
+2 -11
View File
@@ -46,7 +46,6 @@ import {
} from "@/lib/checkDuplicateTrainData";
import { trainPosition } from "@/lib/trainPositionTextArray";
import { LEDFrame } from "@/components/発車時刻表/LED_Vision_Component/LEDFrame";
import { markStartupOnce } from "@/lib/devStartupPerformance";
const readBooleanSetting = async (key: string) => {
try {
@@ -120,8 +119,7 @@ export const LED_vision: FC<props> = (props) => {
const [isInfoArea, setIsInfoArea] = useState(false);
const { areaInfo, areaStationID } = useAreaInfo();
const { allTrainDiagram } = useTrainDiagram();
const { allCustomTrainData, activateCustomTrainData } =
useCustomTrainData();
const { allCustomTrainData } = useCustomTrainData();
const { fixed } = useThemeColors();
const [voicepeakSettings, setVoicepeakSettings] =
useState<VoicepeakSettings | null>(null);
@@ -148,12 +146,6 @@ export const LED_vision: FC<props> = (props) => {
);
const [nativeVoicepeakPlaybackKey, setNativeVoicepeakPlaybackKey] = useState(0);
useEffect(() => {
// LEDが実際に表示される段階で列車補助情報を再検証する。
// キャッシュまたはcustomTrainDataDetectorのfallbackは先に利用できる。
activateCustomTrainData();
}, [activateCustomTrainData]);
const refreshVoicepeakSettings = useCallback(() => {
loadVoicepeakSettings()
.then((settings) => {
@@ -541,13 +533,12 @@ export const LED_vision: FC<props> = (props) => {
}, 1000);
useEffect(() => {
markStartupOnce("led-content-ready", "LED content ready");
onContentReady?.();
}, [onContentReady, station]);
const content = (
<>
{nativeVoicepeakHtml !== EMPTY_NATIVE_VOICEPEAK_HTML && (
{Platform.OS !== "web" && nativeVoicepeakHtml !== EMPTY_NATIVE_VOICEPEAK_HTML && (
<WebView
key={nativeVoicepeakPlaybackKey}
source={{ html: nativeVoicepeakHtml }}
+1 -1
View File
@@ -20,7 +20,7 @@ export const LottieDelayView: FC<Props> = ({ progress }) => {
speed={1.4}
style={{ width: 80, height: 80 }}
loop={false}
source={require("@/assets/939-star.json")}
source={require("../../assets/939-star.json")}
ref={lottieRef}
onAnimationFinish={(isCanceled) => {
setProgressState(progress);
+1 -1
View File
@@ -8,7 +8,7 @@ import {
ViewStyle,
} from "react-native";
import { StationName } from "./StationName";
import lineColorList from "@/assets/originData/lineColorList";
import lineColorList from "../../assets/originData/lineColorList";
import { useThemeColors, lightColors } from "@/lib/theme";
export const NextPreStationLine = ({ nexStation, preStation, isMatsuyama }) => {
const 下枠フレーム: ViewStyle = {
+4 -7
View File
@@ -7,23 +7,20 @@ import {
} from "react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import LottieView from "lottie-react-native";
import { useInterval } from "@/lib/useInterval";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { useInterval } from "../../lib/useInterval";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { StationNameArea } from "./StationNameArea";
import { StationNumberMaker } from "./StationNumberMaker";
import { NextPreStationLine } from "./NextPreStationLine";
import { LottieDelayView } from "./LottieDelayView";
import { AddressText } from "./AddressText";
import { useStationList } from "@/stateBox/useStationList";
import { useStationList } from "../../stateBox/useStationList";
import { useThemeColors, lightColors } from "@/lib/theme";
import { markStartupOnce } from "@/lib/devStartupPerformance";
export default function Sign(props) {
const { oP, oLP, isCurrentStation = false, stationID } = props;
markStartupOnce("menu-station-sign-render", "menu station sign render");
const { width, height } = useWindowDimensions();
const { getStationDataFromId, originalStationList } = useStationList();
const { fixed } = useThemeColors();
@@ -176,7 +173,7 @@ export default function Sign(props) {
height: ((width * 0.8) / 20) * 9,
backgroundColor: lightColors.surface,
}}
source={require("@/assets/StationSign.json")}
source={require("../../assets/StationSign.json")}
/>
)}
<StationNumberMaker {...{ currentStation: currentStationData }} />
-15
View File
@@ -48,26 +48,11 @@ export const API_ENDPOINTS = {
/** 本番列車位置情報 */
CURRENT_POSITIONS: `${BASE_URL}/tmp/currentPositions.json`,
/** 列車位置R2障害時のGAS fallback */
CURRENT_POSITIONS_FALLBACK: 'https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec',
/** 臨時列車情報 */
SPECIAL_TRAIN_INFO_STORAGE: `${BASE_URL}/sptrainfo`,
/** ニュースリリース情報 */
NEWS_RELEASES_STORAGE: `${BASE_URL}/news-releases/latest.json`,
/** VOICEPEAK公開APIのベースURL(音声生成は通常機能からのみPOST) */
VOICEPEAK_PUBLIC_BASE_URL: 'https://voicepeak-api.haruk.in',
} as const;
/** 接続ステータス画面で利用する公開health endpoint */
export const HEALTH_ENDPOINTS = {
/** operation-monitor の公開health */
OPERATION_MONITOR: 'https://jrs-operation-info.haruk.in/health',
/** VOICEPEAK公開APIの公開health(音声生成は実行しない) */
VOICEPEAK: `${API_ENDPOINTS.VOICEPEAK_PUBLIC_BASE_URL}/healthz`,
} as const;
/**
-3
View File
@@ -18,9 +18,6 @@ export const STORAGE_KEYS = {
/** 全列車ダイアグラム */
ALL_TRAIN_DIAGRAM: 'allTrainDiagram',
/** 列車補助情報(列車名・種別・編成など) */
CUSTOM_TRAIN_DATA: 'customTrainData',
/** 遅延データ */
DELAY_DATA: 'delayData',
@@ -1,33 +0,0 @@
# 接続ステータス health contract
## 境界
接続先のJSON/textは各adapterが解釈し、画面は正規化されたHealthCheckResultとServiceHealthResultだけを購読する。
API response → adapter/parser → HealthCheckResult → role-aware service aggregation → UI
画面から個別URL、認証情報、raw exceptionを参照しない。将来のsystem-health aggregatorはこのadapter境界へ追加する。
## 状態
- checking: 実行中
- ok: 到達可能でschema/freshnessが正常
- degraded: stale、更新遅延、diagnostic障害、fallbackなど
- error: timeout、network error、HTTP 5xx、primaryの明示的error
- unknown: healthなし、malformed、未知status、初期化中など
## role
- primary: ユーザー機能を成立させる公開データ。error/degraded/unknownをサービス状態へ反映する。
- diagnostic: 生成Workerや補助API。primaryがokでも障害時はdegradedへするがerrorへ昇格しない。
- optional: 設定ON時の補助機能またはfallback。optionalだけの障害で機能をerrorにしない。
primary > diagnostic > optionalという重要度であり、単純な全checkの最悪値集約は使用しない。primaryがokでdiagnosticが問題ならdegraded、primaryがerrorならerrorとする。
## 実行制約
- 画面focus時、手動再確認、pull-to-refreshだけで実行する。
- 1サービスの失敗はPromise.allSettledで他サービスを止めない。
- 7秒timeout、AbortController、unmount後commit防止を維持する。
- POST、token付きGET、admin endpoint、VOICEPEAK音声生成はstatus確認に使用しない。
- status画面へ追加するのは公開GETと、ユーザー機能を成立させる安全なデータ確認だけとする。
@@ -1,134 +0,0 @@
# 接続ステータス外部依存監査
監査日: 2026-09-18
## 集計
同じURLをRN、WebView、Widget、Live Activityから呼ぶケースは1つの論理依存へまとめて集計した。
- 接続ステータスへ追加・維持: 18論理依存(VOICEPEAKは音声生成runtimeとsafe healthを1機能として集計)
- 現在利用中だがユーザー向け接続ステータス対象外: 9論理依存
- 管理者・開発用または観測基盤: 2論理依存
- 定数だけ残るlegacy/未使用、または他repo情報でURLが確定した未使用依存: 4系統
- routeは確定したがproduction hostが未確定: 2 Worker(3 health route)
- 将来のdeep health追加TODO: 1系統(Position API WorkerのD1確認)
「現在利用中」は、画面・hook・WebView注入・Widget・Live Activity・設定ON・明示操作からruntime到達できるものとして判定した。定数や古いドキュメントにしか登場しないURLは含めていない。
## 1. 接続ステータスへ追加・維持したもの
| 機能 | 内部サービス | URL / method | 呼び出し元 | 現在利用中 | role | 確認方法 | healthの深さ |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 列車走行位置 | R2公開snapshot | https://jr-shikoku-api-data-storage.haruk.in/tmp/currentPositions.json / GET | stateBox/useCurrentTrain.tsx | yes | primary | data配列、fetchedAt、180秒超をdegraded | 公開データschema + freshness |
| 列車走行位置 | GAS fallback | https://script.google.com/macros/s/AKfycby9Y2-Bm75J_WkbZimi7iS8v5r9wMa9wtzpdwES9sOGF4i6HIYEJOM60W6gM1gXzt1o/exec / GET | stateBox/useCurrentTrain.tsx | yes | optional | 配列JSONを検証。状態・freshnessは返らない | fallbackの軽量schema確認のみ |
| 列車走行位置 | position problems n8n | https://n8n.haruk.in/webhook/jrshikoku-position-problems / GET | lib/webViewInjectjavascript.ts | yes | diagnostic | dataキーを持つJSONを検証 | 生成元の死活・payload envelopeのみ |
| 運行情報 | operation-monitor | https://jrs-operation-info.haruk.in/health / GET | lib/connectionStatus.ts、R2運行情報の生成系 | yes | diagnostic | status: ok/stale/error/initializingを正規化 | Worker health。DB deep healthではない |
| 運行情報 | 公開運行情報snapshot | https://jr-shikoku-api-data-storage.haruk.in/operation-info/jr-shikoku/latest.json / GET | stateBox/useAreaInfo.tsx、Widget | yes | primary | fetchedAt、compatibility.areaInfo、operationInfoText | アプリが読む公開JSONのschema |
| 運行情報 | Go Backend operation logs | https://jr-shikoku-backend-api-v1.haruk.in/operation-logs(experimentalはhttps://jr-shikoku-backend-api-v1-beta.haruk.in/operation-logs) / GET | stateBox/useAllTrainDiagram.tsx、WebView注入 | yes | diagnostic | data: nullまたは配列 | Backend endpoint到達 + envelope |
| 時刻表・ダイヤ | R2 diagram | production /tmp/diagram-today.json、experimental /tmp/diagram-today-beta.json / GET | stateBox/useAllTrainDiagram.tsx | yes | primary | 配列JSON | 配信データ取得・schema。位置情報と同じfreshness閾値は使わない |
| 列車・駅データ | Go Backend | https://jr-shikoku-backend-api-v1.haruk.in/ping(experimentalはhttps://jr-shikoku-backend-api-v1-beta.haruk.in/ping) / GET | connection status | yes | diagnostic | HTTP 2xxかつ {message: pong pong} | HTTP process healthのみ。DB正常とは表示しない |
| 列車・駅データ | Go Backend train-data | https://jr-shikoku-backend-api-v1.haruk.in/train-data(experimentalはhttps://jr-shikoku-backend-api-v1-beta.haruk.in/train-data) / GET | stateBox/useAllTrainDiagram.tsx、WebView注入 | yes | primary | data配列 | 公開API schema |
| 列車・駅データ | n8n station list | https://n8n.haruk.in/webhook/jr-shikoku-station-list / GET | lib/webViewInjectjavascript.ts | yes | diagnostic | 配列または非空object | WebView補助データの到達確認 |
| 列車・駅データ | バス・列車補助GAS | https://script.google.com/macros/s/AKfycbw0UW6ZeCDgUYFRP0zxpc_Oqfy-91dBdbWv-cM8n3narKp14IyCd2wy5HW7taXcW7E/exec / GET | stateBox/useBusAndTrainData.tsx(AsyncStorage miss時) | yes(条件付き) | optional | 配列JSON | 補助配信のschemaのみ |
| 遅延速報EX | canonical derived data | https://jr-shikoku-api-data-storage.haruk.in/derived/delays/latest.json / GET | 定数・status check | yes(現行画面の主経路ではない) | diagnostic | stale、sourceFetchedAt/observedAt | 公開derived JSON。遅延列車数は障害判定に使わない |
| 遅延速報EX | 現行legacy text | https://jr-shikoku-api-data-storage.haruk.in/legacy/trainfo-ex.txt / GET | stateBox/useTrainDelayData.tsx、Android Widget | yes | primary | textとして取得できること | HTTP/text到達のみ。text内部のfreshnessは判定しない |
| 臨時列車 | R2 sptrainfo | https://jr-shikoku-api-data-storage.haruk.in/sptrainfo / GET | components/Menu/SpecialTrainInfoBox.tsx | yes | primary | {data: []}を含む配列envelope。0件は正常 | 公開JSON。special-train-dbの /health はroute確定、production host待ち |
| ニュース | R2 news releases | https://jr-shikoku-api-data-storage.haruk.in/news-releases/latest.json / GET | components/Menu/NewsReleaseInfoBox.tsx | yes | primary | {items: []}を含むitems配列。0件は正常 | 公開JSON。/news-releases/health はroute確定、production host待ち |
| 音声読み上げ | VOICEPEAK health | https://voicepeak-api.haruk.in/healthz / GET | lib/connectionStatus.ts | yes(VOICEPEAK_ENABLED=true時) | optional | ok:true、workerHealthy:false、publicSpeech.enabledを検証。enabled=falseはdegraded、malformedはunknown、503はerror | 公開health。内部workerも確認するが、アプリからDB等のdeep healthは表示しない |
| 音声読み上げ | VOICEPEAK public speech | https://voicepeak-api.haruk.in/v1/public/speech / POST | lib/voicepeak.ts、components/発車時刻表/LED_vidion.tsx | yes(VOICEPEAK_ENABLED=true時) | runtimeのみ | 接続ステータスからは一切POSTしない | 音声生成endpoint。生成・CPU負荷・コストを伴うため死活確認には使わない |
| 鉄道運用Hub | R2 thirdparty | https://jr-shikoku-api-data-storage.haruk.in/thirdparty/unyohub-unyo.json / GET | stateBox/useUnyohub.tsx、WebView注入 | yes(設定ON時) | optional | 配列JSON | 補助データschemaのみ |
| えれサイト | R2 thirdparty | https://jr-shikoku-api-data-storage.haruk.in/thirdparty/elesite-unyo.json / GET | stateBox/useElesite.tsx、WebView注入 | yes(設定ON時) | optional | 配列JSON | 補助データschemaのみ |
VOICEPEAKは、通常機能では /v1/public/speech をPOSTするが、接続ステータスは認証不要の /healthz だけをGETする。ok:falseまたは内部worker異常はerror、publicSpeech.enabled:falseはdegraded、想定外schemaはunknownとする。設定OFF時は一覧から非表示にし、RECORDER_ADMIN_TOKENやVOICEPEAK secretは追加しない。
## 2. 利用中だがユーザー向け接続ステータス対象外
| 機能 / 内部名称 | URL / 呼び出し元 | 現在利用中 | 対象外理由 |
| --- | --- | --- | --- |
| 公式在線WebView | https://train.jr-shikoku.co.jp/sp.html、ページ内公式XHR / components/Apps/WebView.tsx、Apps.tsx | yes | 主要機能だが相手サイトの軽量healthがなく、status画面のためにHTML/XHRを追加取得すると負荷と二重取得になる。R2位置データをprimaryとする |
| 公式運行情報WebView | https://www.jr-shikoku.co.jp/info/ / ndView.tsx、hidden preload | yes | 公式ページ全体の取得を診断目的に行わない。R2 snapshot + operation-monitor healthを対象とする |
| 公式在線位置補完Backend | 環境Backendの /api/v1/official-positions/resolve?line=...&pos=...&pos_num=... / lib/officialPositionService.ts | yes | 現在位置から有効なtupleが必要。status画面のために架空IDを送らず、位置snapshot取得後の補完処理で個別に失敗を吸収する |
| アンパンマン列車状態 | https://n8n.haruk.in/webhook/yosan-anpanman?trainNum=...、dosan-anpanman?trainNum=... / trainIconStatus.tsx | yes(対象列車だけ) | 固定の安全なmetadata/healthがなく、任意列車の日付queryをstatus確認のために送らない。表示用の任意補助 |
| 通知設定登録 | https://n8n.haruk.in/webhook/jr-shikoku-notification-configurations / NotificationSettings.tsx | yes(登録操作時) | Push tokenを含む副作用POST。status画面から実行しない |
| 権限確認 | 環境Backendの /check-permission?user_id=<push-token> / useTrainMenu.tsx | yes(token取得後) | 識別子を含むユーザー権限API。health checkのためにtokenを流用しない |
| 駅住所LOD | 駅データのjslodApi + .json、主に https://uedayou.net/jrslod/... / components/駅名表/AddressText.tsx | yes(駅名標表示時) | 駅ごとの動的URLで、主要鉄道機能に必須でない外部LOD。全駅分を取得しない |
| jr-shikoku-data-system画面 | https://jr-shikoku-data-system.pages.dev/...、shikoku-railinfo.haruk.in、push token query / 各Menu・TrainDataSources | yes(明示操作時) | ユーザーが開く外部Web画面。識別子を含むためstatus用probeを追加しない |
| 録画import / GeneralWebView | routeで渡された任意URL、blob、recording download / GeneralWebView.tsx | yes(明示操作時) | 任意URLを扱う機能。監視対象を固定できず、recording backendのadmin endpointも呼ばない |
| 画像・CDN・公式リンク | storage.haruk.in、n8n画像、fossil.2pd.jp、JR四国/JR駅公式URL | yes(表示・明示操作時) | 装飾asset、車両画像、PDF、外部リンク。主要データサービスの接続状態ではない |
## 3. 管理者・開発用または観測基盤
| 名称 | URL / 呼び出し元 | 判定 |
| --- | --- | --- |
| Position mock API | https://jr-shikoku-backend-mock-api-v1.haruk.in/position-masters、/train-positions/current / lib/mockApi/positionMasters.ts | admin専用のmock設定時だけ。一般ユーザー向け一覧には出さない |
| Position API Worker root | https://jr-shikoku-backend-mock-api-v1.haruk.in/ / GET | 他repoでproduction URLとroot応答を確認済み。現行アプリの通常runtime callはなく、一覧には出さない。rootはWorker aliveのみでD1 deep healthではない |
| Sentry / telemetry / Expo Updates | lib/observability/sentry.ts、lib/observability/telemetry.ts、UpdateAsync.ts、expo-updates | 観測・配布基盤。鉄道機能のユーザー向けhealthではない。token/DSNやupdate通信を画面へ表示しない |
HTTP transportはRN側が標準fetchと既存observedFetchJson/observedFetchTextであり、axios、Nitro Fetch、ky、superagentはruntime採用されていない。WebView内部はWebView側のfetch/XHRでRN wrapperの観測範囲外である。
## 4. legacy / 未使用
| 名称 | 根拠 | 扱い |
| --- | --- | --- |
| API_ENDPOINTS.CUSTOM_TRAIN_DATA | constants/api.tsにのみ存在し、runtime call siteなし | 未使用。現行列車データはBackend /train-data |
| API_ENDPOINTS.SPECIAL_TRAIN_INFO | constants/api.tsにのみ存在し、runtime call siteなし | 未使用。現行臨時列車はR2 /sptrainfo |
| recording-backend public health | https://rec-jr-s.haruk.in/healthz / GET(READMEでproduction URLを確認) | URLは確定したが、現行走行履歴は端末local storageで完結するためstatus一覧には出さない。/recorder/statusはadmin token必須なのでアプリから呼ばない |
| README・旧data-fetch-mapの旧n8n/GAS経路 | 現行sourceのcall siteと一致しない記述が残る | 削除せず、今回のruntime監査では現行sourceを正本とした |
### 候補名はあるが現行runtime call siteが見つからなかったもの
- JR西日本運行情報の直接API
- 怪レい列車のデータAPI(通知設定キーはあるが、データ取得APIはない)
- position-collector、special-train-db、news-releases Workerのproduction host(routeは確定、Cloudflare実環境のhost待ち)
- Position API Workerの通常ユーザー向けendpoint(root URLは確定したが現行runtime callなし)
- recording-backend。走行履歴は現行sourceでは端末local storage(public /healthz URLは確定)
- アプリ独自のremote config/version JSON。Expo Updatesは配布基盤として別分類
- font/assetのremote取得。主要フォント・音声・画像はbundleまたは機能画面で個別取得
駅情報の基本リストはlib/getStationList.tsとassetsの静的データで、n8n station-listはWebView補助取得として扱った。
## 5. 判断不能 / backend改修TODO
以下はrouteまたはproduction hostが未確定のため、URLを推測してアプリから呼んでいない。確定済みのVOICEPEAK、Main Backend、Position API root、recording /healthzはこの一覧から除外した。
1. **position-collector /health**
- Worker名はjr-shikoku-position-collector、公開routeは/health、status値はok/stale/error/initializingまで確定している。
- production Custom Domainがrepo configにないため、Cloudflare実環境でworkers.dev hostを確認するまで列車走行位置のdiagnosticへ追加しない。hostを推測・ハードコードしない。
2. **special-train-db /health**
- Worker名はjr-shikoku-special-train-db、公開routeは/health、D1 stateを読む正規healthである。
- production hostがrepo configにないため、Cloudflare実環境で確認するまで臨時列車のdiagnosticへ追加しない。
3. **special-train-db /news-releases/health**
- 上記Workerの同一hostに/news-releases/healthが存在し、ニュース用の別監視項目として利用できる。
- production hostが未確定のため、ニュースのdiagnosticへ仮URLを追加しない。
4. **Position API Workerのdeep health**
- root https://jr-shikoku-backend-mock-api-v1.haruk.in/ と応答schemaは確定しているが、現行アプリの通常runtime callはないため一覧へ追加しない。
- rootはWorker aliveのみ。将来利用する場合はD1 SELECT 1等を行う/healthを別途追加する。
5. **recording-backendのdeep status**
- public https://rec-jr-s.haruk.in/healthz は確定しているが、現行走行履歴は端末local storageで完結するため一覧へ追加しない。
- /recorder/statusはadmin token必須。アプリから呼ばず、RECORDER_ADMIN_TOKENも追加しない。
怪レい列車API、special-train-db、news-releases Worker、Position API Worker、recording-backendのstatus画面用POST/管理APIは、確認のために呼び出していない。現行アプリsourceでユーザー機能のruntime callがないものは、監視対象へ捏造追加していない。
## 6. role集約の契約
- primaryのerror/degraded/unknownは機能状態へ反映する。
- primaryがokでdiagnosticがerror/degraded/unknownなら機能はdegradedとする。diagnostic errorを機能全体のerrorへ昇格しない。
- optionalのerror/degraded/unknownだけでは機能状態をerrorにしない。画面上部には任意サービスの問題を別文言で示す。
- VOICEPEAK設定がOFFの場合はservice定義自体を作らず、一覧から非表示にする。
- VOICEPEAKの /healthz はoptional checkとして扱い、失敗しても鉄道機能全体をerrorにしない。
- HTTP 200だけではokにせず、health status・JSON schema・公開データのfreshnessをadapterで判定する。
## 7. 将来system-health aggregatorへ移行する場合
画面はServiceHealthResultだけを購読し、URLや個別JSONを解釈しない。将来はlib/connectionStatus.tsのservice definitions/clientを/system-health adapterへ差し替え、次を維持すればよい。
- HealthCheckResultのid/status/checkedAt/latencyMs/lastSuccessAt/ageSeconds
- primary/diagnostic/optionalのrole
- disabled/optional機能の表示ルール
- incremental callback、AbortController、7秒timeout、Promise.allSettled
backend側のhealth追加は別repo・別変更として行い、アプリへsecret、admin token、Worker secret、n8n secretを追加しない。
+10 -14
View File
@@ -33,8 +33,8 @@ Sentryで観測している `endpoint=positions / operation_info / train_operati
| positions n8n | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` | `n8n.haruk.in` | `/webhook/c501550c-7d1b-4e50-927b-4429fe18931a` | positions | 起動時、mock切替時、interval | 15秒 | 8000ms | 最大1回 | なし | Google Apps Script fallback | `positions_current` / source=`n8n` / user_visible=`true` / preload=`false` / fetch_priority=`high` | mock API無効時の主経路。成功時は録画snapshotにも使う。 |
| positions GAS fallback | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` catch内 | `script.google.com` | `/macros/s/AKfyc.../exec` | positions | n8n走行位置取得失敗時のみ | n8n失敗時のみ | 8000ms | 最大1回 | 既存データとmerge | 前回表示データ維持 | `positions_gas_fallback` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`high` | Direction等が欠けるため既存データとmerge。 |
| timetable today | `stateBox/useAllTrainDiagram.tsx` | `getTrainDiagram` | `jr-shikoku-api-data-storage.haruk.in` | `/tmp/diagram-today.json` または `/tmp/diagram-today-beta.json` | provider全体 / timetable consumers | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | AsyncStorage `ALL_TRAIN_DIAGRAM` | cache失敗時はalert | `timetable_today` / source=`static_storage` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | ダイヤ・発車標・列車詳細で広く使用。 |
| train operation data | `stateBox/useAllTrainDiagram.tsx` | `getCustomTrainData` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/train-data` | topMenu LED / positions / train detail / diagram | 必要画面のactivation後。起動時はAsyncStorageの`CUSTOM_TRAIN_DATA`を先に表示し、バックグラウンドで再検証 | activation後30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | AsyncStorage `CUSTOM_TRAIN_DATA`(有効期限24時間) | LED/列車情報の既存fallback | `train_operation_data` / source=`backend_api` / user_visible=`false` / preload=`false` / fetch_priority=`medium` | 約1MB級。positions WebViewには別途1日キャッシュがある。 |
| operation logs | `stateBox/useAllTrainDiagram.tsx` | `getTodayOperation` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/operation-logs` | positions / train detail / diagram | 必要画面のactivation後。topMenu単独では取得しない | activation後30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_logs` / source=`backend_api` / user_visible=`false` / preload=`false` / fetch_priority=`medium` | 列車別運行ログ。LED表示には使用しない。WebView内では別途取得される。 |
| train operation data | `stateBox/useAllTrainDiagram.tsx` | `getCustomTrainData` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/train-data` | provider全体 / train detail / diagram | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `train_operation_data` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 約1MB級。WebView内でも別途取得される。 |
| operation logs | `stateBox/useAllTrainDiagram.tsx` | `getTodayOperation` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/operation-logs` | provider全体 / topMenu / train detail | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_logs` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 列車別運行ログ。WebView内でも別途取得される。 |
| operation flag | `stateBox/useAreaInfo.tsx` | `getAreaData` | `n8n.haruk.in` | `/webhook/jr-shikoku-trainfo-flag` | topMenu / information / provider全体 | mount後、InteractionManager後に1200ms遅延 | 60秒 | 10000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_info_flag` / source=`n8n` / user_visible=`true` / preload=`true` / fetch_priority=`medium` | topMenuのバッジ・対象エリア判定。 |
| operation text GAS | `stateBox/useAreaInfo.tsx` | `fetchAreaDescription` | `script.google.com` | `/macros/s/AKfycbz80.../exec` | topMenu / information | operation flagで対象駅ありの場合だけ800ms遅延 | flag結果依存 | 15000ms | なし | なし | 失敗は握りつぶし | `operation_info_text` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`medium` | 第1回観測でtimeout。現在は通常時の実行を抑制。 |
@@ -53,8 +53,8 @@ Sentryで観測している `endpoint=positions / operation_info / train_operati
| positions WebView unyohub | `lib/webViewInjectjavascript.ts` | `unyohubDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/unyohub-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_unyohub` | 鉄道運用Hub由来の集約JSON。 |
| positions WebView elesite | `lib/webViewInjectjavascript.ts` | `elesiteDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/elesite-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_elesite` | えれサイト由来の集約JSON。 |
| operation official page | `ndView.tsx` | operation WebView | `www.jr-shikoku.co.jp` | `/info/` | information | 運行情報root初回activate時 | WebView lifecycle依存 | WebView標準 | WebView標準 | OS/WebView依存 | remount watchdog | 未計測 | injected scriptでページ内容を加工し、画像生成結果をpostMessage。 |
| operation hidden preload | `AppContainer.tsx` | `HiddenStartupPreloadWebViews` | `www.jr-shikoku.co.jp` | `/info/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | Sentry breadcrumb/contextのみ。 |
| positions hidden preload | `AppContainer.tsx` | `HiddenStartupPreloadWebViews` | `train.jr-shikoku.co.jp` | `/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | 走行位置WebViewの事前ウォームアップ。 |
| operation hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `www.jr-shikoku.co.jp` | `/info/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | Sentry breadcrumb/contextのみ。 |
| positions hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `train.jr-shikoku.co.jp` | `/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | 走行位置WebViewの事前ウォームアップ。 |
## Sentry endpoint対象外だが存在するRN fetch
@@ -174,14 +174,14 @@ timetable / station diagram / train detail
### アプリ起動直後
`useAllTrainDiagram` のproviderはダイヤ本体だけを起動時に取得する。大きい列車補助情報と運行ログは、実際に必要な画面がactivationした後に取得する。`useCurrentTrain` のpollingはroot screenのfocus leaseで制御する。
Providerがmountされるため、`useAllTrainDiagram` などのprovider-owned fetchは表示タブに関係なく走り得る。`useCurrentTrain` のpollingだけはroot screenのfocus leaseで制御する。
| 取得 | 起動時挙動 | 備考 |
|---|---|---|
| `useCurrentTrain.getCurrentTrain` | positions/topMenuのフォーカス時に即時1回、その後15秒interval | mock ON/OFFで経路が変わる。blur/unmount時にintervalとin-flight requestを停止し、再フォーカス時に再開する。 |
| `useAllTrainDiagram.getTrainDiagram` | 即時1回、その後30秒interval | timetable。AsyncStorage fallbackあり。 |
| `useAllTrainDiagram.getCustomTrainData` | 起動時にAsyncStorage cacheを読み、必要画面のactivation後に即時1回、その後30秒interval | train_operation。cacheは24時間有効、成功レスポンスを再保存する。 |
| `useAllTrainDiagram.getTodayOperation` | positions / train detail / diagramのactivation後に即時1回、その後30秒interval | operation_logs。topMenu単独では取得しない。 |
| `useAllTrainDiagram.getCustomTrainData` | 即時1回、その後30秒interval | train_operation。 |
| `useAllTrainDiagram.getTodayOperation` | 即時1回、その後30秒interval | operation_info。 |
| `useAreaInfo.getAreaData` | InteractionManager後 + 1200ms遅延。その後60秒interval | operation_info flag。対象ありならGAS textも800ms遅延。 |
| `useTrainMenu.check-permission` | push token取得後 | Sentry未計測。 |
| hidden preload WebViews | preload条件成立時 | positions公式/operation公式を不可視WebViewでロード。 |
@@ -191,9 +191,7 @@ timetable / station diagram / train detail
| 取得 | 発生源 | 備考 |
|---|---|---|
| positions RN polling | `useCurrentTrain` provider | positions/topMenuのフォーカス中だけ15秒。blur/unmount時にintervalとin-flight requestを停止する。 |
| timetable | `useAllTrainDiagram` provider | 起動時に取得し、30秒ごとに更新。 |
| train_operation | `useAllTrainDiagram` provider + 各利用画面 | topMenu LED、positions、列車詳細、ダイヤ画面のactivation後だけ取得し、30秒ごとに更新。 |
| operation_logs | `useAllTrainDiagram` provider + 各利用画面 | positions、列車詳細、ダイヤ画面のactivation後だけ取得し、topMenu単独では取得しない。 |
| timetable / train_operation / operation_logs | `useAllTrainDiagram` provider | 表示タブに関係なく30秒ごと。 |
| operation flag / text | `useAreaInfo` | topMenu表示内容に直結。 |
| special train info | `SpecialTrainInfoBox` | 該当Sheet表示時のみ。 |
@@ -229,12 +227,10 @@ timetable / station diagram / train detail
T+0ms app providers mount
├─ useCurrentTrain -> positions current fetch
├─ useAllTrainDiagram -> timetable fetch
├─ useAllTrainDiagram -> train_operation fetch
├─ useAllTrainDiagram -> operation_logs fetch
└─ useTrainMenu -> settings/env load, maybe check-permission after token
必要画面のactivation後
├─ topMenu LED / positions / train detail / diagram -> train_operation fetch
└─ positions / train detail / diagram -> operation_logs fetch
T+after interactions + 1200ms
└─ useAreaInfo -> operation_info_flag
└─ if active area exists: +800ms -> operation_info_text GAS
+3 -1
View File
@@ -16,7 +16,8 @@ NavigationContainer (Apps.tsx)
│ ├─ Apps
│ ├─ trainbase { info, from? }
│ ├─ stDiagram { currentStation }
│ ├─ generalWebView { uri, useExitButton?, importRecordingDownloads?, onExit? }
│ ├─ howto { info, goTo?, useShow? }
│ ├─ generalWebView { uri, useExitButton?, importRecordingDownloads? }
│ ├─ news
│ ├─ trainMenu
│ └─ favoriteList
@@ -33,6 +34,7 @@ NavigationContainer (Apps.tsx)
│ │ └─ FelicaHistoryPage
│ ├─ trainbase
│ ├─ AllTrainIDList
│ ├─ howto
│ └─ generalWebView
└─ information
└─ TNDView (ndView.tsx)

Some files were not shown because too many files have changed in this diff Show More