Compare commits

..
Author SHA1 Message Date
harukin-expo-dev-env deb24caaa2 feat: add station progress tracking and notification updates for train follow feature 2026-03-22 17:09:19 +00:00
harukinMBP 5515f42415 feat: add Live Activity button for train tracking and update iOS deployment target 2026-03-23 02:07:24 +09:00
harukin-expo-dev-env fbfb83fa34 feat: add live activity notifications for train tracking and station locking
- Implemented ExpoLiveActivity module for Android to manage live notifications.
- Added foreground service for train tracking and station locking notifications.
- Updated app permissions to include POST_NOTIFICATIONS.
- Enhanced FixedStation and FixedTrain components to support live notifications.
- Introduced new notification builders for train and station activities.
- Updated useCurrentTrain and useNotifications hooks to manage live notification state.
- Added notification channel for live tracking in Android.
2026-03-22 16:15:48 +00:00
harukinMBP 8eb49f57d6 fix: update activity state handling to use ActivityContent with nil staleDate 2026-03-22 23:09:13 +09:00
harukinMBP c5d4dc3b65 feat: add automatic Live Activity updates for train position and station lock in TrainDataView and StationDiagramView 2026-03-22 23:03:07 +09:00
harukinMBP 7f2480bc01 feat: add Live Activity support for station locking in StationDiagramView 2026-03-22 23:01:00 +09:00
harukinMBP b8372e5087 feat: add hooks for managing Live Activities for station locking and train following 2026-03-22 22:51:58 +09:00
harukinMBP 75c07f013d feat: add Live Activities support for train tracking and station locking 2026-03-22 22:49:05 +09:00
harukin-expo-dev-env 46bfea4e13 feat: add androidGoogleMapsApiKey to app.json configuration 2026-03-22 13:17:53 +00:00
harukin-expo-dev-env 5a2dc8c6a8 feat: add appleTeamId to iOS configuration in app.json 2026-03-22 11:48:59 +00:00
harukin-expo-dev-env 06650d014a feat(widget): add Shortcut, Delay Info, and Felica Balance widgets
- Implemented ShortcutWidget for quick access to app features with customizable shortcuts.
- Added DelayInfoWidget to display train delay information fetched from a remote endpoint.
- Created FelicaBalanceWidget to show the balance of Felica-compatible IC cards.
- Introduced OperationInfoWidget for displaying train operation status.
- Set up shared data handling for Felica snapshots between the main app and widget.
- Configured widget assets and entitlements for proper functionality.
- Updated Info.plist and expo-target.config.js for widget deployment.
2026-03-22 11:45:58 +00:00
harukin-expo-dev-env 45feeece58 feat: FeliCa対応の可用性チェック機能を追加 2026-03-22 10:13:36 +00:00
harukinMBP 385c2d8b86 Update app.json build number to 58 and refine NFC error handling in ExpoFelicaReaderModule 2026-03-22 18:56:13 +09:00
harukinMBP 41cc70086a Implement NFC scanning functionality in ExpoFelicaReader module 2026-03-21 22:04:55 +09:00
harukinMBP 98c71127aa Fix .gitignore: re-include modules/**/ios/ for EAS build, remove debug code 2026-03-21 20:13:34 +09:00
harukinMBP d049d3b07d Debug: patch resolveCommand.js and apple.js with logging, add pod fallback 2026-03-21 20:03:40 +09:00
harukinMBP 8484f15092 Enhanced autolinking debug: use external JS file 2026-03-21 19:46:25 +09:00
harukinMBP 1afa5e4377 Add Podfile debug plugin to trace autolinking resolve 2026-03-21 19:33:22 +09:00
harukinMBP 9f6d86b8b6 Fix ExpoFelicaReader module: restore full definition, update diagnostics 2026-03-21 17:54:54 +09:00
harukinMBP 72e7d63bd7 Add expo-felica-reader to dependencies for EAS build autolinking 2026-03-21 17:54:39 +09:00
harukin-expo-dev-env b243439e78 Refactor code structure for improved readability and maintainability 2026-03-20 07:14:58 +00:00
harukin-expo-dev-envandCopilot 65f3b2a877 fix: ダークモード - 設定リスト・お気に入り並び替え・SNSリストの表示修正
- SettingTopPage: SettingList の containerStyle を直接設定して背景色を適用
- FavoiliteSettingsItem: 駅名テキストと並び替えアイコンに color: colors.text を追加
- SocialMenu: SNS一覧の ListItem に containerStyle と ListItem.Title の色を追加

Co-authored-by: Copilot <[email protected]>
2026-03-19 08:46:14 +00:00
harukin-expo-dev-envandCopilot 925d162f26 feat: iPad サポートを有効化
app.json の supportsTablet を true に変更

Co-authored-by: Copilot <[email protected]>
2026-03-18 08:44:36 +00:00
harukin-expo-dev-envandCopilot 731fe504c6 perf: dev client で expo-updates をスキップ
__DEV__ 時は checkForUpdateAsync() が不要なため早期リターン

Co-authored-by: Copilot <[email protected]>
2026-03-18 08:38:10 +00:00
harukin-expo-dev-envandCopilot 942ec395f1 perf: metro transformer に experimentalImportSupport を復元
lazyImports: true と組み合わせて動作するオプション。
resolver への誤設定は除外し transformer のみに適用。

Co-authored-by: Copilot <[email protected]>
2026-03-18 07:42:55 +00:00
harukin-expo-dev-env 306cf6882e chore: 不要なビルドファイル build-1773815539430.apk を削除 2026-03-18 07:12:45 +00:00
harukin-expo-dev-envandCopilot 468bb4633a chore: metro.config.js から deprecated な experimentalImportSupport を削除
SDK 55 の Metro では不要。resolver への設定は誤り。

Co-authored-by: Copilot <[email protected]>
2026-03-18 07:11:15 +00:00
303 changed files with 4541 additions and 32164 deletions
-3
View File
@@ -58,6 +58,3 @@ android/
ios/
!modules/**/ios/
*.ipa
*.apk
*.aab
.env.local
-7
View File
@@ -1,7 +0,0 @@
{
"mcpServers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/xprocess-m5/jr-shikoku-unofficial-apps"
}
}
}
+27 -185
View File
@@ -1,11 +1,5 @@
import React, { useEffect } from "react";
import {
IS_LOW_DENSITY,
DEX_SCALE,
useRealWindowDimensions,
} from "./utils/dexDimensionOverride";
import { Linking, Platform, UIManager, View } from "react-native";
import { enableFreeze, enableScreens } from "react-native-screens";
import { Linking, Platform, UIManager } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "./utils/disableFontScaling"; // グローバルなフォントスケーリング無効化
import { AppContainer } from "./Apps";
@@ -16,7 +10,7 @@ import { CurrentTrainProvider } from "./stateBox/useCurrentTrain";
import { AreaInfoProvider } from "./stateBox/useAreaInfo";
import { BusAndTrainDataProvider } from "./stateBox/useBusAndTrainData";
import { AllTrainDiagramProvider } from "./stateBox/useAllTrainDiagram";
import { SheetProvider, SheetManager } from "react-native-actions-sheet";
import { SheetProvider } from "react-native-actions-sheet";
import "./components/ActionSheetComponents/sheets";
import { TrainDelayDataProvider } from "./stateBox/useTrainDelayData";
import { SafeAreaProvider } from "react-native-safe-area-context";
@@ -26,89 +20,22 @@ import { buildProvidersTree } from "./lib/providerTreeProvider";
import { StationListProvider } from "./stateBox/useStationList";
import { NotificationProvider } from "./stateBox/useNotifications";
import { UserPositionProvider } from "./stateBox/useUserPosition";
import {
markStartupExplicitTarget,
resolveStartupNavigationSource,
rootNavigationRef,
stackAwareNavigate,
} from "./lib/rootNavigation";
import { rootNavigationRef } from "./lib/rootNavigation";
import { AppThemeProvider } from "./lib/theme";
import StatusbarDetect from "./StatusbarDetect";
import * as Sentry from '@sentry/react-native';
import {
startAppLifecycleCrashSentinel,
stopAppLifecycleCrashSentinel,
} from "./lib/observability/appLifecycleCrashSentinel";
Sentry.init({
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128',
// Adds more context data to events (IP address, cookies, user, etc.)
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
sendDefaultPii: true,
// Enable Logs
enableLogs: true,
// Configure Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
tracesSampleRate: __DEV__ ? 1.0 : 0.05,
beforeSend(event) {
const dataFetch = event.contexts?.data_fetch as any;
if (dataFetch?.responseHead && typeof dataFetch.responseHead === "string") {
dataFetch.responseHead = dataFetch.responseHead.slice(0, 300);
}
return event;
},
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: __DEV__,
});
LogBox.ignoreLogs([
"ViewPropTypes will be removed",
"ColorPropType will be removed",
]);
if (Platform.OS === "ios") {
enableFreeze(false);
enableScreens(false);
}
if (Platform.OS === "android") {
if (UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
}
export default Sentry.wrap(function App() {
useEffect(() => {
const nativeScreensMode =
Platform.OS === "ios" ? "screens-disabled" : "default";
Sentry.setTag("native_screens_mode", nativeScreensMode);
Sentry.setContext("runtime_navigation_flags", {
platform: Platform.OS,
nativeScreensMode,
});
Sentry.addBreadcrumb({
category: "runtime.flags",
level: "info",
message: "runtime navigation flags applied",
data: {
platform: Platform.OS,
nativeScreensMode,
},
});
void startAppLifecycleCrashSentinel({ nativeScreensMode });
return () => {
stopAppLifecycleCrashSentinel();
};
}, []);
export default function App() {
useEffect(() => {
UpdateAsync();
}, []);
@@ -122,93 +49,37 @@ export default Sentry.wrap(function App() {
return;
}
stackAwareNavigate("topMenu", {
rootNavigationRef.navigate("topMenu", {
screen: "setting",
params: {
screen: "FelicaHistoryPage",
},
});
};
const navigateWhenReady = (
callback: () => void,
url: string,
retryCount = 0
) => {
if (!rootNavigationRef.isReady()) {
if (retryCount < 8) {
setTimeout(() => navigateWhenReady(callback, url, retryCount + 1), 250);
}
return;
}
callback();
} as any);
};
const routeFromUrl = (url: string, retryCount = 0) => {
const normalized = (url || "").toLowerCase();
if (!normalized) return false;
if (!normalized) return;
if (
const isFelicaUrl =
normalized.includes("felicahistorypage") ||
normalized.includes("open/felica")
) {
markStartupExplicitTarget();
navigateWhenReady(() => openFelicaPage(), url, retryCount);
return true;
}
if (normalized.includes("open/traininfo")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("topMenu", { screen: "menu" });
setTimeout(() => {
SheetManager.show("JRSTraInfo");
}, 450);
}, url, retryCount);
return true;
}
if (normalized.includes("open/operation")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("information");
}, url, retryCount);
return true;
}
if (normalized.includes("open/settings")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("topMenu", {
screen: "setting",
});
}, url, retryCount);
return true;
}
if (normalized.includes("open/topmenu")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("topMenu", {
screen: "menu",
});
}, url, retryCount);
return true;
}
if (normalized.includes("positions/apps")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("positions");
}, url, retryCount);
return true;
normalized.includes("open/felica");
if (!isFelicaUrl) return;
if (!rootNavigationRef.isReady()) {
if (retryCount < 8) {
setTimeout(() => routeFromUrl(url, retryCount + 1), 250);
}
return;
}
return false;
openFelicaPage();
};
Linking.getInitialURL()
.then((url) => {
if (url) routeFromUrl(url);
})
.finally(() => {
resolveStartupNavigationSource();
});
Linking.getInitialURL().then((url) => {
if (url) routeFromUrl(url);
});
const sub = Linking.addEventListener("url", ({ url }) => {
routeFromUrl(url);
@@ -226,10 +97,10 @@ export default Sentry.wrap(function App() {
StationListProvider,
FavoriteStationProvider,
TrainDelayDataProvider,
TrainMenuProvider, // CurrentTrainProvider より先に置くことで useTrainMenu が使える
CurrentTrainProvider,
AreaInfoProvider,
BusAndTrainDataProvider,
TrainMenuProvider,
SheetProvider,
]);
return (
@@ -237,42 +108,13 @@ export default Sentry.wrap(function App() {
<DeviceOrientationChangeProvider>
<SafeAreaProvider>
<StatusbarDetect />
<DensityScaleWrapper>
<GestureHandlerRootView style={{ flex: 1 }}>
<ProviderTree>
<AppContainer />
</ProviderTree>
</GestureHandlerRootView>
</DensityScaleWrapper>
<GestureHandlerRootView style={{ flex: 1 }}>
<ProviderTree>
<AppContainer />
</ProviderTree>
</GestureHandlerRootView>
</SafeAreaProvider>
</DeviceOrientationChangeProvider>
</AppThemeProvider>
);
});
/**
* 低密度ディスプレイ(DeX等)で全体を transform scale で拡大。
* Dimensions.get をパッチ済みなので、コンポーネントは内部サイズに合わせてレイアウトする。
*/
function DensityScaleWrapper({ children }: { children: React.ReactNode }) {
const { width, height } = useRealWindowDimensions();
if (!IS_LOW_DENSITY) {
return <>{children}</>;
}
return (
<View style={{ width, height, overflow: "hidden" }}>
<View
style={{
width: width / DEX_SCALE,
height: height / DEX_SCALE,
transform: [{ scale: DEX_SCALE }],
transformOrigin: "top left",
}}
>
{children}
</View>
</View>
);
}
+27 -559
View File
@@ -1,7 +1,7 @@
import React from "react";
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
import { NavigationContainer } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
import { Platform, ActivityIndicator, View, useColorScheme } from "react-native";
import { useNavigationState } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { LinearGradient } from "expo-linear-gradient";
@@ -14,15 +14,7 @@ import { useTrainMenu } from "./stateBox/useTrainMenu";
import lineColorList from "./assets/originData/lineColorList";
import { stationIDPair } from "./lib/getStationList";
import "./components/ActionSheetComponents/sheets";
import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
import { fixedColors } from "./lib/theme/colors";
import { useThemeColors } from "./lib/theme";
import * as Sentry from "@sentry/react-native";
import WebView from "react-native-webview";
import {
recordAppLifecycleRootNavigation,
setAppLifecycleWebViewActive,
} from "./lib/observability/appLifecycleCrashSentinel";
import { rootNavigationRef } from "./lib/rootNavigation";
type RootTabParamList = {
positions: undefined;
@@ -41,228 +33,9 @@ type TabProps = {
const Tab = createBottomTabNavigator<RootTabParamList>();
const HiddenStartupPreloadWebViews = ({
shouldPreloadPositions,
shouldPreloadInformation,
}: {
shouldPreloadPositions: boolean;
shouldPreloadInformation: boolean;
}) => {
const [positionsLoaded, setPositionsLoaded] = React.useState(false);
const [informationLoaded, setInformationLoaded] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("startup_hidden_preload", {
shouldPreloadPositions,
shouldPreloadInformation,
positionsLoaded,
informationLoaded,
});
}, [informationLoaded, positionsLoaded, shouldPreloadInformation, shouldPreloadPositions]);
const hiddenStyle = React.useMemo(() => ({
position: "absolute" as const,
width: 1,
height: 1,
opacity: 0,
left: -1000,
top: -1000,
pointerEvents: "none" as const,
}), []);
const showPositions = shouldPreloadPositions && !positionsLoaded;
const showInformation = shouldPreloadInformation && !informationLoaded;
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);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "positions hidden preload loadStart",
});
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "positions hidden preload loadEnd",
});
setPositionsLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "error",
message: "positions hidden preload error",
});
}}
/>
) : null}
{showInformation ? (
<WebView
source={{ uri: "https://www.jr-shikoku.co.jp/info/" }}
originWhitelist={["https://www.jr-shikoku.co.jp"]}
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", true);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "operation hidden preload loadStart",
});
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "operation hidden preload loadEnd",
});
setInformationLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "error",
message: "operation hidden preload error",
});
}}
/>
) : null}
</View>
);
};
const PositionsRootGateContext = React.createContext({
shouldActivate: false,
isRootFocused: false,
});
const InformationRootGateContext = React.createContext({
shouldActivate: false,
isRootFocused: false,
});
const DeferredPositionsRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
const [hasActivated, setHasActivated] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("positions_root_gate", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
}, [hasActivated, isRootFocused, shouldActivate]);
React.useEffect(() => {
if (!shouldActivate || hasActivated) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root activation",
data: {
reason: "first_root_visit",
},
});
setHasActivated(true);
}, [hasActivated, shouldActivate]);
if (!hasActivated) {
return <View style={{ flex: 1 }} />;
}
return <Top />;
};
const PositionsTabScreen = React.memo(() => {
const { shouldActivate, isRootFocused } = React.useContext(PositionsRootGateContext);
return (
<DeferredPositionsRoot
shouldActivate={shouldActivate}
isRootFocused={isRootFocused}
/>
);
});
PositionsTabScreen.displayName = "PositionsTabScreen";
const DeferredInformationRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
const [hasActivated, setHasActivated] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("operation_root_gate", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
}, [hasActivated, isRootFocused, shouldActivate]);
React.useEffect(() => {
if (!shouldActivate || hasActivated) return;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root activation",
data: {
reason: isRootFocused ? "first_focus" : "background_prewarm",
},
});
setHasActivated(true);
}, [hasActivated, isRootFocused, shouldActivate]);
if (!hasActivated) {
return <View style={{ flex: 1 }} />;
}
return <TNDView />;
};
const InformationTabScreen = React.memo(() => {
const { shouldActivate, isRootFocused } = React.useContext(InformationRootGateContext);
return (
<DeferredInformationRoot
shouldActivate={shouldActivate}
isRootFocused={isRootFocused}
/>
);
});
InformationTabScreen.displayName = "InformationTabScreen";
export function AppContainer() {
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
const { selectedLine } = useTrainMenu();
const { width, height } = useWindowDimensions();
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
const [hasVisitedPositions, setHasVisitedPositions] = React.useState(false);
const [hasVisitedInformation, setHasVisitedInformation] = React.useState(false);
const [currentRootRoute, setCurrentRootRoute] = React.useState<keyof RootTabParamList | null>(null);
const [navigationReady, setNavigationReady] = React.useState(false);
const startupPrewarmAttemptedRef = React.useRef(false);
const informationStartupPrewarmAttemptedRef = React.useRef(false);
// フェードアニメーション用 (0=通常, 1=追加ウィンドウ青)
const fadeAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
Animated.timing(fadeAnim, {
toValue: isExtraWindowOpen ? 1 : 0,
duration: 300,
useNativeDriver: false,
}).start();
}, [isExtraWindowOpen]);
const lineColor = selectedLine && stationIDPair[selectedLine]
? lineColorList[stationIDPair[selectedLine]]
@@ -275,15 +48,17 @@ export function AppContainer() {
const b = Math.round(parseInt(h.slice(4, 6), 16) * factor);
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
};
const { isDark } = useThemeColors();
const lastRootNavStateRef = React.useRef("");
const colorScheme = useColorScheme();
const isDark = colorScheme === "dark";
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
const linking = {
prefixes: ["jrshikoku://"],
config: {
screens: {
positions: {
screens: {},
screens: {
Apps: "positions/apps",
},
},
topMenu: {
screens: {
@@ -314,150 +89,16 @@ export function AppContainer() {
tabBarLabel: label,
headerShown: false,
gestureEnabled: true,
unmountOnBlur: false,
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
},
});
const applyRootNavigationState = React.useCallback((state: any, source: "ready" | "change") => {
const activeRoute = state?.routes?.[state?.index ?? 0];
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
const nestedState = activeRoute?.state;
const nestedRoute = nestedState?.routes?.[nestedState.index ?? 0]?.name ?? null;
const signature = JSON.stringify({
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
});
if (lastRootNavStateRef.current !== signature) {
lastRootNavStateRef.current = signature;
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: source === "ready" ? "root state initialized" : "root state change",
data: {
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
},
});
}
const rootRouteName = (activeRoute?.name ?? null) as keyof RootTabParamList | null;
if (rootRouteName) {
lastObservedRootRouteRef.current = rootRouteName;
}
recordAppLifecycleRootNavigation({
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
source,
});
Sentry.setTag("root_tab", rootRouteName ?? "unknown");
Sentry.setContext("root_navigation", {
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
operationOrientation: width > height ? "landscape" : "portrait",
hasVisitedPositions,
hasVisitedInformation,
});
setCurrentRootRoute(rootRouteName);
setIsExtraWindowOpen(hasExtra);
}, [hasVisitedInformation, hasVisitedPositions, width, height]);
const [fontLoaded, error] = useFonts({
"JR-Nishi": require("./assets/fonts/jr-nishi.otf"),
Zou: require("./assets/fonts/DelaGothicOne-Regular.ttf"),
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
});
React.useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (startupPrewarmAttemptedRef.current) return;
if (hasVisitedPositions) {
startupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "positions") return;
startupPrewarmAttemptedRef.current = true;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedPositions) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedPositions(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
React.useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (informationStartupPrewarmAttemptedRef.current) return;
if (hasVisitedInformation) {
informationStartupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "information") return;
informationStartupPrewarmAttemptedRef.current = true;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedInformation) return;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedInformation(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]);
if (!fontLoaded) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
@@ -466,113 +107,10 @@ export function AppContainer() {
);
}
return (
<NavigationContainer
ref={rootNavigationRef}
linking={linking}
theme={isDark ? DarkTheme : DefaultTheme}
onReady={() => {
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: "navigation ready",
});
applyRootNavigationState(rootNavigationRef.getRootState?.() ?? rootNavigationRef.getState?.(), "ready");
setNavigationReady(true);
}}
onStateChange={(state) => {
applyRootNavigationState(state, "change");
}}
>
<PositionsRootGateContext.Provider
value={{
shouldActivate: hasVisitedPositions,
isRootFocused: currentRootRoute === "positions",
}}
>
<InformationRootGateContext.Provider
value={{
shouldActivate: hasVisitedInformation,
isRootFocused: currentRootRoute === "information",
}}
>
<NavigationContainer ref={rootNavigationRef} linking={linking}>
{/* @ts-expect-error - Tab.Navigator type definition issue */}
<Tab.Navigator
id="rootTabs"
detachInactiveScreens={false}
initialRouteName="topMenu"
screenListeners={({ route }) => ({
tabPress: (event) => {
const rootState = rootNavigationRef.getState();
const activeRootRouteName =
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
currentRootRoute ??
null;
const leavingUnstablePositions =
route.name !== "positions" &&
positionsLifecycleRef.current.isUnstable;
const blockingUnstableExit =
route.name !== "positions" &&
positionsLifecycleRef.current.blockTabExit;
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: blockingUnstableExit
? "root tabPress blocked while positions unstable"
: leavingUnstablePositions
? "root tabPress intercepted for unstable positions"
: "root tabPress",
data: {
route: route.name,
currentRootRoute,
activeRootRouteName,
unstablePositions: positionsLifecycleRef.current.isUnstable,
blockTabExit: positionsLifecycleRef.current.blockTabExit,
},
});
if (blockingUnstableExit) {
event.preventDefault();
return;
}
if (leavingUnstablePositions) {
event.preventDefault();
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root exit blocked while session unstable",
data: { route: route.name },
});
}
},
focus: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab blur",
data: { route: route.name },
});
},
state: (event) => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab state",
data: {
route: route.name,
stateType: event.type,
},
});
},
})}
screenOptions={({ route }) => {
const showGradient = route.name === "positions" && !!lineColor && !!lineColorDark;
const defaultBg = isDark ? "#1c1c1e" : "white";
@@ -580,66 +118,27 @@ export function AppContainer() {
const defaultInactive = isDark ? "#8e8e93" : "#8e8e93";
return {
lazy: false,
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive,
tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive,
tabBarStyle: { backgroundColor: "transparent" },
tabBarBackground: () => (
<View style={{ flex: 1 }}>
{/* 路線カラー or デフォルト背景 */}
{showGradient ? (
<LinearGradient
colors={[lineColor!, lineColorDark!]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ ...StyleSheet.absoluteFillObject }}
/>
) : (
<View style={{ ...StyleSheet.absoluteFillObject, backgroundColor: defaultBg }} />
)}
{/* 追加ウィンドウ時の青グラデーション(フェードイン/アウト) */}
<Animated.View style={{ ...StyleSheet.absoluteFillObject, opacity: fadeAnim }}>
<LinearGradient
colors={[fixedColors.primary, fixedColors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ flex: 1 }}
/>
</Animated.View>
</View>
),
tabBarHideOnKeyboard: Platform.OS === "android",
animation: "shift",
tabBarActiveTintColor: showGradient ? "white" : defaultActive,
tabBarInactiveTintColor: showGradient ? "rgba(255,255,255,0.75)" : defaultInactive,
tabBarStyle: showGradient
? { backgroundColor: "transparent" }
: { backgroundColor: defaultBg },
tabBarBackground: showGradient ? () => (
<LinearGradient
colors={[lineColor!, lineColorDark!]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ flex: 1 }}
/>
) : undefined,
};
}}
>
<Tab.Screen
{...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")}
component={PositionsTabScreen}
listeners={{
tabPress: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root tabPress activation",
});
setHasVisitedPositions(true);
},
focus: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root focus activation",
});
setHasVisitedPositions(true);
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root blur preserved",
});
},
}}
component={Top}
/>
<Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
@@ -655,40 +154,9 @@ export function AppContainer() {
areaInfo ? areaIconBadgeText : undefined,
isInfo
)}
component={InformationTabScreen}
listeners={{
tabPress: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root tabPress activation",
});
setHasVisitedInformation(true);
},
focus: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root focus activation",
});
setHasVisitedInformation(true);
},
blur: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root blur preserved",
});
},
}}
children={TNDView}
/>
</Tab.Navigator>
</InformationRootGateContext.Provider>
</PositionsRootGateContext.Provider>
<HiddenStartupPreloadWebViews
shouldPreloadPositions={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedPositions}
shouldPreloadInformation={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedInformation}
/>
</NavigationContainer>
);
}
+26 -484
View File
@@ -1,277 +1,25 @@
import React from "react";
import { Alert, ActivityIndicator, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import * as FileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import React, { CSSProperties } from "react";
import { Alert, BackHandler, View, ViewProps } from "react-native";
import { WebView } from "react-native-webview";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { BigButton } from "./components/atom/BigButton";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme";
import { AS } from "./storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
DEFAULT_JR_DATA_SYSTEM_ENV,
normalizeJrDataSystemEnvironment,
rewriteJrDataSystemUrl,
} from "@/lib/jrDataSystemEnvironment";
const RECORDING_DOWNLOAD_BRIDGE_SCRIPT = `
(() => {
if (window.__JRS_RECORDING_DOWNLOAD_BRIDGE__) return true;
window.__JRS_RECORDING_DOWNLOAD_BRIDGE__ = true;
const blobUrls = new Map();
const post = (payload) => {
window.ReactNativeWebView?.postMessage(JSON.stringify(payload));
};
const isLikelyRecordingDownload = (url, anchor) => {
if (!url) return false;
const lowerUrl = String(url).toLowerCase();
const downloadName = anchor?.getAttribute?.('download') || '';
const lowerName = String(downloadName).toLowerCase();
return Boolean(downloadName)
|| lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerName.includes('recording')
|| lowerName.endsWith('.json');
};
const readUrlAsText = async (url) => {
if (blobUrls.has(url)) {
return blobUrls.get(url).text();
}
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text();
};
const importFromUrl = async (url) => {
try {
const text = await readUrlAsText(url);
post({ type: 'importRecordingDownload', text, sourceUrl: url });
} catch (error) {
post({
type: 'importRecordingDownloadError',
message: error?.message || String(error),
sourceUrl: url,
});
}
};
const findAnchor = (target) => {
let node = target;
while (node && node !== document) {
if (node.tagName === 'A' && node.href) return node;
node = node.parentNode;
}
return null;
};
if (window.URL?.createObjectURL) {
const originalCreateObjectURL = window.URL.createObjectURL.bind(window.URL);
window.URL.createObjectURL = (object) => {
const url = originalCreateObjectURL(object);
if (object instanceof Blob) blobUrls.set(url, object);
return url;
};
}
document.addEventListener('click', (event) => {
const anchor = findAnchor(event.target);
if (!anchor || !isLikelyRecordingDownload(anchor.href, anchor)) return;
event.preventDefault();
event.stopPropagation();
importFromUrl(anchor.href);
}, true);
if (window.HTMLAnchorElement?.prototype?.click) {
const originalClick = window.HTMLAnchorElement.prototype.click;
window.HTMLAnchorElement.prototype.click = function patchedClick() {
if (isLikelyRecordingDownload(this.href, this)) {
importFromUrl(this.href);
return;
}
return originalClick.apply(this, arguments);
};
}
return true;
})();
true;
`;
const isLikelyRecordingDownloadUrl = (url: string) => {
const lowerUrl = url.toLowerCase();
return lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerUrl.endsWith('.json');
};
export default ({ route }) => {
if (!route.params) {
return null;
}
const {
uri,
useExitButton = true,
importRecordingDownloads = false,
} = route.params;
const { uri, useExitButton = true } = route.params;
const { goBack } = useNavigation();
const { fixed } = useThemeColors();
const { importRecordingsFromText } = useTrainMenu();
const webViewRef = React.useRef<WebView>(null);
const [canGoBack, setCanGoBack] = React.useState(false);
const nativeCanGoBackRef = React.useRef(false);
const historyStackRef = React.useRef<string[]>([]);
const [selectedEnvironment, setSelectedEnvironment] = React.useState(
DEFAULT_JR_DATA_SYSTEM_ENV,
);
const [resolvedUri, setResolvedUri] = React.useState("");
const [isEnvironmentReady, setIsEnvironmentReady] = React.useState(false);
const hasAlerted = React.useRef(false);
const [isLoading, setIsLoading] = React.useState(true);
const [hasError, setHasError] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState("");
// WebViewをforce remountするためのkey
const [webViewKey, setWebViewKey] = React.useState(0);
// RN-side watchdog: WebViewプロセスの死活確認用
const lastPongAt = React.useRef<number>(Date.now());
const isLoadingRef = React.useRef(true);
const hasErrorRef = React.useRef(false);
// コンテンツ消失検知用: bodyLen の最大値と連続白画面カウント
const maxBodyLenRef = React.useRef(0);
const blankCountRef = React.useRef(0);
const remount = React.useCallback(() => {
lastPongAt.current = Date.now(); // remount直後に誤検知しないようリセット
maxBodyLenRef.current = 0;
blankCountRef.current = 0;
setHasError(false);
hasErrorRef.current = false;
setIsLoading(true);
isLoadingRef.current = true;
setWebViewKey((k) => k + 1);
}, []);
React.useEffect(() => {
let isMounted = true;
const applyEnvironment = (value: unknown) => {
if (!isMounted) return;
const nextEnvironment = normalizeJrDataSystemEnvironment(value);
const rawUri = typeof uri === "string" ? uri : "";
historyStackRef.current = [];
nativeCanGoBackRef.current = false;
setCanGoBack(false);
setSelectedEnvironment(nextEnvironment);
setResolvedUri(
importRecordingDownloads
? rawUri
: rewriteJrDataSystemUrl(rawUri, nextEnvironment),
);
setIsEnvironmentReady(true);
};
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then(applyEnvironment)
.catch(() => applyEnvironment(DEFAULT_JR_DATA_SYSTEM_ENV));
return () => {
isMounted = false;
};
}, [uri, importRecordingDownloads]);
const handleReload = () => {
lastPongAt.current = Date.now();
setHasError(false);
hasErrorRef.current = false;
setIsLoading(true);
isLoadingRef.current = true;
setWebViewKey((k) => k + 1);
};
const syncCanGoBack = React.useCallback((nativeCanGoBack: boolean) => {
nativeCanGoBackRef.current = nativeCanGoBack;
setCanGoBack(nativeCanGoBack || historyStackRef.current.length > 1);
}, []);
const pushHistoryEntry = React.useCallback((url: string) => {
if (!url) return;
const stack = historyStackRef.current;
if (stack[stack.length - 1] === url) return;
stack.push(url);
if (stack.length > 30) {
stack.splice(0, stack.length - 30);
}
}, []);
const handlePseudoBack = React.useCallback(() => {
const stack = historyStackRef.current;
if (stack.length <= 1) return false;
stack.pop();
const previousUrl = stack[stack.length - 1];
if (!previousUrl) return false;
setResolvedUri(previousUrl);
syncCanGoBack(false);
remount();
return true;
}, [remount, syncCanGoBack]);
const handleWebViewBack = React.useCallback(() => {
if (nativeCanGoBackRef.current) {
webViewRef.current?.goBack();
return true;
}
return handlePseudoBack();
}, [handlePseudoBack]);
const handleImportRecordingText = React.useCallback(
async (content: string) => {
try {
const result = await importRecordingsFromText(content);
Alert.alert(
"録画データを読み込みました",
result.overwrittenCount > 0
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
: `${result.importedCount}件を読み込みました。`,
);
} catch (error) {
Alert.alert(
"録画データを読み込めませんでした",
(error as Error).message || "ファイル内容を確認してください。",
);
}
},
[importRecordingsFromText],
);
const handleImportRecordingUrl = React.useCallback(
async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await handleImportRecordingText(await response.text());
} catch (error) {
Alert.alert(
"録画データを取得できませんでした",
(error as Error).message || "通信状態を確認してください。",
);
}
},
[handleImportRecordingText],
);
useFocusEffect(
React.useCallback(() => {
const onHardwareBack = () => {
if (canGoBack) {
if (handleWebViewBack()) return true;
webViewRef.current?.goBack();
return true;
}
goBack();
return true;
@@ -279,237 +27,31 @@ export default ({ route }) => {
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
return () => subscription.remove();
}, [canGoBack, goBack, handleWebViewBack])
}, [canGoBack, goBack])
);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
{isEnvironmentReady && (
<WebView
key={webViewKey}
source={{ uri: resolvedUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
setSupportMultipleWindows={false}
ref={webViewRef}
injectedJavaScriptBeforeContentLoaded={
importRecordingDownloads ? RECORDING_DOWNLOAD_BRIDGE_SCRIPT : `true;`
<WebView
source={{ uri }}
allowsBackForwardNavigationGestures
ref={webViewRef}
onNavigationStateChange={(navState) => {
setCanGoBack(navState.canGoBack);
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
goBack();
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
}
onLoadStart={() => {
isLoadingRef.current = true;
setHasError(false);
hasErrorRef.current = false;
maxBodyLenRef.current = 0;
blankCountRef.current = 0;
}}
onLoadEnd={() => {
setIsLoading(false);
isLoadingRef.current = false;
lastPongAt.current = Date.now();
}}
onError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
setIsLoading(false);
isLoadingRef.current = false;
setHasError(true);
hasErrorRef.current = true;
setErrorMessage(nativeEvent.description || "ページを読み込めませんでした");
}}
onHttpError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
if (nativeEvent.statusCode >= 500) {
setIsLoading(false);
isLoadingRef.current = false;
setHasError(true);
hasErrorRef.current = true;
setErrorMessage(`サーバーエラー (${nativeEvent.statusCode})`);
}
}}
onRenderProcessGone={() => {
// クラッシュ・メモリ回収どちらも自動remount
remount();
}}
// iOS: コンテンツプロセスがメモリ圧迫で終了した場合
onContentProcessDidTerminate={() => remount()}
onShouldStartLoadWithRequest={(request) => {
if (request.isTopFrame === false) {
return true;
}
if (
importRecordingDownloads &&
isLikelyRecordingDownloadUrl(request.url) &&
request.url !== resolvedUri
) {
void handleImportRecordingUrl(request.url);
return false;
}
if (!importRecordingDownloads) {
const rewrittenUrl = rewriteJrDataSystemUrl(
request.url,
selectedEnvironment,
);
if (rewrittenUrl !== request.url) {
setResolvedUri(rewrittenUrl);
return false;
}
}
return true;
}}
onFileDownload={(event) => {
if (!importRecordingDownloads) return;
const downloadUrl = event.nativeEvent.downloadUrl;
if (downloadUrl) {
void handleImportRecordingUrl(downloadUrl);
}
}}
onNavigationStateChange={(navState) => {
if (navState.url) {
const nextUrl = importRecordingDownloads
? navState.url
: rewriteJrDataSystemUrl(navState.url, selectedEnvironment);
setResolvedUri((current) => (current === nextUrl ? current : nextUrl));
if (nextUrl !== "https://unyohub.2pd.jp/integration/succeeded.php") {
pushHistoryEntry(nextUrl);
}
}
syncCanGoBack(navState.canGoBack);
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
if (navState.loading) blankCountRef.current = 0;
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
webViewRef.current?.goBack();
if (!hasAlerted.current) {
hasAlerted.current = true;
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
}
}
}}
onMessage={(event) => {
const { data } = event.nativeEvent;
let parsed: any;
try {
parsed = JSON.parse(data);
} catch {
return;
}
const { type } = parsed;
if (type === "importRecordingDownload") {
void handleImportRecordingText(String(parsed.text ?? ""));
return;
}
if (type === "importRecordingDownloadError") {
Alert.alert(
"録画データを取得できませんでした",
parsed.message || "ダウンロード内容を読み取れませんでした。",
);
return;
}
if (type === "pong") {
lastPongAt.current = Date.now();
const bodyLen: number = parsed.bodyLen ?? 0;
// innerTextベース: 最大値を更新
if (bodyLen > maxBodyLenRef.current) maxBodyLenRef.current = bodyLen;
// 一度200文字超の表示テキストがあった後に20文字未満になったら白画面と判定
// SPA遷移中の一時的な空白を避けるため3回連続(15秒)で発火
if (maxBodyLenRef.current > 200 && bodyLen < 20) {
blankCountRef.current += 1;
if (blankCountRef.current >= 3) {
blankCountRef.current = 0;
maxBodyLenRef.current = 0;
remount();
}
} else {
blankCountRef.current = 0;
}
return;
}
if (type === "printHtml") {
(async () => {
try {
const path = FileSystem.cacheDirectory + "diagram.html";
await FileSystem.writeAsStringAsync(path, parsed.html, { encoding: FileSystem.EncodingType.UTF8 });
const ok = await Sharing.isAvailableAsync();
if (ok) {
await Sharing.shareAsync(path, { mimeType: "text/html", dialogTitle: "ダイヤグラムを共有" });
}
} catch (e) {
Alert.alert("エラー", "PDF出力の準備に失敗しました。");
}
})();
return;
}
if (type === "back") return handleWebViewBack();
if (type === "windowClose") return goBack();
}}
/>
)}
{isLoading && !hasError && (
<View style={wvStyles.loadingOverlay} pointerEvents="none">
<ActivityIndicator size="large" color="#fff" />
</View>
)}
{hasError && (
<View style={wvStyles.errorOverlay}>
<MaterialCommunityIcons name="wifi-off" size={48} color="#ccc" />
<Text style={wvStyles.errorText}>{errorMessage}</Text>
<TouchableOpacity style={wvStyles.reloadButton} onPress={handleReload}>
<MaterialCommunityIcons name="reload" size={18} color="#fff" />
<Text style={wvStyles.reloadButtonText}></Text>
</TouchableOpacity>
{useExitButton && (
<TouchableOpacity style={wvStyles.backButton} onPress={goBack}>
<Text style={wvStyles.backButtonText}></Text>
</TouchableOpacity>
)}
</View>
)}
{useExitButton && !hasError && <BigButton onPress={goBack} string="閉じる" />}
}}
onMessage={(event) => {
const { data } = event.nativeEvent;
const { type } = JSON.parse(data);
if (type === "back") return webViewRef.current?.goBack();
if (type === "windowClose") return goBack();
}}
/>
{useExitButton && <BigButton onPress={goBack} string="閉じる" />}
</View>
);
};
const wvStyles = StyleSheet.create({
loadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.25)",
},
errorOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#1a1a2e",
gap: 16,
paddingHorizontal: 32,
},
errorText: {
color: "#aaa",
fontSize: 14,
textAlign: "center",
},
reloadButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: "#0099CC",
borderRadius: 10,
paddingHorizontal: 24,
paddingVertical: 12,
},
reloadButtonText: {
color: "#fff",
fontSize: 15,
fontWeight: "bold",
},
backButton: {
paddingHorizontal: 24,
paddingVertical: 10,
},
backButtonText: {
color: "#888",
fontSize: 14,
},
});
+13 -125
View File
@@ -1,8 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { createStackNavigator } from "@react-navigation/stack";
import { useWindowDimensions, Platform, useColorScheme } from "react-native";
import { useWindowDimensions, Platform } from "react-native";
import Constants from "expo-constants";
import { useResponsive } from "@/lib/responsive";
import { Dimensions, StatusBar } from "react-native";
@@ -18,73 +17,28 @@ import Setting from "@/components/Settings/settings";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { optionData } from "@/lib/stackOption";
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import {
startupExplicitTargetRef,
waitForStartupNavigationResolution,
} from "@/lib/rootNavigation";
import { useNavigation } from "@react-navigation/native";
import { news } from "@/config/newsUpdate";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import GeneralWebView from "@/GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator();
export function MenuPage() {
const { favoriteStation, setFavoriteStation } = useFavoriteStation();
const { height, width } = useWindowDimensions();
const { verticalScale } = useResponsive();
const tabBarHeight = useBottomTabBarHeight();
const navigation = useNavigation<any>();
const { addListener } = navigation;
const isFocused = useIsFocused();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu unmounted",
});
};
}, []);
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: isFocused ? "top menu focused" : "top menu blurred",
data: {
width,
height,
},
});
}, [height, isFocused, width]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
await waitForStartupNavigationResolution();
if (cancelled) return;
const res = await AS.getItem(STORAGE_KEYS.START_PAGE);
if (res !== "true" || startupExplicitTargetRef.current) return;
navigation.navigate("positions");
} catch (e) {
AS.getItem(STORAGE_KEYS.START_PAGE)
.then((res) => {
if (res == "true") navigation.navigate("positions");
})
.catch((e) => {
//6.0以降false
AS.setItem(STORAGE_KEYS.START_PAGE, "false");
}
})();
});
//ニュース表示
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
@@ -97,22 +51,10 @@ export function MenuPage() {
if (isSetIcon == "true") SheetManager.show("TrainIconUpdate");
})
.catch((error) => logger.error("Error fetching icon setting:", error));
return () => {
cancelled = true;
};
}, []);
const scrollRef = useRef(null);
const [mapMode, setMapMode] = useState(false);
useEffect(() => {
Sentry.setContext("top_menu_screen", {
focused: isFocused,
width,
height,
mapMode,
});
}, [height, isFocused, mapMode, width]);
const [mapHeight, setMapHeight] = useState(0);
const mapHeightRef = useRef(0);
const favoriteStationRef = useRef(favoriteStation);
@@ -121,8 +63,8 @@ export function MenuPage() {
height -
tabBarHeight +
(Platform.OS == "android" ? Constants.statusBarHeight : 0) -
verticalScale(100) -
((((width / 100) * 80) / 20) * 9 + verticalScale(10) + verticalScale(30));
100 -
((((width / 100) * 80) / 20) * 9 + 10 + 30);
setMapHeight(MapHeight);
mapHeightRef.current = MapHeight;
}, [height, tabBarHeight, width]);
@@ -138,26 +80,9 @@ export function MenuPage() {
setMapFullHeight(MapFullHeight);
}, [height, tabBarHeight, width]);
useEffect(() => {
const unsubscribe = addListener("tabPress", (e: any) => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu tabPress handler",
data: {
focused: navigation.isFocused(),
stackIndex: stackNavRef.current?.getState()?.index ?? null,
mapMode,
},
});
if (navigation.isFocused() && stackNavRef.current) {
if (stackNavRef.current.getState()?.index > 0) {
e.preventDefault();
stackNavRef.current.goBack();
return;
}
}
const unsubscribe = addListener("tabPress", (e) => {
scrollRef.current?.scrollTo({
y: mapHeightRef.current - verticalScale(80),
y: mapHeightRef.current - 80,
animated: true,
});
setMapMode(false);
@@ -177,45 +102,8 @@ export function MenuPage() {
return unsubscribe;
}, [navigation]);
const stackNavRef = useRef<any>(null);
return (
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {
focus: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack blur",
data: { route: route.name },
});
},
state: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}}
>
<Stack.Navigator id={null}>
<Stack.Screen
name="menu"
options={{
+1 -57
View File
@@ -175,63 +175,7 @@ export const API_ENDPOINTS = {
};
```
## 外部からのアプリ起動(ディープリンク)
本アプリは複数の方法で外部から起動・画面遷移できます。
### カスタムURLスキーム
スキーム `jrshikoku://` を使って特定の画面を直接開くことができます。
| URL | 遷移先 |
|---|---|
| `jrshikoku://open/felica` | FeliCa履歴ページ |
| `jrshikoku://open/traininfo` | 遅延速報EX |
| `jrshikoku://open/operation` | 運行情報 |
| `jrshikoku://open/settings` | 設定 |
| `jrshikoku://open/topmenu` | トップメニュー |
| `jrshikoku://positions/apps` | 走行位置 |
URLの処理は `App.tsx``routeFromUrl` で実装されています。
### Androidウィジェット
ホーム画面に配置可能なウィジェットからアプリを起動できます。
| ウィジェット名 | 説明 |
|---|---|
| `JR_shikoku_train_info` | 遅延速報EX |
| `JR_shikoku_apps_shortcut` | クイックアクセス(各機能へのショートカットタイル) |
| `JR_shikoku_felica_balance` | ICカード残高表示 |
| `JR_shikoku_info` | 運行情報 |
| `JR_shikoku_train_strange` | 怪レい列車 |
ウィジェットのタップ時は `jrshikoku://` スキームでアプリ内画面へ遷移します。
実装: `components/AndroidWidget/widget-task-handler.tsx`
### iOSウィジェット(WidgetKit
WidgetKit拡張としてホーム画面ウィジェットを提供しています。Live Activities にも対応しています。
設定: `targets/widget/Info.plist`
### プッシュ通知
通知タップ時に対応する画面へ遷移します。
| アクション | 遷移先 |
|---|---|
| `delay-ex` | 遅延速報EX |
| `strange-train` | 走行位置(怪レい列車) |
| `information` | 運行情報 |
実装: `stateBox/useNotifications.tsx`
### NFCFeliCa
NFC-Fを利用した交通系ICカードの読み取りに対応しています(Android)。アプリ内からスキャンを開始する形式です。
実装: `modules/expo-felica-reader/`
## 📱 ビルド
## 📱 ビルド
```bash
# APKビルド(Android
+4 -2
View File
@@ -1,8 +1,10 @@
import React, { FC } from "react";
import { Platform, StatusBar } from "react-native";
import { Platform, StatusBar, useColorScheme } from "react-native";
const StatusbarDetect: FC = () => {
return <StatusBar barStyle="light-content" translucent backgroundColor="transparent" />;
const isDark = useColorScheme() === "dark";
const barStyle = isDark ? "light-content" : "dark-content";
return <StatusBar barStyle={barStyle} translucent backgroundColor="transparent" />;
};
export default StatusbarDetect;
+10 -199
View File
@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { createStackNavigator } from "@react-navigation/stack";
import { useIsFocused, useNavigation } from "@react-navigation/native";
import { useColorScheme } from "react-native";
import { useNavigation } from "@react-navigation/native";
import Apps from "./components/Apps";
import TrainBase from "./components/trainbaseview";
import HowTo from "./howto";
@@ -13,20 +12,13 @@ import { useCurrentTrain } from "./stateBox/useCurrentTrain";
import { useTrainMenu } from "./stateBox/useTrainMenu";
import { AS } from "./storageControl";
import { news } from "./config/newsUpdate";
import { InteractionManager, Linking, Platform, View } from "react-native";
import { Linking, Platform } from "react-native";
import GeneralWebView from "./GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator();
export const Top = () => {
const { webview } = useCurrentTrain();
const navigation = useNavigation<any>();
const { navigate, addListener } = navigation;
const isTabFocused = useIsFocused();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const [hasActivatedStack, setHasActivatedStack] = useState(false);
const { navigate, addListener, isFocused } = useNavigation();
//地図用
const { mapSwitch } = useTrainMenu();
@@ -35,215 +27,34 @@ export const Top = () => {
mapSwitchRef.current = mapSwitch;
}, [mapSwitch]);
const goToFavoriteList = (event?: any) => {
if (!hasActivatedStack) {
event?.preventDefault?.();
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabLongPress ignored before stack activation",
});
return;
}
const goToFavoriteList = () =>
navigate("positions", { screen: "favoriteList" });
};
useEffect(() => {
const unsubscribe = addListener("tabLongPress", goToFavoriteList);
return unsubscribe;
}, []);
useEffect(() => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack unmounted",
});
};
}, []);
useEffect(() => {
if (hasActivatedStack) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation scheduled",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
let cancelled = false;
const activate = () => {
if (cancelled) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation committed",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
setHasActivatedStack(true);
};
if (isTabFocused) {
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(activate, 0);
});
return () => {
cancelled = true;
task.cancel?.();
};
}
const timer = setTimeout(activate, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [hasActivatedStack, isTabFocused]);
useEffect(() => {
Sentry.setContext("positions_stack", {
focused: isTabFocused,
activated: hasActivatedStack,
mapSwitch,
});
}, [hasActivatedStack, isTabFocused, mapSwitch]);
const stackNavRef = positionsStackNavRef;
useEffect(() => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
return () => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
};
}, []);
const goToTrainMenu = useCallback((e: any) => {
const stackNav = stackNavRef.current;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabPress handler",
data: {
platform: Platform.OS,
focused: isTabFocused,
mapSwitch: mapSwitchRef.current,
stackIndex: stackNav?.getState()?.index ?? null,
},
});
const goToTrainMenu = useCallback(() => {
if (Platform.OS === "web") {
Linking.openURL("https://train.jr-shikoku.co.jp/");
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
return;
}
if (!hasActivatedStack || !stackNav) {
e?.preventDefault?.();
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabPress ignored before stack activation",
data: {
focused: isTabFocused,
activated: hasActivatedStack,
hasStackNav: !!stackNav,
},
});
return;
}
if (!isTabFocused) return;
if (stackNav && stackNav.getState()?.index > 0) {
e.preventDefault();
stackNav.goBack();
return;
}
if (mapSwitchRef.current == "true")
if (!isFocused()) navigate("positions", { screen: "Apps" });
else if (mapSwitchRef.current == "true")
navigate("positions", { screen: "trainMenu" });
else webview.current?.injectJavaScript(`AccordionClassEvent()`);
return;
}, [hasActivatedStack, isTabFocused, navigate, webview]);
}, [isFocused, navigate, webview]);
useEffect(() => {
const unsubscribe = addListener("tabPress", goToTrainMenu);
return unsubscribe;
}, [addListener, goToTrainMenu]);
if (!hasActivatedStack) {
return <View style={{ flex: 1, backgroundColor: bgColor }} />;
}
return (
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {
focus: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack blur",
data: { route: route.name },
});
},
transitionStart: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionStart",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
transitionEnd: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionEnd",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
state: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}}
>
<Stack.Navigator id={null}>
<Stack.Screen
name="Apps"
options={{
+18 -478
View File
@@ -8,7 +8,7 @@
"android",
"web"
],
"version": "7.1.0",
"version": "7.0.0",
"userInterfaceStyle": "automatic",
"orientation": "default",
"icon": "./assets/icons/s8600.png",
@@ -24,7 +24,7 @@
"**/*"
],
"ios": {
"buildNumber": "66",
"buildNumber": "58",
"supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G",
@@ -33,18 +33,13 @@
},
"infoPlist": {
"NFCReaderUsageDescription": "To read FeliCa card",
"NSPhotoLibraryUsageDescription": "運行情報画像を共有または保存するために写真ライブラリへのアクセスを使用します。",
"NSPhotoLibraryAddUsageDescription": "運行情報画像を写真ライブラリに保存するために使用します。",
"com.apple.developer.nfc.readersession.felica.systemcodes": [
"0003",
"FE00"
],
"ITSAppUsesNonExemptEncryption": false,
"NSSupportsLiveActivities": true,
"NSSupportsLiveActivitiesFrequentUpdates": true,
"UIBackgroundModes": [
"audio"
]
"NSSupportsLiveActivitiesFrequentUpdates": true
},
"entitlements": {
"com.apple.developer.nfc.readersession.formats": [
@@ -57,7 +52,7 @@
},
"android": {
"package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 32,
"versionCode": 30,
"intentFilters": [
{
"action": "VIEW",
@@ -110,7 +105,6 @@
},
"plugins": [
"./plugins/with-android-local-properties",
"./plugins/with-nfc-widget-guard",
"@bacons/apple-targets",
[
"expo-font",
@@ -146,7 +140,17 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "JR四国列車遅延速報EXのウィジェットです。30分ごとに自動更新します。タッチすると強制更新します。",
"previewImage": "./assets/widgetResource/JR_shikoku_train_info.jpg",
"previewImage": "./assets/icon.png",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
},
{
"name": "JR_shikoku_train_strange",
"label": "怪レい列車",
"minWidth": "70dp",
"minHeight": "50dp",
"description": "JR四国怪レい列車BOTのウィジェットです。30分ごとに自動更新します。タッチすると強制更新します。",
"previewImage": "./assets/icon.png",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
},
@@ -156,7 +160,7 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "JR四国運行情報のウィジェットです。30分ごとに自動更新します。タッチすると強制更新します。",
"previewImage": "./assets/widgetResource/JR_shikoku_info.jpg",
"previewImage": "./assets/icon.png",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
},
@@ -166,7 +170,7 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "JR四国非公式アプリの各種リンクを表示するウィジェットです。",
"previewImage": "./assets/widgetResource/JR_shikoku_apps_shortcut.jpg",
"previewImage": "./assets/icon.png",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
},
@@ -176,7 +180,7 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "Felica対応ICカードの残高をホーム画面に表示するウィジェットです。タップでスキャン画面を開きます。",
"previewImage": "./assets/widgetResource/JR_shikoku_felica_balance.jpg",
"previewImage": "./assets/icon.png",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
}
@@ -505,454 +509,6 @@
"foregroundImage": "./assets/icons/ef210.png",
"backgroundColor": "#001413"
}
},
{
"name": "32ns",
"ios": "./assets/icons/s32ns.png",
"android": {
"foregroundImage": "./assets/icons/s32ns.png",
"backgroundColor": "#001413"
}
},
{
"name": "32s",
"ios": "./assets/icons/s32s.png",
"android": {
"foregroundImage": "./assets/icons/s32s.png",
"backgroundColor": "#001413"
}
},
{
"name": "32kpuy1",
"ios": "./assets/icons/s32kpuy1.png",
"android": {
"foregroundImage": "./assets/icons/s32kpuy1.png",
"backgroundColor": "#001413"
}
},
{
"name": "32kpuy2",
"ios": "./assets/icons/s32kpuy2.png",
"android": {
"foregroundImage": "./assets/icons/s32kpuy2.png",
"backgroundColor": "#001413"
}
},
{
"name": "32thtk",
"ios": "./assets/icons/s32thtk.png",
"android": {
"foregroundImage": "./assets/icons/s32thtk.png",
"backgroundColor": "#001413"
}
},
{
"name": "32oni1",
"ios": "./assets/icons/s32oni1.png",
"android": {
"foregroundImage": "./assets/icons/s32oni1.png",
"backgroundColor": "#001413"
}
},
{
"name": "32oni1k",
"ios": "./assets/icons/s32oni1k.png",
"android": {
"foregroundImage": "./assets/icons/s32oni1k.png",
"backgroundColor": "#001413"
}
},
{
"name": "32to4",
"ios": "./assets/icons/s32to4.png",
"android": {
"foregroundImage": "./assets/icons/s32to4.png",
"backgroundColor": "#001413"
}
},
{
"name": "32toai",
"ios": "./assets/icons/s32to_ai.png",
"android": {
"foregroundImage": "./assets/icons/s32to_ai.png",
"backgroundColor": "#001413"
}
},
{
"name": "54s",
"ios": "./assets/icons/s54s.png",
"android": {
"foregroundImage": "./assets/icons/s54s.png",
"backgroundColor": "#001413"
}
},
{
"name": "54to0ys",
"ios": "./assets/icons/s54to0ys.png",
"android": {
"foregroundImage": "./assets/icons/s54to0ys.png",
"backgroundColor": "#001413"
}
},
{
"name": "54nany1",
"ios": "./assets/icons/s54nany1.png",
"android": {
"foregroundImage": "./assets/icons/s54nany1.png",
"backgroundColor": "#001413"
}
},
{
"name": "54nany2",
"ios": "./assets/icons/s54nany2.png",
"android": {
"foregroundImage": "./assets/icons/s54nany2.png",
"backgroundColor": "#001413"
}
},
{
"name": "54smek1",
"ios": "./assets/icons/s32smek1.png",
"android": {
"foregroundImage": "./assets/icons/s32smek1.png",
"backgroundColor": "#001413"
}
},
{
"name": "40s",
"ios": "./assets/icons/s40.png",
"android": {
"foregroundImage": "./assets/icons/s40.png",
"backgroundColor": "#001413"
}
},
{
"name": "40w",
"ios": "./assets/icons/w40.png",
"android": {
"foregroundImage": "./assets/icons/w40.png",
"backgroundColor": "#001413"
}
},
{
"name": "185cm",
"ios": "./assets/icons/s185cm.png",
"android": {
"foregroundImage": "./assets/icons/s185cm.png",
"backgroundColor": "#001413"
}
},
{
"name": "185g",
"ios": "./assets/icons/s185g.png",
"android": {
"foregroundImage": "./assets/icons/s185g.png",
"backgroundColor": "#001413"
}
},
{
"name": "185tu_uzu",
"ios": "./assets/icons/s185tu_uzu.png",
"android": {
"foregroundImage": "./assets/icons/s185tu_uzu.png",
"backgroundColor": "#001413"
}
},
{
"name": "185ap1",
"ios": "./assets/icons/s185ap1.png",
"android": {
"foregroundImage": "./assets/icons/s185ap1.png",
"backgroundColor": "#001413"
}
},
{
"name": "185mm2",
"ios": "./assets/icons/s185mm2.png",
"android": {
"foregroundImage": "./assets/icons/s185mm2.png",
"backgroundColor": "#001413"
}
},
{
"name": "185ym2",
"ios": "./assets/icons/s185ym2.png",
"android": {
"foregroundImage": "./assets/icons/s185ym2.png",
"backgroundColor": "#001413"
}
},
{
"name": "1200",
"ios": "./assets/icons/s1200.png",
"android": {
"foregroundImage": "./assets/icons/s1200.png",
"backgroundColor": "#001413"
}
},
{
"name": "1201",
"ios": "./assets/icons/s1201.png",
"android": {
"foregroundImage": "./assets/icons/s1201.png",
"backgroundColor": "#001413"
}
},
{
"name": "1501",
"ios": "./assets/icons/s1501.png",
"android": {
"foregroundImage": "./assets/icons/s1501.png",
"backgroundColor": "#001413"
}
},
{
"name": "1550",
"ios": "./assets/icons/s1550.png",
"android": {
"foregroundImage": "./assets/icons/s1550.png",
"backgroundColor": "#001413"
}
},
{
"name": "1551",
"ios": "./assets/icons/s1551.png",
"android": {
"foregroundImage": "./assets/icons/s1551.png",
"backgroundColor": "#001413"
}
},
{
"name": "2000uwa",
"ios": "./assets/icons/s2000_uwa.png",
"android": {
"foregroundImage": "./assets/icons/s2000_uwa.png",
"backgroundColor": "#001413"
}
},
{
"name": "2000nl",
"ios": "./assets/icons/s2000nl.png",
"android": {
"foregroundImage": "./assets/icons/s2000nl.png",
"backgroundColor": "#001413"
}
},
{
"name": "2000-3",
"ios": "./assets/icons/s2000-3.png",
"android": {
"foregroundImage": "./assets/icons/s2000-3.png",
"backgroundColor": "#001413"
}
},
{
"name": "2000ganp1",
"ios": "./assets/icons/s2000ganp1.png",
"android": {
"foregroundImage": "./assets/icons/s2000ganp1.png",
"backgroundColor": "#001413"
}
},
{
"name": "2600apr",
"ios": "./assets/icons/s2600apr.png",
"android": {
"foregroundImage": "./assets/icons/s2600apr.png",
"backgroundColor": "#001413"
}
},
{
"name": "2600apb",
"ios": "./assets/icons/s2600apb.png",
"android": {
"foregroundImage": "./assets/icons/s2600apb.png",
"backgroundColor": "#001413"
}
},
{
"name": "2700asi",
"ios": "./assets/icons/s2700_asi.png",
"android": {
"foregroundImage": "./assets/icons/s2700_asi.png",
"backgroundColor": "#001413"
}
},
{
"name": "2700smn",
"ios": "./assets/icons/s2700_smn.png",
"android": {
"foregroundImage": "./assets/icons/s2700_smn.png",
"backgroundColor": "#001413"
}
},
{
"name": "2700uzu",
"ios": "./assets/icons/s2700_uzu.png",
"android": {
"foregroundImage": "./assets/icons/s2700_uzu.png",
"backgroundColor": "#001413"
}
},
{
"name": "2700apy1",
"ios": "./assets/icons/s2700apy1.png",
"android": {
"foregroundImage": "./assets/icons/s2700apy1.png",
"backgroundColor": "#001413"
}
},
{
"name": "2700apr1",
"ios": "./assets/icons/s2700apr1.png",
"android": {
"foregroundImage": "./assets/icons/s2700apr1.png",
"backgroundColor": "#001413"
}
},
{
"name": "3600",
"ios": "./assets/icons/s3600.png",
"android": {
"foregroundImage": "./assets/icons/s3600.png",
"backgroundColor": "#001413"
}
},
{
"name": "6000f",
"ios": "./assets/icons/s6000f.png",
"android": {
"foregroundImage": "./assets/icons/s6000f.png",
"backgroundColor": "#001413"
}
},
{
"name": "7001",
"ios": "./assets/icons/s7001.png",
"android": {
"foregroundImage": "./assets/icons/s7001.png",
"backgroundColor": "#001413"
}
},
{
"name": "8001nr",
"ios": "./assets/icons/s8001nr.png",
"android": {
"foregroundImage": "./assets/icons/s8001nr.png",
"backgroundColor": "#001413"
}
},
{
"name": "9000",
"ios": "./assets/icons/s9000.png",
"android": {
"foregroundImage": "./assets/icons/s9000.png",
"backgroundColor": "#001413"
}
},
{
"name": "9640ht",
"ios": "./assets/icons/tosa9640ht.png",
"android": {
"foregroundImage": "./assets/icons/tosa9640ht.png",
"backgroundColor": "#001413"
}
},
{
"name": "9640mo1",
"ios": "./assets/icons/tosa9640mo1.png",
"android": {
"foregroundImage": "./assets/icons/tosa9640mo1.png",
"backgroundColor": "#001413"
}
},
{
"name": "9640mo2",
"ios": "./assets/icons/tosa9640mo2.png",
"android": {
"foregroundImage": "./assets/icons/tosa9640mo2.png",
"backgroundColor": "#001413"
}
},
{
"name": "9640tyg",
"ios": "./assets/icons/tosa9640tyg.png",
"android": {
"foregroundImage": "./assets/icons/tosa9640tyg.png",
"backgroundColor": "#001413"
}
},
{
"name": "9640tyb",
"ios": "./assets/icons/tosa9640tyb.png",
"android": {
"foregroundImage": "./assets/icons/tosa9640tyb.png",
"backgroundColor": "#001413"
}
},
{
"name": "9640jbl",
"ios": "./assets/icons/tosa9640jbl.png",
"android": {
"foregroundImage": "./assets/icons/tosa9640jbl.png",
"backgroundColor": "#001413"
}
},
{
"name": "W741",
"ios": "./assets/icons/w741.png",
"android": {
"foregroundImage": "./assets/icons/w741.png",
"backgroundColor": "#001413"
}
},
{
"name": "EF210a",
"ios": "./assets/icons/ef210a.png",
"android": {
"foregroundImage": "./assets/icons/ef210a.png",
"backgroundColor": "#001413"
}
},
{
"name": "EF210n",
"ios": "./assets/icons/ef210n.png",
"android": {
"foregroundImage": "./assets/icons/ef210n.png",
"backgroundColor": "#001413"
}
},
{
"name": "EF210n1",
"ios": "./assets/icons/ef210n1.png",
"android": {
"foregroundImage": "./assets/icons/ef210n1.png",
"backgroundColor": "#001413"
}
},
{
"name": "EF210l3",
"ios": "./assets/icons/ef210l3.png",
"android": {
"foregroundImage": "./assets/icons/ef210l3.png",
"backgroundColor": "#001413"
}
},
{
"name": "T45000",
"ios": "./assets/icons/s150001to.png",
"android": {
"foregroundImage": "./assets/icons/s150001to.png",
"backgroundColor": "#001413"
}
},
{
"name": "Wk141",
"ios": "./assets/icons/w141jg.png",
"android": {
"foregroundImage": "./assets/icons/w141jg.png",
"backgroundColor": "#001413"
}
}
]
],
@@ -972,28 +528,12 @@
"expo-web-browser",
"expo-asset",
"expo-sharing",
[
"react-native-share",
{
"ios": [],
"android": [],
"enableBase64ShareAndroid": false
}
],
[
"react-native-maps",
{
"iosGoogleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y",
"androidGoogleMapsApiKey": "AIzaSyAmFb-Yj033bXZWlSzNrfq_0jc1PgRrWcE"
}
],
[
"@sentry/react-native/expo",
{
"url": "https://sentry.io/",
"project": "jr-shikoku-unofficial-apps",
"organization": "xprocess-m5"
}
]
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+17 -96
View File
@@ -1,124 +1,45 @@
export const series: { key: string; title: string; ids: string[] }[] = [
{ key: "32", title: "キハ32形", ids: ["32ns", "32s", "32kpuy1", "32kpuy2", "32tht", "32thtk", "32oni1", "32oni1k", "32toai", "32at"] },
{ key: "54", title: "キハ54形", ids: ["54s", "54st", "54nany1", "54nany2", "54smek1"] },
{ key: "40", title: "キハ40", ids: ["40s", "40w"] },
{ key: "185", title: "キハ185系", ids: ["185mrt", "185cm", "185g", "185tu", "185tu_uzu", "185iyor", "185iyoy", "185toai", "185mm1", "185mm2", "185ym1", "185ym2", "185ap1"] },
{ key: "1000", title: "1000形", ids: ["1000"] },
{ key: "1200", title: "1200形・1201形", ids: ["1200", "1201"] },
{ key: "1500", title: "1500形", ids: ["1501", "1550", "1551"] },
{ key: "2000", title: "2000系・N2000系", ids: ["2000asi", "2000uwa", "N2000", "2000nl", "2000-3", "2000ganp1", "2002a"] },
{ key: "2600", title: "2600系", ids: ["2600", "2600apr", "2600apb"] },
{ key: "2700", title: "2700系", ids: ["2700", "2700asi", "2700smn", "2700uzu", "2700apy", "2700apr", "2700apy1", "2700apr1"] },
{ key: "3600", title: "3600系", ids: ["3600"] },
{ key: "5000", title: "5000系", ids: ["5001", "5001k"] },
{ key: "6000", title: "6000系", ids: ["6000f"] },
{ key: "7000", title: "7000系", ids: ["7000"] },
{ key: "7200", title: "7200系", ids: ["7200"] },
{ key: "8000", title: "8000系", ids: ["8000no", "8000nr", "8001nr", "8000ap", "8000nn"] },
{ key: "8600", title: "8600系", ids: ["8600"] },
{ key: "9000", title: "9000系", ids: ["9000"] },
{ key: "9640", title: "9640形", ids: ["9640", "9640ht", "9640mo1", "9640mo2", "9640tyg", "9640tyb", "9640jgr", "9640jbl"] },
{ key: "other", title: "JR西日本・他", ids: ["285", "213w", "W741", "Wk141", "EF65", "EF210a", "EF210n", "EF210n1", "EF210l3", "T45000"] },
];
export default () =>{
return [
//{ "id": "32", "name": "キハ32形", "icon": require("./32.png") },
{ "id": "32ns", "name": "キハ32形(標準色)", "icon": require("./s32ns.png") },
{ "id": "32s", "name": "キハ32形(新塗装)", "icon": require("./s32s.png") },
//{ "id": "32kpuuy", "name": "キハ32形かっぱうようよ号", "icon": require("./32kpuuy.png") },
{ "id": "32kpuy1", "name": "キハ32形かっぱうようよ号①", "icon": require("./s32kpuy1.png") },
{ "id": "32kpuy2", "name": "キハ32形かっぱうようよ号②", "icon": require("./s32kpuy2.png") },
{ "id": "32", "name": "キハ32形", "icon": require("./32.png") },
{ "id": "32kpuuy", "name": "キハ32形かっぱうようよ号", "icon": require("./32kpuuy.png") },
{ "id": "32tht", "name": "キハ32形新幹線ホビートレイン", "icon": require("./32tht.png") },
{ "id": "32thtk", "name": "キクハ32形新幹線ホビートレイン", "icon": require("./s32thtk.png") },
{ "id": "32oni1", "name": "キハ32形特別塗装①", "icon": require("./s32oni1.png") },
{ "id": "32oni1k", "name": "キハ32形特別塗装①(連結)", "icon": require("./s32oni1k.png") },
//{ "id": "32to4", "name": "キハ32形(土讃色)", "icon": require("./s32to4.png") },
{ "id": "32toai", "name": "キクハ32形藍よしのがわトロッコ", "icon": require("./s32to_ai.png") },
{ "id": "32at", "name": "キクハ32形アンパンマントロッコ", "icon": require("./32at.png") },
//{ "id": "54", "name": "キハ54形", "icon": require("./54.png") },
{ "id": "54s", "name": "キハ54形(四国色)", "icon": require("./s54s.png") },
{ "id": "54", "name": "キハ54形", "icon": require("./54.png") },
{ "id": "54st", "name": "キハ54形しまんトロッコ", "icon": require("./54st.png") },
//{ "id": "54to0ys", "name": "キハ54形(ゆとりすと)", "icon": require("./s54to0ys.png") },
{ "id": "54nany1", "name": "キハ54形なんよ号①", "icon": require("./s54nany1.png") },
{ "id": "54nany2", "name": "キハ54形なんよ号②", "icon": require("./s54nany2.png") },
{ "id": "54smek1", "name": "キハ54形しまんトロッコ(連結)", "icon": require("./s32smek1.png") },
//{ "id": "40", "name": "キハ40", "icon": require("./40.png") },
{ "id": "40s", "name": "キハ40(四国色)", "icon": require("./s40.png") },
{ "id": "40w", "name": "キハ40(JR西日本色)", "icon": require("./w40.png") },
{ "id": "40", "name": "キハ40", "icon": require("./40.png") },
{ "id": "185mrt", "name": "キハ185系四国色", "icon": require("./s185_mrt.png") },
{ "id": "185cm", "name": "キハ185系普通列車色", "icon": require("./s185cm.png") },
{ "id": "185g", "name": "キハ185系(緑帯)", "icon": require("./s185g.png") },
{ "id": "185tu", "name": "キハ185系剣山色", "icon": require("./s185tu.png") },
{ "id": "185tu_uzu", "name": "キハ185系剣山色(うずしお)", "icon": require("./s185tu_uzu.png") },
{ "id": "185iyor", "name": "キハ185系伊予灘ものがたり(赤)", "icon": require("./s185iyor.png") },
{ "id": "185iyoy", "name": "キハ185系伊予灘ものがたり(黄)", "icon": require("./s185iyoy.png") },
{ "id": "185toai", "name": "キハ185系藍よしのがわトロッコ", "icon": require("./s185to_ai.png") },
{ "id": "185mm1", "name": "キハ185系四国まんなか千年ものがたり(緑)", "icon": require("./s185mm1.png") },
{ "id": "185mm2", "name": "キハ185系四国まんなか千年ものがたり(赤)", "icon": require("./s185mm2.png") },
{ "id": "185ym1", "name": "キハ185系時代の夜明けのものがたり(茶)", "icon": require("./s185ym1.png") },
{ "id": "185ym2", "name": "キハ185系時代の夜明けのものがたり(緑)", "icon": require("./s185ym2.png") },
{ "id": "185ap1", "name": "キハ185系アンパンマン", "icon": require("./s185ap1.png") },
{ "id": "1000", "name": "1000形", "icon": require("./s1000.png") },
//{ "id": "1200n", "name": "1200形", "icon": require("./s1200n.png") },
{ "id": "1200", "name": "1200形(旧塗装)", "icon": require("./s1200.png") },
{ "id": "1201", "name": "1201形", "icon": require("./s1201.png") },
//{ "id": "1500", "name": "1500形", "icon": require("./s1500.png") },
{ "id": "1501", "name": "1500形 1501", "icon": require("./s1501.png") },
{ "id": "1550", "name": "1500形 1550", "icon": require("./s1550.png") },
{ "id": "1551", "name": "1500形 1551", "icon": require("./s1551.png") },
{ "id": "2000asi", "name": "2000系", "icon": require("./s2000_asi.png") },
{ "id": "2000uwa", "name": "2000系(宇和海色)", "icon": require("./s2000_uwa.png") },
{ "id": "N2000", "name": "N2000系", "icon": require("./s2000n.png") },
{ "id": "2000nl", "name": "N2000系(南風色)", "icon": require("./s2000nl.png") },
{ "id": "2000-3", "name": "N2000系(試作)", "icon": require("./s2000-3.png") },
{ "id": "2000ganp1", "name": "2000系(復活塗装)", "icon": require("./s2000ganp1.png") },
{ "id": "2002a", "name": "2000系アンパンマン", "icon": require("./s2002a.png") },
{ "id": "2600", "name": "2600系" , "icon": require("./s2600.png")},
{ "id": "2600apr", "name": "2600系アンパンマン(赤)", "icon": require("./s2600apr.png") },
{ "id": "2600apb", "name": "2600系アンパンマン(青)", "icon": require("./s2600apb.png") },
{ "id": "2700", "name": "2700系", "icon": require("./s2700.png") },
{ "id": "2700asi", "name": "2700系(あしずり色)", "icon": require("./s2700_asi.png") },
{ "id": "2700smn", "name": "2700系(しまんと色)", "icon": require("./s2700_smn.png") },
{ "id": "2700uzu", "name": "2700系(うずしお色)", "icon": require("./s2700_uzu.png") },
{ "id": "2700apy", "name": "2700系アンパンマン(黄)", "icon": require("./s2700apy.png") },
{ "id": "2700apr", "name": "2700系アンパンマン(赤)", "icon": require("./s2700apr.png") },
{ "id": "2700apy1", "name": "2700系アンパンマン(黄)①", "icon": require("./s2700apy1.png") },
{ "id": "2700apr1", "name": "2700系アンパンマン(赤)①", "icon": require("./s2700apr1.png") },
{ "id": "3600", "name": "3600系", "icon": require("./s3600.png") },
{ "id": "1200n", "name": "1200形", "icon": require("./s1200n.png") },
{ "id": "1500", "name": "1500形", "icon": require("./s1500.png") },
{ "id": "5001", "name": "5000系(二階建て)", "icon": require("./s5001.png") },
{ "id": "5001k", "name": "5000系(平屋側)", "icon": require("./s5001k.png") },
//{ "id": "6000p", "name": "6000系", "icon": require("./s6000p.png") },
{ "id": "6000f", "name": "6000系(Fライナー)", "icon": require("./s6000f.png") },
{ "id": "6000p", "name": "6000系", "icon": require("./s6000p.png") },
{ "id": "7000", "name": "7000系", "icon": require("./s7000.png") },
//{ "id": "7001", "name": "7000系 7001", "icon": require("./s7001.png") },
{ "id": "7200", "name": "7200系", "icon": require("./s7200.png") },
{ "id": "2000asi", "name": "2000系", "icon": require("./s2000_asi.png") },
{ "id": "N2000", "name": "N2000系", "icon": require("./s2000n.png") },
{ "id": "2002a", "name": "2000系アンパンマン", "icon": require("./s2002a.png") },
{ "id": "2600", "name": "2600系" , "icon": require("./s2600.png")},
{ "id": "2700", "name": "2700系", "icon": require("./s2700.png") },
{ "id": "2700apy", "name": "2700系アンパンマン(黄)", "icon": require("./s2700apy.png") },
{ "id": "2700apr", "name": "2700系アンパンマン(赤)", "icon": require("./s2700apr.png") },
{ "id": "8000no", "name": "8000系(オレンジ)", "icon": require("./s8000no.png") },
{ "id": "8000nr", "name": "8000系(赤)", "icon": require("./s8000nr.png") },
{ "id": "8001nr", "name": "8000系(赤) 8001", "icon": require("./s8001nr.png") },
{ "id": "8000ap", "name": "8000系アンパンマン", "icon": require("./s8000ap.png") },
{ "id": "8000nn", "name": "8000系リニューアル改", "icon": require("./s8000nn.png") },
{ "id": "8600", "name": "8600系", "icon": require("./s8600.png") },
{ "id": "9000", "name": "9000系", "icon": require("./s9000.png") },
{ "id": "9640", "name": "9640形(白)", "icon": require("./tosa9640.png") },
{ "id": "9640ht", "name": "9640形(赤帯)", "icon": require("./tosa9640ht.png") },
{ "id": "9640mo1", "name": "9640形もみじ号①", "icon": require("./tosa9640mo1.png") },
{ "id": "9640mo2", "name": "9640形もみじ号②", "icon": require("./tosa9640mo2.png") },
{ "id": "9640tyg", "name": "9640形(緑)", "icon": require("./tosa9640tyg.png") },
{ "id": "9640tyb", "name": "9640形(青)", "icon": require("./tosa9640tyb.png") },
{ "id": "9640jgr", "name": "9640形オープンデッキ(緑)", "icon": require("./tosa9640jgr.png") },
{ "id": "9640jbl", "name": "9640形オープンデッキ(青)", "icon": require("./tosa9640jbl.png") },
{ "id": "285", "name": "285系サンライズ瀬戸", "icon": require("./w285.png") },
{ "id": "213w", "name": "ラ・マル・ド・ボァ", "icon": require("./w213w.png") },
{ "id": "W741", "name": "DEC741系", "icon": require("./w741.png") },
{ "id": "Wk141", "name": "キヤ141系", "icon": require("./w141jg.png") },
{ "id": "EF65", "name": "EF65", "icon": require("./ef65.png") },
//{ "id": "EF210", "name": "EF210", "icon": require("./ef210.png") },
{ "id": "EF210a", "name": "EF210(300番代)", "icon": require("./ef210a.png") },
{ "id": "EF210n", "name": "EF210(新塗装)", "icon": require("./ef210n.png") },
{ "id": "EF210n1", "name": "EF210(新塗装①)", "icon": require("./ef210n1.png") },
{ "id": "EF210l3", "name": "EF210(L3編成)", "icon": require("./ef210l3.png") },
{ "id": "T45000", "name": "トラ45000", "icon": require("./s150001to.png") },
{ "id": "EF210", "name": "EF210", "icon": require("./ef210.png") },
]
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

-35
View File
@@ -1,35 +0,0 @@
/**
*
* StationNumber "SP"
* Station_JP .
*/
export default [
{
Station_JP: ".与島",
Station_EN: "Yoshima",
MyStation: "0",
StationNumber: null,
DispNum: "3",
StationTimeTable: "",
StationMap: "https://www.google.co.jp/maps/place/34.389472,133.816444",
JrHpUrl: "https://www.jb-honshi.co.jp/yoshimapa/",
jslodApi: "spot",
lat: 34.389472,
lng: 133.816444,
isSpot: true,
},
{
Station_JP: ".小歩危展望台",
Station_EN: "Koboke Observatory",
MyStation: "0",
StationNumber: null,
DispNum: "3",
StationTimeTable: "",
StationMap: "https://maps.app.goo.gl/WBMN5R2tk2tusavk7",
JrHpUrl: "https://miyoshi-tourism.jp/spot/5438/",
jslodApi: "spot",
lat: 33.9372609,
lng: 133.753258,
isSpot: true,
},
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

@@ -1,35 +1,21 @@
import React, { useRef, useState } from "react";
import React, { useRef } from "react";
import { Platform } from "react-native";
import ActionSheet from "react-native-actions-sheet";
import { EachTrainInfoCore } from "./EachTrainInfoCore";
import { useSheetMaxHeight } from "./useSheetMaxHeight";
export const EachTrainInfo = ({ payload }) => {
const actionSheetRef = useRef(null);
const maxHeight = useSheetMaxHeight();
const [sheetOpened, setSheetOpened] = useState(false);
const handleOpen = () => {
setSheetOpened(true);
};
const handleClose = () => {
setSheetOpened(false);
};
if (!payload) return <></>;
const actionSheetRef = useRef(null);
return (
<ActionSheet
gestureEnabled={true}
CustomHeaderComponent={<></>}
ref={actionSheetRef}
drawUnderStatusBar={false}
isModal={Platform.OS === "ios" && !Platform.isPad}
containerStyle={{ maxHeight }}
onOpen={handleOpen}
onClose={handleClose}
isModal={Platform.OS == "ios"}
//useBottomSafeAreaPadding={Platform.OS == "android"}
>
<EachTrainInfoCore {...{ actionSheetRef, sheetOpened, ...payload }} />
<EachTrainInfoCore {...{ actionSheetRef, ...payload }} />
</ActionSheet>
);
};
@@ -2,7 +2,6 @@ import React, { FC } from "react";
import { View, Text, TouchableWithoutFeedback, Alert } from "react-native";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
export const DataConnectedButton: FC<{
i: string;
@@ -11,7 +10,6 @@ export const DataConnectedButton: FC<{
const [station, se, time] = i.split(",");
const { keyList } = useAllTrainDiagram();
const { colors } = useThemeColors();
const { fontScale, moderateScale } = useResponsive();
// 列番が有効かどうかをチェックする関数
const isValidTrainNumber = (trainNum: string): boolean => {
return keyList.includes(trainNum);
@@ -46,19 +44,19 @@ export const DataConnectedButton: FC<{
>
<View
style={{
width: moderateScale(35),
width: 35,
position: "relative",
marginHorizontal: 15,
flexDirection: "row",
height: "10%",
}}
/>
<Text style={{ fontSize: fontScale(16), fontFamily: "DiaPro", color: colors.text }}>
<Text style={{ fontSize:16, fontFamily: "DiaPro", color: colors.text }}>
{se === "増" ? "⬐" : "↳"}
</Text>
<Text style={{ fontSize: fontScale(20), color: colors.textLink }}>{time}</Text>
<Text style={{ fontSize: 20, color: colors.textLink }}>{time}</Text>
<View style={{ flex: 1 }} />
<Text style={{ fontSize: fontScale(18), width: moderateScale(50), color: colors.text }}>
<Text style={{ fontSize: 18, width: 50, color: colors.text }}>
{se === "増" ? "増結" : "解結"}
</Text>
</View>
@@ -12,7 +12,6 @@ import {
} from "@/utils/seUtils";
import type { SeTypes } from "@/types";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
type seTypes =
| "発編"
@@ -66,7 +65,6 @@ export const EachStopList: FC<props> = ({
isNotService = false,
}) => {
const { colors: themeColors, isDark } = useThemeColors();
const { fontScale, moderateScale } = useResponsive();
const [station, se, time, platformNum] = i.split(",") as [
string,
seTypes,
@@ -122,16 +120,6 @@ export const EachStopList: FC<props> = ({
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 platformDisplay = hasPlatformNum
? isPlatformNumNumeric
? parseInt(trimmedPlatformNum, 10) === 0
? "⓪"
: String.fromCharCode(0x2460 + parseInt(trimmedPlatformNum, 10) - 1)
: `(${trimmedPlatformNum})`
: "";
// SE文字列を表示用に変換
const [seString, seType] = parseSeString(se);
@@ -209,7 +197,7 @@ export const EachStopList: FC<props> = ({
>
<View
style={{
width: moderateScale(35),
width: 35,
position: "relative",
marginHorizontal: 15,
flexDirection: "row",
@@ -234,7 +222,7 @@ export const EachStopList: FC<props> = ({
<View style={{ position: "relative", height: 25 }}>
<Text
style={{
fontSize: fontScale(20),
fontSize: 20,
position: "absolute",
textAlignVertical: "center",
left: -15,
@@ -259,24 +247,16 @@ export const EachStopList: FC<props> = ({
<View style={{ flex: 1 }} />
<Text
style={{
fontSize: fontScale(20),
fontSize: 20,
color: colors.text,
fontStyle: isCommunity ? "italic" : "normal",
textAlignVertical: "center",
}}
>
{station}
{hasPlatformNum && (
<Text
style={{
fontSize: fontScale(isPlatformNumNumeric ? 20 : 14),
color: colors.text,
fontStyle: isCommunity ? "italic" : "normal",
}}
>
{platformDisplay}
</Text>
)}
{station}{platformNum &&
(parseInt(platformNum) === 0
? "⓪"
: String.fromCharCode(0x2460 + parseInt(platformNum) - 1))}
</Text>
<View style={{ flex: 1 }} />
</View>
@@ -328,21 +308,20 @@ const TimeText: FC<{
normalTextColor,
}) => {
const [seString, seType] = parseSeString(se);
const { fontScale, moderateScale } = useResponsive();
return (
<View style={{ flexDirection: "row", alignItems: "center", opacity: isBefore ? 0.5 : 1 }}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
{!!currentTrainData?.delay &&
currentTrainData?.delay != "入線" &&
currentTrainData?.delay != 0 && (
<Text
style={{
fontSize: fontScale(isBefore ? 14 : 15),
fontSize: isBefore ? 14 : 15,
marginVertical: isDouble ? -2 : 0,
color: normalTextColor,
width: moderateScale(60),
width: 60,
position: "absolute",
right: isBefore ? moderateScale(125) : moderateScale(120),
right: isBefore ? 125 : 120,
textAlign: "right",
textDecorationLine: "line-through",
fontStyle: seType == "community" ? "italic" : "normal",
@@ -362,8 +341,8 @@ const TimeText: FC<{
/>
<Text
style={{
fontSize: fontScale(isBefore ? 14 : 18),
width: moderateScale(50),
fontSize: isBefore ? 14 : 18,
width: 50,
color: textColor,
marginVertical: isDouble ? -2 : 0,
fontStyle: seType == "community" ? "italic" : "normal",
@@ -377,7 +356,6 @@ const TimeText: FC<{
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 == "通過" ||
@@ -397,7 +375,7 @@ const StationNumbersBox: FC<{ stn: string; se: seTypes }> = (props) => {
style={{
color: fixed.textOnPrimary,
textAlign: "center",
fontSize: fontScale(10),
fontSize: 10,
fontWeight: "bold",
}}
>
@@ -422,7 +400,6 @@ type StationTimeBoxType = {
const StationTimeBox: FC<StationTimeBoxType> = (props) => {
const { delay, textColor, seType, se, time, isDouble, isBefore } = props;
const { fontScale, moderateScale } = useResponsive();
const dates = dayjs()
.set("hour", parseInt(time.split(":")[0]))
.set("minute", parseInt(time.split(":")[1]))
@@ -431,10 +408,10 @@ const StationTimeBox: FC<StationTimeBoxType> = (props) => {
return (
<Text
style={{
fontSize: fontScale(isBefore ? 14 : 20),
fontSize: isBefore ? 14 : 20,
marginVertical: isDouble ? -2 : 0,
color: textColor,
width: moderateScale(60),
width: 60,
fontStyle: seType == "community" ? "italic" : "normal",
}}
>
@@ -1,5 +1,5 @@
import React, { FC } from "react";
import { View } from "react-native";
import { ScrollView } from "react-native";
import { TrainDataView } from "./TrainDataView";
import { trainDataType } from "@/lib/trainPositionTextArray";
import type { NavigateFunction } from "@/types";
@@ -18,13 +18,12 @@ export const LongHeader:FC<props> = ({
navigate,
}) => {
return (
<View
<ScrollView
style={{
flexDirection: "row",
flex: 1,
width: "100%",
alignSelf: "stretch",
}}
horizontal
pagingEnabled
>
<TrainDataView
currentTrainData={currentTrainData}
@@ -34,6 +33,6 @@ export const LongHeader:FC<props> = ({
navigate={navigate}
key={"LongHeader"}
/>
</View>
</ScrollView>
);
};
@@ -1,8 +1,7 @@
import { trainPosition, trainDataType } from "@/lib/trainPositionTextArray";
import React, { FC } from "react";
import { View, Text, ViewStyle } from "react-native";
import { View, Text, TextStyle, ViewStyle } from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
type stateBox = {
currentTrainData: trainDataType | undefined;
@@ -24,7 +23,6 @@ export const PositionBox: FC<stateBox> = (props) => {
lineNumber,
} = props;
const { colors } = useThemeColors();
const { fontScale } = useResponsive();
let firstText = "";
let secondText = "";
let marginText = "";
@@ -51,11 +49,11 @@ export const PositionBox: FC<stateBox> = (props) => {
}
return (
<View style={{ ...(mode == 2 ? boxStyle2 : boxStyle), backgroundColor: colors.surface, ...style }}>
<Text style={{ fontSize: fontScale(12), color: colors.textAccent }}>{title}</Text>
<Text style={{ fontSize: 12, color: colors.textAccent }}>{title}</Text>
<View style={{ flex: 1 }} />
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
{firstText && (
<Text style={[mode == 2 ? { fontSize: fontScale(18), textAlign: "right" as const } : (isBetween ? { fontSize: fontScale(25), textAlign: "right" as const } : { fontSize: fontScale(28), textAlign: "right" as const }), { color: colors.textAccent }]}>
<Text style={[mode == 2 ? boxTextStyle2 : (isBetween ? boxTextStyle : boxTextStyleBig), { color: colors.textAccent }]}>
{firstText}
</Text>
)}
@@ -65,7 +63,7 @@ export const PositionBox: FC<stateBox> = (props) => {
</Text>
)}
{secondText && (
<Text style={[mode == 2 ? { fontSize: fontScale(18), textAlign: "right" as const } :(isBetween ? { fontSize: fontScale(25), textAlign: "right" as const } : { fontSize: fontScale(16), textAlign: "right" as const }), { color: colors.textAccent }]}>
<Text style={[mode == 2 ? boxTextStyle2 :(isBetween ? boxTextStyle : boxTextStyleMini), { color: colors.textAccent }]}>
{secondText}
</Text>
)}
@@ -74,8 +72,8 @@ export const PositionBox: FC<stateBox> = (props) => {
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
style={{
...(mode == 2 ? { fontSize: fontScale(18), textAlign: "right" as const } : { fontSize: fontScale(25), textAlign: "right" as const }),
fontSize: fontScale(10),
...{ ...(mode == 2 ? boxTextStyle2 : boxTextStyle) },
fontSize: 10,
color: colors.textAccent,
}}
>
@@ -99,3 +97,23 @@ const boxStyle2: ViewStyle = {
padding: 5,
margin: 5,
};
const boxTextStyle2: TextStyle = {
fontSize: 18,
textAlign: "right",
};
const boxTextStyleBig: TextStyle = {
fontSize: 28,
textAlign: "right",
};
const boxTextStyleMini: TextStyle = {
fontSize: 16,
textAlign: "right",
};
const boxTextStyle: TextStyle = {
fontSize: 25,
textAlign: "right",
};
@@ -1,12 +1,10 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { View, Text, LayoutAnimation, TouchableOpacity } from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
export const ScrollStickyContent = (props) => {
const { currentTrainData, showThrew, setShowThrew, haveThrough } = props;
const { colors } = useThemeColors();
const { fontScale } = useResponsive();
return (
<View
style={{
@@ -24,13 +22,13 @@ export const ScrollStickyContent = (props) => {
flex: 1,
}}
>
<Text style={{ fontSize: fontScale(20), color: colors.text }}></Text>
<Text style={{ fontSize: 20, color: colors.text }}></Text>
<View style={{ flex: 1 }} />
<View style={{ flexDirection: "row" }}>
{!isNaN(currentTrainData?.delay) && currentTrainData?.delay != 0 && (
<Text
style={{
fontSize: fontScale(15),
fontSize: 15,
color: colors.text,
position: "absolute",
right: 110,
@@ -43,7 +41,7 @@ export const ScrollStickyContent = (props) => {
)}
<Text
style={{
fontSize: fontScale(20),
fontSize: 20,
color: isNaN(currentTrainData?.delay)
? colors.text
: currentTrainData?.delay == 0
@@ -57,13 +55,17 @@ export const ScrollStickyContent = (props) => {
<TouchableOpacity
onPress={() => {
if (!haveThrough) return;
LayoutAnimation.configureNext({
duration: 200,
update: { type: "easeInEaseOut", springDamping: 0.6 },
});
setShowThrew(!showThrew);
}}
>
<View style={{ flex: 1 }} />
<Text
style={{
fontSize: fontScale(12),
fontSize: 12,
width: 50,
paddingBottom: 0,
margin: "auto",
@@ -1,5 +1,5 @@
import React, { FC } from "react";
import { View } from "react-native";
import { ScrollView } from "react-native";
import { TrainDataView } from "./TrainDataView";
import { trainDataType } from "@/lib/trainPositionTextArray";
import type { NavigateFunction } from "@/types";
@@ -18,13 +18,13 @@ export const ShortHeader:FC<props> = ({
navigate,
}) => {
return (
<View
<ScrollView
style={{
flexDirection: "row",
flex: 1,
width: "100%",
alignSelf: "stretch",
}}
horizontal
pagingEnabled
>
<TrainDataView
mode={2}
@@ -35,6 +35,6 @@ export const ShortHeader:FC<props> = ({
navigate={navigate}
key={"ShortHeader"}
/>
</View>
</ScrollView>
);
};
@@ -1,7 +1,6 @@
import React, { CSSProperties, FC } from "react";
import { View, Text, StyleProp, ViewStyle } from "react-native";
import { View, Text, StyleProp, TextStyle, ViewStyle } from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
type stateBox = {
text: string;
@@ -13,20 +12,19 @@ type stateBox = {
export const StateBox: FC<stateBox> = (props) => {
const { text, title, style, mode, endText } = props;
const { colors } = useThemeColors();
const { fontScale } = useResponsive();
return (
<View style={{ ...(mode == 2 ? boxStyle2 : boxStyle), backgroundColor: colors.surface, ...style }}>
<Text style={{ fontSize: fontScale(12), color: colors.textAccent }}>{title}</Text>
<Text style={{ fontSize: 12, color: colors.textAccent }}>{title}</Text>
<View style={{ flex: 1 }} />
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text style={[mode == 2 ? { fontSize: fontScale(18), textAlign: "right" as const } : { fontSize: fontScale(25), textAlign: "right" as const }, { color: colors.textAccent }]}>{text}</Text>
<Text style={[mode == 2 ? boxTextStyle2 : boxTextStyle, { color: colors.textAccent }]}>{text}</Text>
</View>
{endText && (
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
style={{
...(mode == 2 ? { fontSize: fontScale(18), textAlign: "right" as const } : { fontSize: fontScale(25), textAlign: "right" as const }),
fontSize: fontScale(10),
...{ ...(mode == 2 ? boxTextStyle2 : boxTextStyle) },
fontSize: 10,
color: colors.textAccent,
}}
>
@@ -49,3 +47,12 @@ const boxStyle2: ViewStyle = {
padding: 5,
margin: 5,
};
const boxTextStyle2: TextStyle = {
fontSize: 18,
textAlign: "right",
};
const boxTextStyle: TextStyle = {
fontSize: 25,
textAlign: "right",
};
@@ -1,5 +1,5 @@
import React, { useState, useEffect, FC } from "react";
import { View, TouchableOpacity, Text } from "react-native";
import { View, TouchableOpacity, useWindowDimensions, Text } from "react-native";
import { StateBox } from "./StateBox";
import { PositionBox } from "./PositionBox";
import { useDeviceOrientationChange } from "../../../stateBox/useDeviceOrientationChange";
@@ -13,7 +13,6 @@ import { useStationList } from "../../../stateBox/useStationList";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { customTrainDataDetector } from "@/components/custom-train-data";
import type { NavigateFunction } from "@/types";
import { stackAwareNavigate } from "@/lib/rootNavigation";
type props = {
@@ -34,6 +33,7 @@ export const TrainDataView:FC<props> = ({
}) => {
const { stationList } = useStationList();
const { width, height } = useWindowDimensions();
const { isLandscape } = useDeviceOrientationChange();
const { setInjectData } = useCurrentTrain();
@@ -155,7 +155,7 @@ export const TrainDataView:FC<props> = ({
flexDirection: "row",
//minHeight: 200,
//height: heightPercentageToDP("20%"),
width: "100%",
width: isLandscape ? (width / 100) * 40 : width,
flex: 1,
}}
>
@@ -164,16 +164,16 @@ export const TrainDataView:FC<props> = ({
//disabled={!onLine}
//onLongPress={openEditWindow}
onLongPress={()=>{
if (!currentTrainData) return;
if (!onLine) return;
setInjectData({ type:"train", value:currentTrainData?.num, fixed:true});
stackAwareNavigate("positions");
navigate("positions", { screen: "Apps" });
SheetManager.hide("EachTrainInfo");
}}
onPress={() => {
if (!onLine) return;
setInjectData({ type: "station", value: currentPosition[0], fixed: false });
stackAwareNavigate("positions");
navigate("positions", { screen: "Apps" });
SheetManager.hide("EachTrainInfo");
}}
>
@@ -22,10 +22,8 @@ import { ShowSpecialTrain } from "./EachTrainInfo/ShowSpecialTrain";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { HeaderText } from "./EachTrainInfoCore/HeaderText";
import { useStationList } from "../../stateBox/useStationList";
import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
import { useThemeColors } from "@/lib/theme";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useResponsive } from "@/lib/responsive";
// Custom hooks
import { useTrainDiagramData } from "./EachTrainInfoCore/hooks/useTrainDiagramData";
@@ -35,6 +33,7 @@ import { useStopStationIDs } from "./EachTrainInfoCore/hooks/useStopStationIDs";
import { useTrainPosition } from "./EachTrainInfoCore/hooks/useTrainPosition";
import { useAutoScroll } from "./EachTrainInfoCore/hooks/useAutoScroll";
import { useExtendedStations } from "./EachTrainInfoCore/hooks/useExtendedStations";
import { LiveActivityButton } from "./EachTrainInfo/LiveActivityButton";
export const EachTrainInfoCore = ({
actionSheetRef,
@@ -42,16 +41,13 @@ export const EachTrainInfoCore = ({
openStationACFromEachTrainInfo,
from,
navigate,
sheetOpened = false,
}) => {
const { stationList } = useStationList();
const { allCustomTrainData } = useAllTrainDiagram();
const { colors, fixed } = useThemeColors();
const { verticalScale, moderateScale } = useResponsive();
const { setTrainInfo } = useTrainMenu();
const { height } = useWindowDimensions();
const { isLandscape } = useDeviceOrientationChange();
const { getCurrentStationData } = useCurrentTrain();
const scrollRef = useRef<any>(null);
// Custom hooks for data management
@@ -75,9 +71,7 @@ export const EachTrainInfoCore = ({
} = useExtendedStations(trainData, setTrainData);
// UI state
// 走行中の列車は初期状態から通過駅を表示する(後から showThrew を true に変更すると
// ActionSheet の onSheetLayout が再発火してスプリングアニメーションが途中でリスタートするため)
const [showThrew, setShowThrew] = useState(() => !!getCurrentStationData(data.trainNum));
const [showThrew, setShowThrew] = useState(false);
const [isJumped, setIsJumped] = useState(false);
// Auto scroll to current position
@@ -87,7 +81,7 @@ export const EachTrainInfoCore = ({
scrollRef,
isJumped,
setIsJumped,
sheetOpened
setShowThrew
);
// Back button handler
@@ -131,6 +125,7 @@ export const EachTrainInfoCore = ({
} else {
SheetManager.hide("EachTrainInfo").then(() => {
setTimeout(() => {
// @ts-expect-error - SheetManager payload type is too restrictive
SheetManager.show("EachTrainInfo", { payload });
}, 200);
});
@@ -147,11 +142,11 @@ export const EachTrainInfoCore = ({
borderWidth: 1,
}}
>
<View style={{ height: verticalScale(26), width: "100%" }}>
<View style={{ height: 26, width: "100%" }}>
<View
style={{
height: verticalScale(6),
width: moderateScale(45),
height: 6,
width: 45,
borderRadius: 100,
backgroundColor: colors.borderLight,
marginVertical: 10,
@@ -178,7 +173,7 @@ export const EachTrainInfoCore = ({
scrollRef={scrollRef}
containerProps={{
style: {
maxHeight: height * 0.7,
maxHeight: isLandscape ? height - 94 : (height / 100) * 70,
backgroundColor:
customTrainType.data === "notService" ? "#777777ff" : colors.surface,
},
@@ -211,8 +206,12 @@ export const EachTrainInfoCore = ({
/>
}
>
<LiveActivityButton
currentTrainData={currentTrainData}
currentPosition={currentPosition}
/>
{customTrainType.data === "notService" && (
<Text style={{ backgroundColor: colors.surface, color: colors.text, fontWeight: "bold" }}>
<Text style={{ backgroundColor: colors.surface, fontWeight: "bold" }}>
</Text>
)}
@@ -16,8 +16,6 @@ import type { NavigateFunction } from "@/types";
import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
type Props = {
data: { trainNum: string; limited: string };
@@ -48,11 +46,10 @@ export const HeaderText: FC<Props> = ({
from,
scrollRef,
}) => {
const { trainNum } = data;
const { limited, trainNum } = data;
const { fixed } = useThemeColors();
const { fontScale } = useResponsive();
const { iconSetting } = useTrainMenu();
const { updatePermission } = useTrainMenu();
const { allCustomTrainData, getTodayOperationByTrainId } =
useAllTrainDiagram();
const { expoPushToken } = useNotification();
@@ -60,18 +57,18 @@ export const HeaderText: FC<Props> = ({
getUnyohubByTrainNumber,
getUnyohubEntriesByTrainNumber,
useUnyohub: unyohubEnabled,
unyohubData,
} = useUnyohub();
const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } =
useElesite();
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
// 追加ソースのON/OFFをここで管理(将来ソースが増えたらここに足す)
const additionalSources = {
unyohub: unyohubEnabled,
elesite: elesiteEnabled,
};
const hasAdditionalSources = Object.values(additionalSources).some(Boolean);
// 列車名、種別、フォントの取得
const [
typeName,
trainName,
@@ -133,7 +130,7 @@ export const HeaderText: FC<Props> = ({
case to_data && to_data !== "":
return [
typeString,
`${to_data}行き`,
to_data + "行き",
fontAvailable,
isOneMan,
infogram,
@@ -147,7 +144,7 @@ export const HeaderText: FC<Props> = ({
return [
typeString,
migrateTrainName(
`${trainData[trainData.length - 1].split(",")[0]}行き`,
trainData[trainData.length - 1].split(",")[0] + "行き",
),
fontAvailable,
isOneMan,
@@ -159,10 +156,11 @@ export const HeaderText: FC<Props> = ({
result,
];
}
}, [trainData, trainNum, allCustomTrainData]);
}, [trainData]);
const allTodayOperation = getTodayOperationByTrainId(trainNum) ?? [];
const todayOperation = allTodayOperation.filter((d) => d.state !== 100);
const todayOperation = getTodayOperationByTrainId(trainNum).filter(
(d) => d.state !== 100,
);
let iconTrainDirection =
parseInt(trainNum.replace(/[^\d]/g, "")) % 2 == 0 ? true : false;
@@ -170,34 +168,11 @@ export const HeaderText: FC<Props> = ({
iconTrainDirection = directions ? true : false;
}
const unyohubLookupNum = customTrainData?.train_number_override || trainNum;
const isFreightRetsuban = unyohubLookupNum.includes("レ");
const unyohubTrainNumForSourceScreen = isFreightRetsuban
? unyohubLookupNum.replace(/レ/g, "")
: unyohubLookupNum;
const freightUnyohubCandidates = (() => {
const digits = unyohubTrainNumForSourceScreen.replace(/[^\d]/g, "");
const candidates = new Set<string>();
if (!digits) return candidates;
candidates.add(digits);
if (/^\d{2}$/.test(digits)) {
candidates.add(`30${digits}`);
candidates.add(`90${digits}`);
} else if (/^(30|90)\d{2}$/.test(digits)) {
candidates.add(digits.slice(-2));
}
return candidates;
})();
const unyohubFormation = getUnyohubByTrainNumber(unyohubLookupNum);
const unyohubEntries = isFreightRetsuban
? unyohubData.filter((unyo) =>
unyo.trains?.some(
(t) => !!t.train_number && freightUnyohubCandidates.has(t.train_number),
),
)
: getUnyohubEntriesByTrainNumber(unyohubTrainNumForSourceScreen);
const unyohubFormation = getUnyohubByTrainNumber(trainNum);
const unyohubEntries = getUnyohubEntriesByTrainNumber(trainNum);
const elesiteEntries = getElesiteEntriesByTrainNumber(trainNum);
// 車番(formations)が空でないエントリが1件以上あれば「運用Hub情報あり」と判定
const hasUnyohubFormation = unyohubEntries.some(
(e) => !!e.formations && e.formations.trim() !== "",
);
@@ -207,21 +182,12 @@ export const HeaderText: FC<Props> = ({
const hasExtraInfo =
priority > 200 ||
todayOperation.length > 0 ||
todayOperation?.length > 0 ||
hasUnyohubFormation ||
hasElesiteFormation;
const [isWrapped, setIsWrapped] = useState(false);
const openTrainInfoUrl = () => {
if (!trainInfoUrl) return;
const uri = trainInfoUrl.includes("pdf")
? getPDFViewURL(trainInfoUrl)
: trainInfoUrl;
navigate("generalWebView", { uri, useExitButton: true });
SheetManager.hide("EachTrainInfo");
};
return (
<View
style={{
@@ -231,7 +197,7 @@ export const HeaderText: FC<Props> = ({
width: "100%",
}}
onTouchStart={() =>
scrollRef.current?.scrollTo({ y: 0, animated: true })
scrollRef.current?.current?.scrollTo({ y: 0, animated: true })
}
>
<TrainIconStatus
@@ -240,7 +206,6 @@ export const HeaderText: FC<Props> = ({
from={from}
todayOperation={todayOperation}
direction={iconTrainDirection}
iconDisplayMode={iconDisplayMode}
/>
<View
@@ -252,7 +217,7 @@ export const HeaderText: FC<Props> = ({
>
<Text
style={{
fontSize: fontScale(20),
fontSize: 20,
color: fixed.textOnPrimary,
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
@@ -279,28 +244,33 @@ export const HeaderText: FC<Props> = ({
}
: {}),
}}
onPress={openTrainInfoUrl}
onPress={() => {
if (!trainInfoUrl) return;
const uri = trainInfoUrl.includes("pdf")
? getPDFViewURL(trainInfoUrl)
: trainInfoUrl;
navigate("generalWebView", { uri, useExitButton: true });
SheetManager.hide("EachTrainInfo");
}}
disabled={!trainInfoUrl}
>
<Text
style={{
...textConfig,
color: fixed.textOnPrimary,
...(trainName.length > 10
? { fontSize: fontScale(16) }
: { fontSize: fontScale(17) }),
...(trainName.length > 10 ? { fontSize: 16 } : {}),
flexShrink: 1,
}}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
}}
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
}}
>
{trainName}
<InfogramText infogram={infogram} />
</Text>
<InfogramText infogram={infogram} />
</TouchableOpacity>
<View style={{ flex: 1 }} />
<Text style={{ ...textConfig, fontSize: fontScale(17), color: fixed.textOnPrimary }}>
<Text style={{ ...textConfig, color: fixed.textOnPrimary }}>
{showHeadStation.map((d) => `${headStation[d].id} + `)}
{trainNum}
{showTailStation.map((d) => ` + ${tailStation[d].id}`)}
@@ -315,7 +285,6 @@ export const HeaderText: FC<Props> = ({
(SheetManager.show as any)("TrainDataSources", {
payload: {
trainNum,
unyohubTrainNum: unyohubTrainNumForSourceScreen,
unyohubEntries,
elesiteEntries,
todayOperation,
@@ -332,6 +301,7 @@ export const HeaderText: FC<Props> = ({
},
});
} else {
// 追加ソースが全てオフ → 元の挙動(直接 DB ページを開く)
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
navigate("generalWebView", { uri, useExitButton: false });
SheetManager.hide("EachTrainInfo");
@@ -3,14 +3,13 @@ import { Text } from "react-native";
import { useThemeColors } from "@/lib/theme";
type props = {
infogram: string;
fontSize?: number;
}
export const InfogramText: FC<props> = ({infogram, fontSize = 20}) => {
export const InfogramText: FC<props> = ({infogram}) => {
const { fixed } = useThemeColors();
return (
<Text
style={{
fontSize,
fontSize: 20,
color: fixed.textOnPrimary,
fontFamily: "JNR-font",
}}
@@ -1,98 +0,0 @@
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";
type StackVariant = "header" | "list";
type StatusIcon = ComponentProps<typeof Ionicons>;
type Props = {
entries: TrainIconEntry[];
direction?: boolean;
hidden?: boolean;
onPressEntry?: (entry: TrainIconEntry, index: number) => void;
statusIcon?: StatusIcon;
variant?: StackVariant;
};
const iconSize = {
header: {
width: 24,
height: 30,
stackedWidth: 12,
stackedHeight: 15,
marginRight: 5,
stackedMarginLeft: -10,
stackedMarginTop: 10,
statusSize: 24,
},
list: {
width: 20,
height: 22,
stackedWidth: 10,
stackedHeight: 12,
marginRight: 2,
stackedMarginLeft: -8,
stackedMarginTop: 8,
statusSize: 18,
},
} as const;
export const TrainIconStack: FC<Props> = ({
entries,
direction,
hidden = false,
onPressEntry,
statusIcon,
variant = "header",
}) => {
const size = iconSize[variant];
return (
<View style={{ flexDirection: "row", alignItems: "flex-start" }}>
{entries.map((entry, index) => {
const trainIcon = direction
? entry.vehicle_info_img
: entry.vehicle_info_right_img || entry.vehicle_info_img;
if (!trainIcon) return null;
const content = (
<View>
<View style={{ opacity: hidden ? 0 : 1 }}>
<Image
source={{ uri: trainIcon }}
style={{
height: index > 0 ? size.stackedHeight : size.height,
width: index > 0 ? size.stackedWidth : size.width,
marginRight: size.marginRight,
marginLeft: index > 0 ? size.stackedMarginLeft : 0,
marginTop: index > 0 ? size.stackedMarginTop : 0,
}}
resizeMethod="resize"
/>
</View>
{statusIcon && hidden && (
<View style={{ position: "absolute", top: 0, left: 0 }}>
<Ionicons {...statusIcon} size={size.statusSize} />
</View>
)}
</View>
);
if (!onPressEntry) {
return <View key={`${trainIcon}-${index}`}>{content}</View>;
}
return (
<TouchableOpacity
key={`${trainIcon}-${index}`}
onPress={() => onPressEntry(entry, index)}
disabled={!entry.vehicle_info_url}
>
{content}
</TouchableOpacity>
);
})}
</View>
);
};
@@ -1,4 +1,5 @@
import { useEffect, MutableRefObject } from 'react';
import React,{ useEffect, MutableRefObject } from 'react';
import { LayoutAnimation, ScrollView } from 'react-native';
export const useAutoScroll = (
@@ -7,15 +8,24 @@ export const useAutoScroll = (
scrollRef: MutableRefObject<any>,
isJumped: boolean,
setIsJumped: (value: boolean) => void,
sheetOpened: boolean = false
setShowThrew: (value: boolean) => void
) => {
useEffect(() => {
// ActionSheetのスプリングアニメーション完了後まで待機
if (!sheetOpened || isJumped || !points?.length || !scrollRef) return;
if (isJumped || !points?.length || !scrollRef) return;
const currentPositionIndex = points.findIndex((d) => d === true);
if (currentPositionIndex === -1) return;
setShowThrew(true);
const isPassingThrough = trainDataWithThrough[currentPositionIndex]?.split(',')[1] === '通過';
if (isPassingThrough) {
LayoutAnimation.configureNext({
duration: 400,
update: { type: 'easeInEaseOut', springDamping: 0.6 },
});
}
// 5駅以内の場合はスクロールしない
if (currentPositionIndex < 5) {
setIsJumped(true);
@@ -23,11 +33,9 @@ export const useAutoScroll = (
}
const scrollPosition = currentPositionIndex * 44 - 50;
const timer = setTimeout(() => {
scrollRef.current?.scrollTo({ y: scrollPosition, animated: true });
setTimeout(() => {
scrollRef.current?.current?.scrollTo({ y: scrollPosition, animated: true });
setIsJumped(true);
}, 100);
return () => clearTimeout(timer);
}, [sheetOpened, points, trainDataWithThrough, scrollRef, isJumped, setIsJumped]);
}, 400);
}, [points, trainDataWithThrough, scrollRef, isJumped, setIsJumped, setShowThrew]);
};
@@ -1,25 +1,25 @@
import { useState, useEffect } from 'react';
import { useStationList } from '@/stateBox/useStationList';
const computeStopStationIDs = (data: string[], stationList: any[][]): string[][] =>
data.map((item) => {
const [stationName] = item.split(',');
return stationList
.map((lineStations) => lineStations.filter((s) => s.StationName === stationName))
.reduce((acc, s) => acc.concat(s), [])
.map((s) => s.StationNumber);
});
export const useStopStationIDs = (trainDataWithThrough: string[]) => {
const { stationList } = useStationList();
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [stopStationIDList, setStopStationIDList] = useState<string[][]>(() =>
computeStopStationIDs(trainDataWithThrough, stationList)
);
const [stopStationIDList, setStopStationIDList] = useState<string[][]>([]);
useEffect(() => {
setStopStationIDList(computeStopStationIDs(trainDataWithThrough, stationList));
const stationIDs = trainDataWithThrough.map((item) => {
const [stationName] = item.split(',');
const matchingStations = stationList
.map((lineStations) =>
lineStations.filter((station) => station.StationName === stationName)
)
.reduce((acc, stations) => acc.concat(stations), [])
.map((station) => station.StationNumber);
return matchingStations;
});
setStopStationIDList(stationIDs);
}, [trainDataWithThrough, stationList]);
return stopStationIDList;

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