Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ea9d6764b | |||
| cf3995fcbe | |||
| 983c453322 | |||
| c69585b257 | |||
| c6d4fca881 | |||
| 9c67190509 | |||
| 956b660b4f | |||
| f056fe593a | |||
| ca8d3225d1 | |||
| 0ebc6bc153 | |||
| fbd342d640 | |||
| b6e6d2e809 |
+2
-1
@@ -59,4 +59,5 @@ ios/
|
||||
!modules/**/ios/
|
||||
*.ipa
|
||||
*.apk
|
||||
*.aab
|
||||
*.aab
|
||||
.env.local
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"Sentry": {
|
||||
"url": "https://mcp.sentry.dev/mcp/xprocess-m5/jr-shikoku-unofficial-apps"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,26 @@ import { UserPositionProvider } from "./stateBox/useUserPosition";
|
||||
import { rootNavigationRef, stackAwareNavigate } from "./lib/rootNavigation";
|
||||
import { AppThemeProvider } from "./lib/theme";
|
||||
import StatusbarDetect from "./StatusbarDetect";
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
|
||||
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()],
|
||||
|
||||
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
|
||||
// spotlight: __DEV__,
|
||||
});
|
||||
|
||||
LogBox.ignoreLogs([
|
||||
"ViewPropTypes will be removed",
|
||||
@@ -40,7 +60,7 @@ if (Platform.OS === "android") {
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
export default Sentry.wrap(function App() {
|
||||
useEffect(() => {
|
||||
UpdateAsync();
|
||||
}, []);
|
||||
@@ -157,7 +177,7 @@ export default function App() {
|
||||
</DeviceOrientationChangeProvider>
|
||||
</AppThemeProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 低密度ディスプレイ(DeX等)で全体を transform scale で拡大。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions } from "react-native";
|
||||
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
|
||||
import { useNavigationState } from "@react-navigation/native";
|
||||
import { useFonts } from "expo-font";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
@@ -14,9 +14,10 @@ import { useTrainMenu } from "./stateBox/useTrainMenu";
|
||||
import lineColorList from "./assets/originData/lineColorList";
|
||||
import { stationIDPair } from "./lib/getStationList";
|
||||
import "./components/ActionSheetComponents/sheets";
|
||||
import { rootNavigationRef } from "./lib/rootNavigation";
|
||||
import { positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
|
||||
import { fixedColors } from "./lib/theme/colors";
|
||||
import { useThemeColors } from "./lib/theme";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
|
||||
type RootTabParamList = {
|
||||
positions: undefined;
|
||||
@@ -35,12 +36,46 @@ type TabProps = {
|
||||
|
||||
const Tab = createBottomTabNavigator<RootTabParamList>();
|
||||
|
||||
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 />;
|
||||
};
|
||||
|
||||
export function AppContainer() {
|
||||
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
|
||||
const { selectedLine } = useTrainMenu();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const operationScreenKey = width > height ? "operation-landscape" : "operation-portrait";
|
||||
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
|
||||
const [hasVisitedPositions, setHasVisitedPositions] = React.useState(false);
|
||||
const [currentRootRoute, setCurrentRootRoute] = React.useState<keyof RootTabParamList | null>(null);
|
||||
const [navigationReady, setNavigationReady] = React.useState(false);
|
||||
const startupPrewarmAttemptedRef = React.useRef(false);
|
||||
|
||||
// フェードアニメーション用 (0=通常, 1=追加ウィンドウ青)
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
@@ -64,6 +99,7 @@ export function AppContainer() {
|
||||
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
|
||||
};
|
||||
const { isDark } = useThemeColors();
|
||||
const lastRootNavStateRef = React.useRef("");
|
||||
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
|
||||
const linking = {
|
||||
prefixes: ["jrshikoku://"],
|
||||
@@ -101,6 +137,7 @@ export function AppContainer() {
|
||||
tabBarLabel: label,
|
||||
headerShown: false,
|
||||
gestureEnabled: true,
|
||||
unmountOnBlur: false,
|
||||
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
|
||||
|
||||
},
|
||||
@@ -111,6 +148,49 @@ export function AppContainer() {
|
||||
"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;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
timer = setTimeout(() => {
|
||||
if (cancelled || hasVisitedPositions) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.root",
|
||||
level: "info",
|
||||
message: "positions startup prewarm activated",
|
||||
data: {
|
||||
currentRootRoute,
|
||||
},
|
||||
});
|
||||
setHasVisitedPositions(true);
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task.cancel?.();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
|
||||
|
||||
if (!fontLoaded) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
@@ -123,14 +203,134 @@ export function AppContainer() {
|
||||
ref={rootNavigationRef}
|
||||
linking={linking}
|
||||
theme={isDark ? DarkTheme : DefaultTheme}
|
||||
onReady={() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "nav.root",
|
||||
level: "info",
|
||||
message: "navigation ready",
|
||||
});
|
||||
setNavigationReady(true);
|
||||
}}
|
||||
onStateChange={(state) => {
|
||||
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: "root state change",
|
||||
data: {
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: activeRoute?.name ?? null,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
hasExtra,
|
||||
},
|
||||
});
|
||||
}
|
||||
const rootRouteName = (activeRoute?.name ?? null) as keyof RootTabParamList | null;
|
||||
Sentry.setTag("root_tab", rootRouteName ?? "unknown");
|
||||
Sentry.setContext("root_navigation", {
|
||||
rootIndex: state?.index ?? 0,
|
||||
rootRoute: rootRouteName,
|
||||
nestedIndex: nestedState?.index ?? 0,
|
||||
nestedRoute,
|
||||
hasExtra,
|
||||
operationOrientation: width > height ? "landscape" : "portrait",
|
||||
hasVisitedPositions,
|
||||
});
|
||||
setCurrentRootRoute(rootRouteName);
|
||||
setIsExtraWindowOpen(hasExtra);
|
||||
}}
|
||||
>
|
||||
<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";
|
||||
@@ -138,6 +338,7 @@ 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,
|
||||
@@ -171,8 +372,39 @@ export function AppContainer() {
|
||||
>
|
||||
<Tab.Screen
|
||||
{...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")}
|
||||
component={Top}
|
||||
/>
|
||||
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",
|
||||
});
|
||||
},
|
||||
}}
|
||||
>
|
||||
{() => (
|
||||
<DeferredPositionsRoot
|
||||
shouldActivate={hasVisitedPositions}
|
||||
isRootFocused={currentRootRoute === "positions"}
|
||||
/>
|
||||
)}
|
||||
</Tab.Screen>
|
||||
<Tab.Screen
|
||||
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
|
||||
component={MenuPage}
|
||||
@@ -188,7 +420,7 @@ export function AppContainer() {
|
||||
isInfo
|
||||
)}
|
||||
>
|
||||
{() => <TNDView key={operationScreenKey} />}
|
||||
{() => <TNDView />}
|
||||
</Tab.Screen>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
|
||||
+190
-14
@@ -9,19 +9,120 @@ 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 } = route.params;
|
||||
const {
|
||||
uri,
|
||||
useExitButton = true,
|
||||
importRecordingDownloads = false,
|
||||
} = route.params;
|
||||
const { goBack } = useNavigation();
|
||||
const { fixed } = useThemeColors();
|
||||
const { importRecordingsFromText } = useTrainMenu();
|
||||
const webViewRef = React.useRef<WebView>(null);
|
||||
const [canGoBack, setCanGoBack] = React.useState(false);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = React.useState(
|
||||
@@ -62,12 +163,12 @@ export default ({ route }) => {
|
||||
const applyEnvironment = (value: unknown) => {
|
||||
if (!isMounted) return;
|
||||
const nextEnvironment = normalizeJrDataSystemEnvironment(value);
|
||||
const rawUri = typeof uri === "string" ? uri : "";
|
||||
setSelectedEnvironment(nextEnvironment);
|
||||
setResolvedUri(
|
||||
rewriteJrDataSystemUrl(
|
||||
typeof uri === "string" ? uri : "",
|
||||
nextEnvironment,
|
||||
),
|
||||
importRecordingDownloads
|
||||
? rawUri
|
||||
: rewriteJrDataSystemUrl(rawUri, nextEnvironment),
|
||||
);
|
||||
setIsEnvironmentReady(true);
|
||||
};
|
||||
@@ -79,7 +180,7 @@ export default ({ route }) => {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [uri]);
|
||||
}, [uri, importRecordingDownloads]);
|
||||
|
||||
const handleReload = () => {
|
||||
lastPongAt.current = Date.now();
|
||||
@@ -90,6 +191,44 @@ export default ({ route }) => {
|
||||
setWebViewKey((k) => k + 1);
|
||||
};
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// AppState監視: バックグラウンド10秒超で復帰したらWebViewを再マウント
|
||||
React.useEffect(() => {
|
||||
const onAppStateChange = (nextState: AppStateStatus) => {
|
||||
@@ -130,8 +269,11 @@ export default ({ route }) => {
|
||||
source={{ uri: resolvedUri }}
|
||||
contentMode="mobile"
|
||||
allowsBackForwardNavigationGestures
|
||||
setSupportMultipleWindows={false}
|
||||
ref={webViewRef}
|
||||
injectedJavaScriptBeforeContentLoaded={`true;`}
|
||||
injectedJavaScriptBeforeContentLoaded={
|
||||
importRecordingDownloads ? RECORDING_DOWNLOAD_BRIDGE_SCRIPT : `true;`
|
||||
}
|
||||
onLoadStart={() => {
|
||||
isLoadingRef.current = true;
|
||||
setHasError(false);
|
||||
@@ -173,16 +315,34 @@ export default ({ route }) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rewrittenUrl = rewriteJrDataSystemUrl(
|
||||
request.url,
|
||||
selectedEnvironment,
|
||||
);
|
||||
if (rewrittenUrl !== request.url) {
|
||||
setResolvedUri(rewrittenUrl);
|
||||
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) => {
|
||||
setCanGoBack(navState.canGoBack);
|
||||
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
|
||||
@@ -199,8 +359,24 @@ export default ({ route }) => {
|
||||
}}
|
||||
onMessage={(event) => {
|
||||
const { data } = event.nativeEvent;
|
||||
const parsed = JSON.parse(data);
|
||||
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;
|
||||
|
||||
+79
-3
@@ -18,11 +18,12 @@ import Setting from "@/components/Settings/settings";
|
||||
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
|
||||
import { optionData } from "@/lib/stackOption";
|
||||
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useNavigation, useIsFocused } 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() {
|
||||
@@ -32,8 +33,37 @@ export function MenuPage() {
|
||||
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(() => {
|
||||
AS.getItem(STORAGE_KEYS.START_PAGE)
|
||||
.then((res) => {
|
||||
@@ -59,6 +89,14 @@ export function MenuPage() {
|
||||
|
||||
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);
|
||||
@@ -85,6 +123,16 @@ export function MenuPage() {
|
||||
}, [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();
|
||||
@@ -119,9 +167,37 @@ export function MenuPage() {
|
||||
<Stack.Navigator
|
||||
id={null}
|
||||
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
|
||||
screenListeners={({ navigation: stackNav }) => {
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
return {};
|
||||
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.Screen
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useIsFocused, useNavigation } from "@react-navigation/native";
|
||||
import { useColorScheme } from "react-native";
|
||||
import Apps from "./components/Apps";
|
||||
import TrainBase from "./components/trainbaseview";
|
||||
@@ -13,16 +13,20 @@ import { useCurrentTrain } from "./stateBox/useCurrentTrain";
|
||||
import { useTrainMenu } from "./stateBox/useTrainMenu";
|
||||
import { AS } from "./storageControl";
|
||||
import { news } from "./config/newsUpdate";
|
||||
import { Linking, Platform } from "react-native";
|
||||
import { InteractionManager, Linking, Platform, View } from "react-native";
|
||||
import GeneralWebView from "./GeneralWebView";
|
||||
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
|
||||
import { positionsStackNavRef } from "./lib/rootNavigation";
|
||||
import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
const Stack = createStackNavigator();
|
||||
export const Top = () => {
|
||||
const { webview } = useCurrentTrain();
|
||||
const { navigate, addListener, isFocused } = useNavigation<any>();
|
||||
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 { mapSwitch } = useTrainMenu();
|
||||
@@ -39,16 +43,94 @@ export const Top = () => {
|
||||
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 task = InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.stack",
|
||||
level: "info",
|
||||
message: "positions stack activation committed",
|
||||
data: {
|
||||
reason: isTabFocused ? "first_focus" : "background_prewarm",
|
||||
},
|
||||
});
|
||||
setHasActivatedStack(true);
|
||||
}, isTabFocused ? 0 : 300);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task.cancel?.();
|
||||
};
|
||||
}, [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,
|
||||
},
|
||||
});
|
||||
if (Platform.OS === "web") {
|
||||
Linking.openURL("https://train.jr-shikoku.co.jp/");
|
||||
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
|
||||
return;
|
||||
}
|
||||
if (!isFocused()) return;
|
||||
const stackNav = stackNavRef.current;
|
||||
if (!isTabFocused) return;
|
||||
if (stackNav && stackNav.getState()?.index > 0) {
|
||||
e.preventDefault();
|
||||
stackNav.goBack();
|
||||
@@ -58,20 +140,74 @@ export const Top = () => {
|
||||
navigate("positions", { screen: "trainMenu" });
|
||||
else webview.current?.injectJavaScript(`AccordionClassEvent()`);
|
||||
return;
|
||||
}, [isFocused, navigate, webview]);
|
||||
}, [isTabFocused, 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={({ navigation: stackNav }) => {
|
||||
screenListeners={({ route, navigation: stackNav }) => {
|
||||
stackNavRef.current = stackNav;
|
||||
return {};
|
||||
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.Screen
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"android",
|
||||
"web"
|
||||
],
|
||||
"version": "7.0.0",
|
||||
"version": "7.1.0",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icons/s8600.png",
|
||||
@@ -24,7 +24,7 @@
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"buildNumber": "63",
|
||||
"buildNumber": "65",
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
|
||||
"appleTeamId": "54CRDT797G",
|
||||
@@ -33,6 +33,8 @@
|
||||
},
|
||||
"infoPlist": {
|
||||
"NFCReaderUsageDescription": "To read FeliCa card",
|
||||
"NSPhotoLibraryUsageDescription": "運行情報画像を共有または保存するために写真ライブラリへのアクセスを使用します。",
|
||||
"NSPhotoLibraryAddUsageDescription": "運行情報画像を写真ライブラリに保存するために使用します。",
|
||||
"com.apple.developer.nfc.readersession.felica.systemcodes": [
|
||||
"0003",
|
||||
"FE00"
|
||||
@@ -55,7 +57,7 @@
|
||||
},
|
||||
"android": {
|
||||
"package": "jrshikokuinfo.xprocess.hrkn",
|
||||
"versionCode": 30,
|
||||
"versionCode": 31,
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
@@ -970,13 +972,29 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+265
-36
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { InteractionManager } from "react-native";
|
||||
import {
|
||||
View,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StatusBar,
|
||||
useColorScheme,
|
||||
Button,
|
||||
} from "react-native";
|
||||
import * as Updates from "expo-updates";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -23,9 +25,11 @@ import { NewMenu } from "./Apps/NewMenu";
|
||||
import { MapsButton } from "./Apps/MapsButton";
|
||||
import { ReloadButton } from "./Apps/ReloadButton";
|
||||
import { useStationList } from "../stateBox/useStationList";
|
||||
import { positionsLifecycleRef } from "../lib/rootNavigation";
|
||||
import { FixedPositionBox } from "./Apps/FixedPositionBox";
|
||||
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
|
||||
import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
|
||||
export default function Apps() {
|
||||
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
|
||||
@@ -33,11 +37,15 @@ export default function Apps() {
|
||||
const { navigate } = useNavigation<any>();
|
||||
const { isLandscape } = useDeviceOrientationChange();
|
||||
const { top } = useSafeAreaInsets();
|
||||
const handleLayout = () => {};
|
||||
const { originalStationList } = useStationList();
|
||||
const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu();
|
||||
const isDark = useColorScheme() === "dark";
|
||||
const isFocused = useIsFocused();
|
||||
const [hasActivatedScreen, setHasActivatedScreen] = React.useState(false);
|
||||
const [hasActivatedWebView, setHasActivatedWebView] = React.useState(false);
|
||||
const [hasStableWebViewSession, setHasStableWebViewSession] = React.useState(false);
|
||||
const lastFocusSignatureRef = React.useRef("");
|
||||
const lastLayoutSignatureRef = React.useRef("");
|
||||
|
||||
const lineColor = selectedLine && stationIDPair[selectedLine]
|
||||
? lineColorList[stationIDPair[selectedLine]]
|
||||
@@ -84,20 +92,209 @@ export default function Apps() {
|
||||
}
|
||||
};
|
||||
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
|
||||
|
||||
const resetTransientPositionsSession = React.useCallback(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: hasStableWebViewSession
|
||||
? "positions stable session preserved before leave"
|
||||
: "positions transient session reset before leave",
|
||||
data: {
|
||||
stableWebViewSession: hasStableWebViewSession,
|
||||
},
|
||||
});
|
||||
positionsLifecycleRef.current = {
|
||||
...positionsLifecycleRef.current,
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
};
|
||||
if (hasStableWebViewSession) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
webview.current?.stopLoading?.();
|
||||
} catch {}
|
||||
setHasActivatedWebView(false);
|
||||
setHasActivatedScreen(false);
|
||||
setHasStableWebViewSession(false);
|
||||
}, [hasStableWebViewSession, webview]);
|
||||
|
||||
React.useEffect(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen mounted",
|
||||
});
|
||||
return () => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen unmounted",
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isFocused) {
|
||||
if (!hasActivatedWebView && !hasStableWebViewSession) {
|
||||
// allow one hidden startup prewarm so the positions session can begin before first tab focus
|
||||
} else {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions webview preserved on blur",
|
||||
data: {
|
||||
stableWebViewSession: hasStableWebViewSession,
|
||||
activatedWebView: hasActivatedWebView,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (hasActivatedScreen && hasActivatedWebView) return;
|
||||
const activationReason = isFocused
|
||||
? (hasActivatedScreen ? "focus" : "first_focus")
|
||||
: "background_prewarm";
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation scheduled",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
let cancelled = false;
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(() => {
|
||||
if (cancelled) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation cancelled before webview mount",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions screen activation committed",
|
||||
data: {
|
||||
reason: activationReason,
|
||||
},
|
||||
});
|
||||
setHasActivatedScreen(true);
|
||||
setHasActivatedWebView(true);
|
||||
}, isFocused ? 250 : 700);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task.cancel?.();
|
||||
};
|
||||
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, isFocused]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const shouldGuardTabExit = isFocused && hasActivatedWebView && !hasStableWebViewSession;
|
||||
positionsLifecycleRef.current = {
|
||||
isUnstable: shouldGuardTabExit,
|
||||
blockTabExit: shouldGuardTabExit,
|
||||
resetBeforeLeave: resetTransientPositionsSession,
|
||||
deactivateStack: positionsLifecycleRef.current.deactivateStack,
|
||||
};
|
||||
return () => {
|
||||
positionsLifecycleRef.current = {
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
resetBeforeLeave: null,
|
||||
deactivateStack: positionsLifecycleRef.current.deactivateStack,
|
||||
};
|
||||
};
|
||||
}, [hasActivatedWebView, hasStableWebViewSession, isFocused, resetTransientPositionsSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
Sentry.setContext("positions_screen", {
|
||||
focused: isFocused,
|
||||
activatedScreen: hasActivatedScreen,
|
||||
activatedWebView: hasActivatedWebView,
|
||||
stableWebViewSession: hasStableWebViewSession,
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
trainNum: trainInfo?.trainNum ?? null,
|
||||
landscape: isLandscape,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, height, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum, width]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const signature = JSON.stringify({
|
||||
focused: isFocused,
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
trainNum: trainInfo?.trainNum ?? null,
|
||||
landscape: isLandscape,
|
||||
});
|
||||
if (lastFocusSignatureRef.current === signature) return;
|
||||
lastFocusSignatureRef.current = signature;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: isFocused ? "positions focused" : "positions blurred",
|
||||
data: {
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
trainNum: trainInfo?.trainNum ?? null,
|
||||
landscape: isLandscape,
|
||||
},
|
||||
});
|
||||
}, [hasActivatedWebView, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum]);
|
||||
|
||||
const handleLayout = React.useCallback((event) => {
|
||||
const layout = event?.nativeEvent?.layout;
|
||||
if (!layout) return;
|
||||
const signature = [Math.round(layout.width), Math.round(layout.height), isLandscape ? "landscape" : "portrait"].join(":");
|
||||
if (lastLayoutSignatureRef.current === signature) return;
|
||||
lastLayoutSignatureRef.current = signature;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions layout",
|
||||
data: {
|
||||
width: Math.round(layout.width),
|
||||
height: Math.round(layout.height),
|
||||
landscape: isLandscape,
|
||||
},
|
||||
});
|
||||
}, [isLandscape]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: bgColor }}>
|
||||
{isFocused && mapSwitch === "true" && lineColor && lineColorDark && (
|
||||
<LinearGradient
|
||||
colors={[lineColorDark, lineColor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
|
||||
/>
|
||||
)}
|
||||
{isFocused && mapSwitch !== "true" && (
|
||||
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: top, backgroundColor: "#0099CC" }} />
|
||||
)}
|
||||
{isFocused && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
|
||||
>
|
||||
{mapSwitch === "true" && lineColor && lineColorDark ? (
|
||||
<LinearGradient
|
||||
colors={[lineColorDark, lineColor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#0099CC",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{isFocused && hasActivatedScreen && (
|
||||
<StatusBar barStyle="light-content" />
|
||||
)}
|
||||
<View
|
||||
@@ -108,32 +305,64 @@ export default function Apps() {
|
||||
}}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
<AppsWebView
|
||||
{...{
|
||||
openStationACFromEachTrainInfo,
|
||||
}}
|
||||
/>
|
||||
{hasActivatedScreen ? (
|
||||
<>
|
||||
{hasActivatedWebView ? (
|
||||
<AppsWebView
|
||||
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
|
||||
onInitialLoadReady={() => {
|
||||
if (hasStableWebViewSession) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "positions webview became stable",
|
||||
});
|
||||
positionsLifecycleRef.current = {
|
||||
...positionsLifecycleRef.current,
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
};
|
||||
setHasStableWebViewSession(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ flex: 1 }} />
|
||||
)}
|
||||
|
||||
<MapsButton
|
||||
onPress={() => {
|
||||
navigate("trainMenu", { webview });
|
||||
}}
|
||||
/>
|
||||
{isFocused && (
|
||||
<MapsButton
|
||||
onPress={() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.screen",
|
||||
level: "info",
|
||||
message: "maps button press",
|
||||
data: {
|
||||
mapSwitch,
|
||||
selectedLine: selectedLine ?? null,
|
||||
hasActivatedWebView,
|
||||
},
|
||||
});
|
||||
navigate("trainMenu", { webview });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fixedPosition.type && <FixedPositionBox />}
|
||||
<PlaybackTimeline />
|
||||
<RecordingStatusBar />
|
||||
|
||||
{mapSwitch == "true" ? (
|
||||
<>
|
||||
<ReloadButton
|
||||
onPress={() => Updates.reloadAsync()}
|
||||
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<NewMenu />
|
||||
)}
|
||||
{isFocused && fixedPosition.type && <FixedPositionBox />}
|
||||
{isFocused && <PlaybackTimeline />}
|
||||
{isFocused && <RecordingStatusBar />}
|
||||
{isFocused &&
|
||||
(mapSwitch == "true" ? (
|
||||
<ReloadButton
|
||||
onPress={() => Updates.reloadAsync()}
|
||||
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
|
||||
/>
|
||||
) : (
|
||||
<NewMenu />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<View style={{ flex: 1 }} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
+155
-13
@@ -15,15 +15,17 @@ import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
|
||||
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useNavigation, useIsFocused } from "@react-navigation/native";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
import { useStationList } from "../../stateBox/useStationList";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useWebViewRemount } from "@/lib/useWebViewRemount";
|
||||
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
|
||||
export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
|
||||
const { webview, currentTrain } = useCurrentTrain();
|
||||
const { navigate } = useNavigation<any>();
|
||||
const isFocused = useIsFocused();
|
||||
const { favoriteStation } = useFavoriteStation();
|
||||
const { isLandscape } = useDeviceOrientationChange();
|
||||
const { isDark } = useThemeColors();
|
||||
@@ -40,27 +42,98 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
mockApiFeatureEnabled,
|
||||
mockTrainPositions,
|
||||
} = useTrainMenu();
|
||||
const { remountKey, remount, processHandlers } = useWebViewRemount();
|
||||
const addWebViewBreadcrumb = (
|
||||
message: string,
|
||||
data?: Record<string, string | number | boolean | null | undefined>
|
||||
) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "positions.webview",
|
||||
level: "info",
|
||||
message,
|
||||
data,
|
||||
});
|
||||
};
|
||||
const { remountKey, processHandlers } = useWebViewRemount({
|
||||
isFocused,
|
||||
pauseWatchdogWhenUnfocused: Platform.OS === "ios",
|
||||
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
|
||||
onRemount: (reason, data) => {
|
||||
addWebViewBreadcrumb("webview remount requested", {
|
||||
reason,
|
||||
...(data ?? {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
|
||||
|
||||
// コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画
|
||||
const mountedRef = useRef(false);
|
||||
const urlCacheRef = useRef("");
|
||||
const initialInjectDoneRef = useRef(false);
|
||||
const loadEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const focusedRef = useRef(isFocused);
|
||||
const initialLoadReadyNotifiedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
focusedRef.current = isFocused;
|
||||
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
|
||||
landscape: isLandscape,
|
||||
mockApi: mockApiFeatureEnabled,
|
||||
});
|
||||
}, [isFocused, isLandscape, mockApiFeatureEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastRemountKeyRef.current !== remountKey) {
|
||||
addWebViewBreadcrumb("webview remount key", { remountKey });
|
||||
lastRemountKeyRef.current = remountKey;
|
||||
}
|
||||
}, [remountKey]);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.setContext("positions_webview", {
|
||||
focused: isFocused,
|
||||
landscape: isLandscape,
|
||||
mockApi: mockApiFeatureEnabled,
|
||||
remountKey,
|
||||
currentUrl: urlCacheRef.current || null,
|
||||
});
|
||||
}, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
addWebViewBreadcrumb("webview cleanup start", {
|
||||
focused: focusedRef.current,
|
||||
currentUrl: urlCacheRef.current || null,
|
||||
});
|
||||
if (loadEndTimeoutRef.current) {
|
||||
clearTimeout(loadEndTimeoutRef.current);
|
||||
loadEndTimeoutRef.current = null;
|
||||
}
|
||||
addWebViewBreadcrumb("webview unmounted");
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
// 初回マウント時はスキップ(beforeContentLoaded で既に正しいデータが入っている)
|
||||
if (!mountedRef.current) {
|
||||
mountedRef.current = true;
|
||||
addWebViewBreadcrumb("mock positions effect skipped initial mount", {
|
||||
mockApi: mockApiFeatureEnabled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!mockApiFeatureEnabled || !mockTrainPositions) return;
|
||||
addWebViewBreadcrumb("mock positions injected", {
|
||||
positionCount: mockTrainPositions.length,
|
||||
});
|
||||
const script = generateMockUpdateScript(mockTrainPositions);
|
||||
webview?.current?.injectJavaScript(script);
|
||||
}, [mockTrainPositions]);
|
||||
var urlcache = "";
|
||||
let once = false;
|
||||
}, [mockApiFeatureEnabled, mockTrainPositions, webview]);
|
||||
|
||||
const onNavigationStateChange = ({ url }) => {
|
||||
if (url == urlcache) return;
|
||||
if (url == urlCacheRef.current) return;
|
||||
//URL二重判定回避
|
||||
urlcache = url;
|
||||
urlCacheRef.current = url;
|
||||
addWebViewBreadcrumb("webview navigation", { url });
|
||||
switch (true) {
|
||||
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
|
||||
if (Platform.OS === "android") navigate("howto", { info: url });
|
||||
@@ -82,14 +155,27 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
* {type,event,id,name,pdf,map,url,chk}
|
||||
*/
|
||||
if (data.includes("train.html")) {
|
||||
addWebViewBreadcrumb("webview train link", { data });
|
||||
navigate("trainbase", { info: data, from: "Train" });
|
||||
return;
|
||||
}
|
||||
if (!originalStationList) {
|
||||
addWebViewBreadcrumb("webview message blocked waiting station data");
|
||||
alert("駅名標データを取得中...");
|
||||
return;
|
||||
}
|
||||
const dataSet = JSON.parse(data);
|
||||
let dataSet;
|
||||
try {
|
||||
dataSet = JSON.parse(data);
|
||||
} catch (error) {
|
||||
addWebViewBreadcrumb("webview message parse failed", {
|
||||
dataPreview: data.slice(0, 120),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
addWebViewBreadcrumb("webview message", {
|
||||
type: dataSet.type ?? "unknown",
|
||||
});
|
||||
switch (dataSet.type) {
|
||||
case "LoadError": {
|
||||
setLoadError(true);
|
||||
@@ -153,8 +239,28 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadStart = () => {
|
||||
addWebViewBreadcrumb("webview loadStart", {
|
||||
focused: focusedRef.current,
|
||||
currentUrl: urlCacheRef.current || null,
|
||||
});
|
||||
};
|
||||
|
||||
const onLoadEnd = () => {
|
||||
if (once) return () => {};
|
||||
addWebViewBreadcrumb("webview loadEnd", {
|
||||
focused: focusedRef.current,
|
||||
hasStationData: !!stationData,
|
||||
hasOriginalStationList: !!originalStationList,
|
||||
favoriteCount: favoriteStation.length,
|
||||
});
|
||||
if (!initialLoadReadyNotifiedRef.current) {
|
||||
initialLoadReadyNotifiedRef.current = true;
|
||||
addWebViewBreadcrumb("webview initial load ready", {
|
||||
focused: focusedRef.current,
|
||||
});
|
||||
onInitialLoadReady?.();
|
||||
}
|
||||
if (initialInjectDoneRef.current) return () => {};
|
||||
if (!stationData) return () => {};
|
||||
if (!originalStationList) return () => {};
|
||||
if (favoriteStation.length < 1) return () => {};
|
||||
@@ -162,10 +268,33 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
favoriteStation[0][0].StationNumber
|
||||
);
|
||||
if (!string) return () => {};
|
||||
setTimeout(() => {
|
||||
if (loadEndTimeoutRef.current) {
|
||||
clearTimeout(loadEndTimeoutRef.current);
|
||||
}
|
||||
addWebViewBreadcrumb("webview initial inject scheduled");
|
||||
loadEndTimeoutRef.current = setTimeout(() => {
|
||||
if (!focusedRef.current) {
|
||||
addWebViewBreadcrumb("webview initial inject skipped while blurred");
|
||||
loadEndTimeoutRef.current = null;
|
||||
return;
|
||||
}
|
||||
addWebViewBreadcrumb("webview initial inject run");
|
||||
webview?.current?.injectJavaScript(string);
|
||||
loadEndTimeoutRef.current = null;
|
||||
}, 500);
|
||||
once = true;
|
||||
initialInjectDoneRef.current = true;
|
||||
};
|
||||
|
||||
const handleRenderProcessGone = (event) => {
|
||||
addWebViewBreadcrumb("webview render process gone", {
|
||||
didCrash: event.nativeEvent.didCrash,
|
||||
});
|
||||
processHandlers.onRenderProcessGone?.(event);
|
||||
};
|
||||
|
||||
const handleContentProcessDidTerminate = () => {
|
||||
addWebViewBreadcrumb("webview content process terminated");
|
||||
processHandlers.onContentProcessDidTerminate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -183,8 +312,21 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
|
||||
setSupportMultipleWindows
|
||||
contentMode="mobile"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
onLoadStart={onLoadStart}
|
||||
onError={(event) => {
|
||||
addWebViewBreadcrumb("webview error", {
|
||||
description: event.nativeEvent.description || null,
|
||||
code: event.nativeEvent.code ?? null,
|
||||
});
|
||||
}}
|
||||
onHttpError={(event) => {
|
||||
addWebViewBreadcrumb("webview http error", {
|
||||
statusCode: event.nativeEvent.statusCode ?? null,
|
||||
});
|
||||
}}
|
||||
onRenderProcessGone={handleRenderProcessGone}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
|
||||
{...processHandlers}
|
||||
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
|
||||
injectedJavaScript={injectJavascript}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
ScrollView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
@@ -27,6 +28,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSortMode } from "./useSortMode";
|
||||
import { StationSource } from "@/types";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
|
||||
export const CarouselBox = ({
|
||||
originalStationList,
|
||||
@@ -45,7 +47,11 @@ export const CarouselBox = ({
|
||||
navigate: any;
|
||||
stationSource: StationSource;
|
||||
}) => {
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
|
||||
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
|
||||
const carouselRef = useRef<ICarouselInstance>(null);
|
||||
const androidCarouselScrollRef = useRef<ScrollView>(null);
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { fontScale } = useResponsive();
|
||||
@@ -109,6 +115,7 @@ export const CarouselBox = ({
|
||||
// コンテナ高さ(カルーセル ↔ グリッドで可変)
|
||||
const containerHeight = useSharedValue(carouselHeight);
|
||||
const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value }));
|
||||
const androidContainerStyle = isAndroid ? { height: isSortMode ? gridHeight : carouselHeight } : null;
|
||||
|
||||
// ドットエリアのフェード
|
||||
const dotsOpacity = useSharedValue(1);
|
||||
@@ -122,6 +129,11 @@ export const CarouselBox = ({
|
||||
const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value }));
|
||||
|
||||
useEffect(() => {
|
||||
if (isAndroid) {
|
||||
setIsGridMounted(isSortMode);
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = 250;
|
||||
if (isSortMode) {
|
||||
setIsGridMounted(true); // フェードイン前にマウント
|
||||
@@ -137,7 +149,7 @@ export const CarouselBox = ({
|
||||
if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント
|
||||
});
|
||||
}
|
||||
}, [isSortMode, gridHeight, carouselHeight]);
|
||||
}, [isAndroid, isSortMode, gridHeight, carouselHeight]);
|
||||
|
||||
// ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ)
|
||||
const justExitedSortRef = useRef(false);
|
||||
@@ -150,12 +162,18 @@ export const CarouselBox = ({
|
||||
|
||||
// バッジからのインデックス変更をカルーセルに反映
|
||||
useEffect(() => {
|
||||
if (listIndex >= 0 && carouselRef.current) {
|
||||
const animated = !justExitedSortRef.current;
|
||||
justExitedSortRef.current = false;
|
||||
carouselRef.current.scrollTo({ index: listIndex, animated });
|
||||
if (listIndex < 0) return;
|
||||
const animated = !justExitedSortRef.current;
|
||||
justExitedSortRef.current = false;
|
||||
if (Platform.OS === "android") {
|
||||
androidCarouselScrollRef.current?.scrollTo({
|
||||
x: width * listIndex,
|
||||
animated,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [listIndex]);
|
||||
carouselRef.current?.scrollTo({ index: listIndex, animated });
|
||||
}, [listIndex, width]);
|
||||
|
||||
// ドットのスクロール追従
|
||||
useEffect(() => {
|
||||
@@ -167,9 +185,35 @@ export const CarouselBox = ({
|
||||
|
||||
// ドット表示設定の読み込み
|
||||
useEffect(() => {
|
||||
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
|
||||
setDotButton(data === "true");
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings read scheduled",
|
||||
});
|
||||
AS.getItem("CarouselSettings/activeDotSettings")
|
||||
.then((data) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings read success",
|
||||
data: {
|
||||
rawValue: data == null ? "<null>" : String(data),
|
||||
resolved: data === "true",
|
||||
},
|
||||
});
|
||||
setDotButton(data === "true");
|
||||
})
|
||||
.catch((error) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "error",
|
||||
message: "carousel activeDotSettings read failed",
|
||||
data: {
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const oPSign = () => {
|
||||
@@ -199,7 +243,38 @@ export const CarouselBox = ({
|
||||
duration: 600,
|
||||
update: { type: "spring", springDamping: 0.5 },
|
||||
});
|
||||
AS.setItem("CarouselSettings/activeDotSettings", !dotButton ? "true" : "false");
|
||||
const nextValue = !dotButton ? "true" : "false";
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings write scheduled",
|
||||
data: {
|
||||
nextValue,
|
||||
},
|
||||
});
|
||||
void AS.setItem("CarouselSettings/activeDotSettings", nextValue)
|
||||
.then(() => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "info",
|
||||
message: "carousel activeDotSettings write success",
|
||||
data: {
|
||||
nextValue,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
Sentry.addBreadcrumb({
|
||||
category: "menu.carousel",
|
||||
level: "error",
|
||||
message: "carousel activeDotSettings write failed",
|
||||
data: {
|
||||
nextValue,
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
});
|
||||
setDotButton(!dotButton);
|
||||
};
|
||||
|
||||
@@ -255,37 +330,59 @@ export const CarouselBox = ({
|
||||
return (
|
||||
<View style={{ flex: 1, paddingTop: 10 }}>
|
||||
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
|
||||
<Animated.View style={[{ overflow: "visible" }, containerHeightStyle]}>
|
||||
<Animated.View style={[{ overflow: "visible" }, isAndroid ? androidContainerStyle : containerHeightStyle]}>
|
||||
{/* カルーセル */}
|
||||
<Animated.View
|
||||
style={[{ position: "absolute", width }, carouselAnimStyle]}
|
||||
style={[{ position: "absolute", width }, isAndroid ? { opacity: isSortMode ? 0 : 1 } : carouselAnimStyle]}
|
||||
pointerEvents={isSortMode ? "none" : "auto"}
|
||||
>
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
|
||||
height={carouselHeight}
|
||||
pagingEnabled={true}
|
||||
snapEnabled={true}
|
||||
loop={false}
|
||||
width={width}
|
||||
style={{ width, alignContent: "center" }}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxAdjacentItemScale: 0.8,
|
||||
}}
|
||||
scrollAnimationDuration={600}
|
||||
onSnapToItem={setListIndex}
|
||||
renderItem={RenderItem}
|
||||
overscrollEnabled={false}
|
||||
defaultIndex={
|
||||
lastValidListIndexRef.current >= listUpStation.length
|
||||
? 0
|
||||
: lastValidListIndexRef.current
|
||||
}
|
||||
/>
|
||||
{Platform.OS === "android" ? (
|
||||
<ScrollView
|
||||
ref={androidCarouselScrollRef}
|
||||
horizontal
|
||||
pagingEnabled
|
||||
showsHorizontalScrollIndicator={false}
|
||||
overScrollMode="never"
|
||||
onMomentumScrollEnd={({ nativeEvent }) => {
|
||||
const nextIndex = Math.round(nativeEvent.contentOffset.x / width);
|
||||
if (nextIndex !== listIndex) {
|
||||
setListIndex(nextIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]).map((item, index) => (
|
||||
<View key={item[0].StationNumber ?? item[0].Station_JP ?? index} style={{ width }}>
|
||||
<RenderItem item={item} />
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<Carousel
|
||||
ref={carouselRef}
|
||||
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
|
||||
height={carouselHeight}
|
||||
pagingEnabled={true}
|
||||
snapEnabled={true}
|
||||
loop={false}
|
||||
width={width}
|
||||
style={{ width, alignContent: "center" }}
|
||||
mode="parallax"
|
||||
modeConfig={{
|
||||
parallaxScrollingScale: 1,
|
||||
parallaxScrollingOffset: 100,
|
||||
parallaxAdjacentItemScale: 0.8,
|
||||
}}
|
||||
scrollAnimationDuration={600}
|
||||
onSnapToItem={setListIndex}
|
||||
renderItem={RenderItem}
|
||||
overscrollEnabled={false}
|
||||
defaultIndex={
|
||||
lastValidListIndexRef.current >= listUpStation.length
|
||||
? 0
|
||||
: lastValidListIndexRef.current
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* グリッド:ソートモード中のみマウント */}
|
||||
@@ -293,7 +390,7 @@ export const CarouselBox = ({
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" },
|
||||
gridAnimStyle,
|
||||
isAndroid ? { opacity: 1 } : gridAnimStyle,
|
||||
]}
|
||||
>
|
||||
<Sortable.Grid
|
||||
@@ -311,7 +408,7 @@ export const CarouselBox = ({
|
||||
</Animated.View>
|
||||
|
||||
{/* ドットエリア:ソートモード時はフェードアウト */}
|
||||
<Animated.View style={dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
|
||||
<Animated.View style={isAndroid ? { opacity: isSortMode ? 0 : 1 } : dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -355,8 +452,8 @@ export const CarouselBox = ({
|
||||
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
|
||||
{isSortMode && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
entering={sortControlsEntering}
|
||||
exiting={sortControlsExiting}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Alert,
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
import { Switch } from "@rneui/themed";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import Swipeable from "react-native-gesture-handler/Swipeable";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { AS } from "../../storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
@@ -278,19 +276,7 @@ const ELESITE_FEATURES: Feature[] = [
|
||||
/* ------------------------------------------------------------------ */
|
||||
export const DataSourceSettings = () => {
|
||||
const navigation = useNavigation();
|
||||
const {
|
||||
updatePermission,
|
||||
mockApiFeatureEnabled,
|
||||
setMockApiFeatureEnabled,
|
||||
recorderState,
|
||||
recordingSnapshotCount,
|
||||
recordingList,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
startPlayback,
|
||||
stopPlayback,
|
||||
deleteRecording,
|
||||
} = useTrainMenu();
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const showDebugSelector = __DEV__ || updatePermission;
|
||||
const [useUnyohub, setUseUnyohub] = useState(false);
|
||||
@@ -304,8 +290,6 @@ export const DataSourceSettings = () => {
|
||||
useState<JrDataSystemUiVariant>(
|
||||
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
|
||||
);
|
||||
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
|
||||
|
||||
const applyJrDataSystemEnv = (env: JrDataSystemEnvironmentKey) => {
|
||||
setJrDataSystemEnv(env);
|
||||
setJrDataSystemTrack(getJrDataSystemTrack(env));
|
||||
@@ -361,28 +345,6 @@ export const DataSourceSettings = () => {
|
||||
applyJrDataSystemEnv(env);
|
||||
};
|
||||
|
||||
const closeRecordingSwipe = (id: string) => {
|
||||
recordingSwipeRefs.current[id]?.close();
|
||||
};
|
||||
|
||||
const confirmDeleteRecording = (id: string, label: string) => {
|
||||
closeRecordingSwipe(id);
|
||||
Alert.alert(
|
||||
"録画を削除",
|
||||
`${label} の録画を削除しますか?`,
|
||||
[
|
||||
{ text: "キャンセル", style: "cancel" },
|
||||
{
|
||||
text: "削除",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
void deleteRecording(id);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
|
||||
<SheetHeaderItem
|
||||
@@ -451,224 +413,6 @@ export const DataSourceSettings = () => {
|
||||
|
||||
{showDebugSelector && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
|
||||
デバッグ: モックAPI検証
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.debugDescription,
|
||||
{ color: colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
公式サイトの代わりにサンプルデータを流し込みます。
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.debugCurrentText,
|
||||
{ color: colors.textPrimary, fontSize: 14 },
|
||||
]}
|
||||
>
|
||||
モックAPI検証機能
|
||||
</Text>
|
||||
<Switch
|
||||
value={mockApiFeatureEnabled}
|
||||
onValueChange={setMockApiFeatureEnabled}
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 録画・再生 */}
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.borderSecondary },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
|
||||
デバッグ: 走行位置録画
|
||||
</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>
|
||||
ライブデータを録画してモックとして再生します。録画中はモックOFFになります。
|
||||
</Text>
|
||||
|
||||
{/* ステータス行 */}
|
||||
<View style={{ flexDirection: "row", alignItems: "center", marginTop: 8, gap: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 10, height: 10, borderRadius: 5,
|
||||
backgroundColor:
|
||||
recorderState === 'recording' ? '#e53935' :
|
||||
recorderState === 'playing' ? '#43a047' : colors.borderSecondary,
|
||||
}}
|
||||
/>
|
||||
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
|
||||
{recorderState === 'recording'
|
||||
? `録画中… ${recordingSnapshotCount} スナップショット`
|
||||
: recorderState === 'playing'
|
||||
? '再生中'
|
||||
: `${recordingList.length} 件の録画`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 録画開始 / 停止ボタン */}
|
||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
|
||||
{recorderState === 'idle' && (
|
||||
<TouchableOpacity
|
||||
onPress={startRecording}
|
||||
style={{
|
||||
backgroundColor: '#e53935', borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 13 }}>● 録画開始</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === 'recording' && (
|
||||
<TouchableOpacity
|
||||
onPress={stopRecording}
|
||||
style={{
|
||||
backgroundColor: colors.borderSecondary, borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}>■ 録画停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === 'playing' && (
|
||||
<TouchableOpacity
|
||||
onPress={stopPlayback}
|
||||
style={{
|
||||
backgroundColor: colors.borderSecondary, borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}>■ 再生停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 録画一覧 */}
|
||||
{recordingList.length > 0 && recorderState !== 'recording' && (
|
||||
<View style={{ marginTop: 10, gap: 6 }}>
|
||||
{recordingList.map((rec) => {
|
||||
const isPlaying = recorderState === 'playing';
|
||||
const durationSec = Math.round(rec.durationMs / 1000);
|
||||
const durationLabel = durationSec >= 60
|
||||
? `${Math.floor(durationSec / 60)}分${durationSec % 60}秒`
|
||||
: `${durationSec}秒`;
|
||||
const dateLabel = new Date(rec.recordedAt).toLocaleString('ja-JP', {
|
||||
month: 'numeric', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
const recordingRow = (
|
||||
<TouchableOpacity
|
||||
onPress={() => startPlayback(rec.id)}
|
||||
disabled={isPlaying}
|
||||
activeOpacity={0.72}
|
||||
style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 8, padding: 12, gap: 10,
|
||||
opacity: isPlaying ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: 'bold' }}>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>
|
||||
{rec.snapshotCount} コマ / {durationLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end', gap: 4 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||
<MaterialCommunityIcons
|
||||
name={isPlaying ? 'pause-circle-outline' : 'play-circle-outline'}
|
||||
size={18}
|
||||
color={isPlaying ? colors.textTertiary : '#43a047'}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isPlaying ? colors.textTertiary : colors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{isPlaying ? '再生中は操作不可' : 'タップで再生'}
|
||||
</Text>
|
||||
</View>
|
||||
{!isPlaying && (
|
||||
<Text style={{ color: colors.textTertiary, fontSize: 10 }}>
|
||||
左へスワイプで削除
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={18}
|
||||
color={colors.iconSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (isPlaying) {
|
||||
return <View key={rec.id}>{recordingRow}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
key={rec.id}
|
||||
ref={(instance) => {
|
||||
recordingSwipeRefs.current[rec.id] = instance;
|
||||
}}
|
||||
friction={2}
|
||||
overshootRight={false}
|
||||
rightThreshold={48}
|
||||
renderRightActions={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 96,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#e53935',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 6,
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
|
||||
<Text style={{ color: '#fff', fontSize: 11, fontWeight: 'bold', marginTop: 4 }}>
|
||||
削除
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
|
||||
>
|
||||
{recordingRow}
|
||||
</Swipeable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Switch } from "@rneui/themed";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import Swipeable from "react-native-gesture-handler/Swipeable";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useNotification } from "@/stateBox/useNotifications";
|
||||
|
||||
export const ResearchToolsSettings = () => {
|
||||
const navigation = useNavigation<any>();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { expoPushToken } = useNotification();
|
||||
const {
|
||||
updatePermission,
|
||||
mockApiFeatureEnabled,
|
||||
setMockApiFeatureEnabled,
|
||||
recorderState,
|
||||
recordingSnapshotCount,
|
||||
recordingList,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
startPlayback,
|
||||
stopPlayback,
|
||||
deleteRecording,
|
||||
exportRecordingFile,
|
||||
exportAllRecordingsFile,
|
||||
importRecordingFile,
|
||||
} = useTrainMenu();
|
||||
const showResearchTools = __DEV__ || updatePermission;
|
||||
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
|
||||
const [recordingFileStatus, setRecordingFileStatus] = useState<{
|
||||
type: "info" | "success" | "error";
|
||||
text: string;
|
||||
}>({ type: "info", text: "録画JSONの書き出しと読み込みができます。" });
|
||||
|
||||
const closeRecordingSwipe = (id: string) => {
|
||||
recordingSwipeRefs.current[id]?.close();
|
||||
};
|
||||
|
||||
const confirmDeleteRecording = (id: string, label: string) => {
|
||||
closeRecordingSwipe(id);
|
||||
Alert.alert("録画を削除", `${label} の録画を削除しますか?`, [
|
||||
{ text: "キャンセル", style: "cancel" },
|
||||
{
|
||||
text: "削除",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
void deleteRecording(id);
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const setFileStatus = (type: "info" | "success" | "error", text: string) => {
|
||||
setRecordingFileStatus({ type, text });
|
||||
};
|
||||
|
||||
const handleExportRecordingFile = async (id: string, label: string) => {
|
||||
try {
|
||||
await exportRecordingFile(id);
|
||||
setFileStatus("success", `${label} の録画JSONを書き出しました。`);
|
||||
} catch (error) {
|
||||
setFileStatus(
|
||||
"error",
|
||||
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAllRecordingsFile = async () => {
|
||||
try {
|
||||
await exportAllRecordingsFile();
|
||||
setFileStatus("success", `${recordingList.length}件の録画JSONを書き出しました。`);
|
||||
} catch (error) {
|
||||
setFileStatus(
|
||||
"error",
|
||||
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportRecordingFile = async () => {
|
||||
try {
|
||||
const result = await importRecordingFile();
|
||||
if (!result) {
|
||||
setFileStatus("info", "録画JSONの読み込みをキャンセルしました。");
|
||||
return;
|
||||
}
|
||||
setFileStatus(
|
||||
"success",
|
||||
result.overwrittenCount > 0
|
||||
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
|
||||
: `${result.importedCount}件を読み込みました。`,
|
||||
);
|
||||
} catch (error) {
|
||||
setFileStatus(
|
||||
"error",
|
||||
`録画JSONを読み込めませんでした: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openRecordingDatabase = () => {
|
||||
const uri = `https://experimental.shikoku-railinfo.haruk.in/position-board?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
|
||||
const params = {
|
||||
uri,
|
||||
importRecordingDownloads: true,
|
||||
useExitButton: false,
|
||||
};
|
||||
const parentNavigation = navigation.getParent?.();
|
||||
if (parentNavigation) {
|
||||
parentNavigation.navigate("generalWebView", params);
|
||||
return;
|
||||
}
|
||||
navigation.navigate("generalWebView", params);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
|
||||
<SheetHeaderItem
|
||||
title="調査ツール"
|
||||
LeftItem={{
|
||||
title: "< 戻る",
|
||||
onPress: () => navigation.goBack(),
|
||||
position: "left",
|
||||
}}
|
||||
/>
|
||||
<ScrollView
|
||||
style={[styles.content, { backgroundColor: colors.backgroundSecondary }]}
|
||||
contentContainerStyle={styles.contentInner}
|
||||
>
|
||||
{!showResearchTools ? (
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>利用できる調査ツールはありません</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>このページは開発・管理向けの調査機能を配置しています。</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: モックAPI検証</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>公式サイトの代わりにサンプルデータを流し込みます。</Text>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={[styles.debugCurrentText, { color: colors.textPrimary, fontSize: 14 }]}>モックAPI検証機能</Text>
|
||||
<Switch
|
||||
value={mockApiFeatureEnabled}
|
||||
onValueChange={setMockApiFeatureEnabled}
|
||||
color={fixed.primary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.debugSection,
|
||||
{
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.borderSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: 走行位置録画</Text>
|
||||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>ライブデータを録画してモックとして再生します。録画中はモックOFFになります。</Text>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor:
|
||||
recorderState === "recording"
|
||||
? "#e53935"
|
||||
: recorderState === "playing"
|
||||
? "#43a047"
|
||||
: colors.borderSecondary,
|
||||
}}
|
||||
/>
|
||||
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
|
||||
{recorderState === "recording"
|
||||
? `録画中… ${recordingSnapshotCount} スナップショット`
|
||||
: recorderState === "playing"
|
||||
? "再生中"
|
||||
: `${recordingList.length} 件の録画`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
{recorderState === "idle" && (
|
||||
<TouchableOpacity onPress={startRecording} style={styles.recordButton}>
|
||||
<Text style={styles.primaryButtonText}>● 録画開始</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === "recording" && (
|
||||
<TouchableOpacity onPress={stopRecording} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
|
||||
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}>■ 録画停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{recorderState === "playing" && (
|
||||
<TouchableOpacity onPress={stopPlayback} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
|
||||
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}>■ 再生停止</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.fileBox, { backgroundColor: colors.backgroundTertiary }]}>
|
||||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "600" }}>録画JSONファイル</Text>
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
void handleImportRecordingFile();
|
||||
}}
|
||||
disabled={recorderState === "recording"}
|
||||
style={{
|
||||
backgroundColor: recorderState === "recording" ? colors.borderSecondary : fixed.primary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
opacity: recorderState === "recording" ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: 12 }}>JSONを読み込む</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
void handleExportAllRecordingsFile();
|
||||
}}
|
||||
disabled={recordingList.length === 0 || recorderState === "recording"}
|
||||
style={{
|
||||
backgroundColor:
|
||||
recordingList.length === 0 || recorderState === "recording"
|
||||
? colors.borderSecondary
|
||||
: colors.surface,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSecondary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
opacity: recordingList.length === 0 || recorderState === "recording" ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.textPrimary, fontWeight: "bold", fontSize: 12 }}>全件を書き出す</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor:
|
||||
recordingFileStatus.type === "success"
|
||||
? "#43a04722"
|
||||
: recordingFileStatus.type === "error"
|
||||
? "#e5393522"
|
||||
: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor:
|
||||
recordingFileStatus.type === "success"
|
||||
? "#43a04755"
|
||||
: recordingFileStatus.type === "error"
|
||||
? "#e5393555"
|
||||
: colors.borderSecondary,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 12, lineHeight: 18 }}>{recordingFileStatus.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{recordingList.length > 0 && recorderState !== "recording" && (
|
||||
<View style={styles.recordingList}>
|
||||
{recordingList.map((rec) => {
|
||||
const isPlaying = recorderState === "playing";
|
||||
const durationSec = Math.round(rec.durationMs / 1000);
|
||||
const durationLabel = durationSec >= 60
|
||||
? `${Math.floor(durationSec / 60)}分${durationSec % 60}秒`
|
||||
: `${durationSec}秒`;
|
||||
const dateLabel = new Date(rec.recordedAt).toLocaleString("ja-JP", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const recordingRow = (
|
||||
<TouchableOpacity
|
||||
onPress={() => startPlayback(rec.id)}
|
||||
onLongPress={() => {
|
||||
void handleExportRecordingFile(rec.id, dateLabel);
|
||||
}}
|
||||
disabled={isPlaying}
|
||||
activeOpacity={0.72}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
gap: 10,
|
||||
opacity: isPlaying ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "bold" }}>{dateLabel}</Text>
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>{rec.snapshotCount} コマ / {durationLabel}</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: "flex-end", gap: 4 }}>
|
||||
<View style={styles.playbackHint}>
|
||||
<MaterialCommunityIcons
|
||||
name={isPlaying ? "pause-circle-outline" : "play-circle-outline"}
|
||||
size={18}
|
||||
color={isPlaying ? colors.textTertiary : "#43a047"}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isPlaying ? colors.textTertiary : colors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{isPlaying ? "再生中は操作不可" : "タップで再生"}
|
||||
</Text>
|
||||
</View>
|
||||
{!isPlaying && (
|
||||
<Text style={{ color: colors.textTertiary, fontSize: 10 }}>長押しで書き出し / 左へスワイプで削除</Text>
|
||||
)}
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.iconSecondary} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (isPlaying) {
|
||||
return <View key={rec.id}>{recordingRow}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
key={rec.id}
|
||||
ref={(instance) => {
|
||||
recordingSwipeRefs.current[rec.id] = instance;
|
||||
}}
|
||||
friction={2}
|
||||
overshootRight={false}
|
||||
rightThreshold={48}
|
||||
renderRightActions={() => (
|
||||
<View style={styles.deleteAction}>
|
||||
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
|
||||
<Text style={styles.deleteActionText}>削除</Text>
|
||||
</View>
|
||||
)}
|
||||
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
|
||||
>
|
||||
{recordingRow}
|
||||
</Swipeable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={openRecordingDatabase}
|
||||
activeOpacity={0.78}
|
||||
style={[
|
||||
styles.databaseButton,
|
||||
{
|
||||
backgroundColor: fixed.primary,
|
||||
borderColor: fixed.primary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="database-search-outline"
|
||||
size={18}
|
||||
color={fixed.textOnPrimary}
|
||||
/>
|
||||
<Text style={[styles.databaseButtonText, { color: fixed.textOnPrimary }]}>
|
||||
全録データベースを参照
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#0099CC",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f8f8fc",
|
||||
},
|
||||
contentInner: {
|
||||
paddingHorizontal: 14,
|
||||
paddingBottom: 40,
|
||||
paddingTop: 20,
|
||||
gap: 12,
|
||||
},
|
||||
debugSection: {
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
padding: 14,
|
||||
gap: 10,
|
||||
},
|
||||
debugTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
debugDescription: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
debugCurrentText: {
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
},
|
||||
switchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 8,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
gap: 8,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
marginTop: 10,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
recordButton: {
|
||||
backgroundColor: "#e53935",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
neutralButton: {
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "bold",
|
||||
fontSize: 13,
|
||||
},
|
||||
neutralButtonText: {
|
||||
fontWeight: "bold",
|
||||
fontSize: 13,
|
||||
},
|
||||
fileBox: {
|
||||
marginTop: 4,
|
||||
gap: 8,
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
},
|
||||
recordingList: {
|
||||
marginTop: 10,
|
||||
gap: 6,
|
||||
},
|
||||
playbackHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
deleteAction: {
|
||||
width: 96,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#e53935",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: 6,
|
||||
},
|
||||
deleteActionText: {
|
||||
color: "#fff",
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
marginTop: 4,
|
||||
},
|
||||
databaseButton: {
|
||||
minHeight: 46,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
marginTop: 4,
|
||||
},
|
||||
databaseButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { SwitchArea } from "../atom/SwitchArea";
|
||||
import { useNotification } from "../../stateBox/useNotifications";
|
||||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||||
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
|
||||
const versionCode = "7.0.7"; // Update this version code as needed
|
||||
|
||||
@@ -29,6 +30,8 @@ export const SettingTopPage = ({
|
||||
const { expoPushToken } = useNotification();
|
||||
const { colors, fixed, colorTheme, setColorTheme } = useThemeColors();
|
||||
const navigation = useNavigation<any>();
|
||||
const { updatePermission } = useTrainMenu();
|
||||
const showResearchTools = __DEV__ || updatePermission;
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
@@ -141,6 +144,14 @@ export const SettingTopPage = ({
|
||||
navigation.navigate("setting", { screen: "DataSourceSettings" })
|
||||
}
|
||||
/>
|
||||
{showResearchTools && (
|
||||
<SettingList
|
||||
string="調査ツール"
|
||||
onPress={() =>
|
||||
navigation.navigate("setting", { screen: "ResearchToolsSettings" })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SectionHeader title="その他" />
|
||||
<SettingList
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ToastAndroid,
|
||||
} from "react-native";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import { TransitionPresets } from "@react-navigation/stack";
|
||||
import { pushTransitionOptions } from "@/lib/stackOption";
|
||||
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
|
||||
import * as Updates from "expo-updates";
|
||||
import { AS } from "../../storageControl";
|
||||
@@ -22,6 +22,7 @@ import { FavoriteSettings } from "./FavoriteSettings";
|
||||
import { NotificationSettings } from "./NotificationSettings";
|
||||
import { LauncherIconSettings } from "./LauncherIconSettings";
|
||||
import { DataSourceSettings } from "./DataSourceSettings";
|
||||
import { ResearchToolsSettings } from "./ResearchToolsSettings";
|
||||
import { FelicaHistoryPage } from "./FelicaHistoryPage";
|
||||
import { SoundSettings } from "./SoundSettings";
|
||||
import { OperationInfoSettings } from "./OperationInfoSettings";
|
||||
@@ -112,7 +113,7 @@ export default function Setting(props) {
|
||||
name="settingTopPage"
|
||||
options={{
|
||||
gestureEnabled: false,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -132,7 +133,7 @@ export default function Setting(props) {
|
||||
name="LayoutSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -165,7 +166,7 @@ export default function Setting(props) {
|
||||
name="NotificationSettings"
|
||||
options={{
|
||||
//gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -176,7 +177,7 @@ export default function Setting(props) {
|
||||
name="LauncherIconSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -187,7 +188,7 @@ export default function Setting(props) {
|
||||
name="FavoriteSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -198,18 +199,29 @@ export default function Setting(props) {
|
||||
name="DataSourceSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
component={DataSourceSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ResearchToolsSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
}}
|
||||
component={ResearchToolsSettings}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="FelicaHistoryPage"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -220,7 +232,7 @@ export default function Setting(props) {
|
||||
name="SoundSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
@@ -231,7 +243,7 @@ export default function Setting(props) {
|
||||
name="OperationInfoSettings"
|
||||
options={{
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.SlideFromRightIOS,
|
||||
...pushTransitionOptions,
|
||||
cardOverlayEnabled: true,
|
||||
headerTransparent: true,
|
||||
headerShown: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { FC } from "react";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
import { Image, StyleSheet, View, Platform } from "react-native";
|
||||
import { Marker } from "react-native-maps";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 保存した運用録画のファイルインポート/エクスポート方針
|
||||
|
||||
## 目的
|
||||
|
||||
保存した走行位置録画を、クリップボードだけでなくファイルとして受け渡しできるようにする。
|
||||
録画データを JSON として書き出し、同じ JSON を選択して取り込める構成にする。
|
||||
|
||||
## 導入済みの必須ライブラリ
|
||||
|
||||
- `expo-file-system`: 録画 JSON を一時ファイルまたは Documents 配下へ書き出し、インポート時に選択済みファイルを読み込む。
|
||||
- `expo-document-picker`: ユーザーに録画 JSON ファイルを選ばせる。読み込み直後に扱いやすくするため、実装時は `copyToCacheDirectory: true` を使う。
|
||||
|
||||
既存の `expo-sharing` は単一ファイルの共有 fallback として残す。`react-native-share` は複数画像や複数ファイルの同時共有、共有先ごとの制御が必要なケースで使う。既存の `expo-clipboard` はコピペ export/import の fallback として残す。
|
||||
|
||||
## 実装済み
|
||||
|
||||
- 録画1件と全件の JSON envelope 書き出し。
|
||||
- `react-native-share` によるネイティブ共有/保存。
|
||||
- Web では `Blob` と `download` による JSON ダウンロード。
|
||||
- `expo-document-picker` と `expo-file-system` による JSON 読み込み。
|
||||
- 既存保存形式と同じ検証処理を通したインポート。
|
||||
- 録画本体は `Paths.document/train-recordings` 配下の JSON ファイルへ保存し、AsyncStorage/SQLite には軽量な一覧メタだけ保存する。
|
||||
- 旧SQLite保存の録画本体は、起動時/インポート時にファイルへ移行して旧キーを削除する。
|
||||
|
||||
## 想定する実装方針
|
||||
|
||||
### ネイティブ
|
||||
|
||||
1. 録画データを version 付き envelope JSON に変換する。
|
||||
2. `expo-file-system` で `cacheDirectory` に `.json` ファイルを書き出す。
|
||||
3. 単一 JSON は `expo-sharing` または `react-native-share` で共有する。
|
||||
4. 複数画像や複数ファイルを同時共有する場合は `react-native-share` の `urls` を使う。
|
||||
5. インポートは `expo-document-picker` で JSON を選択する。
|
||||
6. 選択したファイルを `expo-file-system` で読み込み、既存の検証処理を通して保存する。
|
||||
|
||||
### Web
|
||||
|
||||
Web ではローカル file URI の `expo-sharing` が使えないため、`Blob` と `URL.createObjectURL` による download を別実装にする。
|
||||
インポートはブラウザの file input 相当で読み込む方針にする。
|
||||
|
||||
|
||||
## 正式採用する共有ライブラリ
|
||||
|
||||
- `react-native-share`: 複数画像や複数ファイルを同時共有するために正式採用する。Expo config plugin は options 付きで登録し、初期状態では `ios` と `android` の query 追加を空配列にしている。特定アプリ向けの `shareSingle` や `isPackageInstalled` が必要になった時点で、対象アプリの scheme/package を追加する。
|
||||
- `enableBase64ShareAndroid`: 初期状態では `false`。ファイル URI 共有を基本にするため、Android の legacy storage permission を増やさない。base64 共有が必要になった場合だけ有効化する。
|
||||
|
||||
## 今回インストールしない候補
|
||||
|
||||
- `pako` または `fflate`: 録画 JSON が大きくなったときの gzip/deflate 圧縮候補。まずは plain JSON で運用し、共有できないサイズになった時点で入れる。
|
||||
- `expo-file-system` config plugin options: iOS の Files アプリからアプリ Documents を直接見せたい場合に `enableFileSharing` と `supportsOpeningDocumentsInPlace` を検討する。現時点では共有シートと DocumentPicker で足りる。
|
||||
- `expo-sharing` の共有受け取り設定: 他アプリから「このアプリで開く」導線を作る候補。まずはアプリ内のインポートボタンから選択する方式にする。
|
||||
- `react-native-blob-util`: 大容量/バックグラウンド/Android Storage Access Framework の細かい制御が必要になった場合の候補。Expo managed workflow では導入コストが上がるため、現時点では使わない。
|
||||
|
||||
## 参考
|
||||
|
||||
- https://docs.expo.dev/versions/latest/sdk/filesystem/
|
||||
- https://docs.expo.dev/versions/latest/sdk/document-picker/
|
||||
- https://docs.expo.dev/versions/latest/sdk/sharing/
|
||||
@@ -0,0 +1,258 @@
|
||||
# Sentry Crash 調査履歴
|
||||
|
||||
## 目的
|
||||
このメモは、現在追っているクラッシュ調査の経緯を ChatGPT 側で再考察しやすい形に整理したもの。
|
||||
|
||||
## 現象
|
||||
- `top menu` を表示した後、タブ切り替えや回転をきっかけにクラッシュする。
|
||||
- Android / iOS の両方で再現した。
|
||||
- 既存の `lazy` / `detachInactiveScreens` は必須前提として扱う。
|
||||
- 走行位置、運行情報、トップメニューの各画面で WebView / ネイティブ View のマウントが絡んでいる。
|
||||
|
||||
## 最初の主要シグナル
|
||||
- Sentry の主エラーは `RetryableMountingLayerException: Unable to find viewState for tag ...`
|
||||
- 典型的には Fabric / New Architecture のネイティブ UI ツリー更新失敗を示していた。
|
||||
- つまり JavaScript の例外というより、View の mount / unmount タイミング競合を疑うべき状態だった。
|
||||
|
||||
## 試した方向性の履歴
|
||||
|
||||
### 1. 遷移タイミングをずらす
|
||||
- `navigate` / `replace` / `reset` の直後に落ちるケースを疑い、遷移を一拍遅らせる案を検討した。
|
||||
- ただし症状は完全には止まらず、遷移先だけの問題ではなかった。
|
||||
|
||||
### 2. ライフサイクル依存の自動解放を抑える
|
||||
- iOS でブラウザや WebView がタブ切り替え時に開放される問題があり、`lazyload` を外す方向は破綻した。
|
||||
- そのため、WebView を「常時破棄しない」方向を維持する必要があった。
|
||||
|
||||
### 3. Sentry の観測点を増やす
|
||||
- 画面ごとに breadcrumb と context を追加した。
|
||||
- 追加した主な観測点:
|
||||
- `Apps.tsx`
|
||||
- `Top.tsx`
|
||||
- `components/Apps.tsx`
|
||||
- `components/Apps/WebView.tsx`
|
||||
- `MenuPage.tsx`
|
||||
- `ndView.tsx`
|
||||
- `lib/useWebViewRemount.ts`
|
||||
- これで「どの画面がいつ active になったか」「WebView がいつ remount されたか」を追えるようにした。
|
||||
|
||||
## 重要な発見
|
||||
|
||||
### 発見 1: 走行位置画面の WebView が、非表示なのに active 扱いになる
|
||||
- Sentry のイベントで、`root_tab = topMenu` なのに `positions_screen.activatedScreen = true` や `positions_webview.currentUrl = https://train.jr-shikoku.co.jp/sp.html` が見えていた。
|
||||
- Replay 上はトップメニューだけに見えても、内部 state は走行位置側が先に活性化していた。
|
||||
- これは「見た目では隠れているが、React Navigation / Fabric 的には既に mount されている」可能性を示した。
|
||||
|
||||
### 発見 2: `useIsFocused()` を初期 render の active 判定に使うのが危険
|
||||
- `useIsFocused()` 由来の初期状態が、ナビゲータ初期化直後に想定外の `true` を返す経路があった。
|
||||
- その結果、`positions` 側の root / stack / screen / webview が早すぎるタイミングで活性化した。
|
||||
- これが Fabric の mount 競合を起こしている可能性が高い。
|
||||
|
||||
### 発見 3: `positions` の gate を 1 枚かませても足りなかった
|
||||
- `DeferredPositionsRoot` を追加して root 側を遅延させたが、それでも crash は残った。
|
||||
- つまり root だけではなく、stack / screen / WebView 側にも同じ「初期 focused 扱い」の問題が潜んでいた。
|
||||
|
||||
## 反復した修正
|
||||
|
||||
### 実施した instrumentation
|
||||
- root navigation の state 変化を breadcrumb に残すようにした。
|
||||
- `positions_stack` / `positions_screen` / `positions_webview` / `operation_screen` などの context を追加した。
|
||||
- WebView remount の理由も記録するようにした。
|
||||
|
||||
### 実施した防御
|
||||
- 画面遷移時の WebView remount を観測しやすくした。
|
||||
- Android の回転時やバックグラウンド遷移時に何が起きるか追えるようにした。
|
||||
|
||||
## 現在の仮説
|
||||
- 根本原因は React Navigation そのものではなく、初期 render 時の `focused` 判定を前提にした activation が早すぎること。
|
||||
- その結果、非表示の `positions` ツリーが先に active になり、Fabric が mount state を失う。
|
||||
- 画面遷移先の問題というより、「起動直後の数タイミングで走行位置を経由したタブ遷移が入ると壊れる」挙動に近い。
|
||||
|
||||
## upstream issue 調査メモ
|
||||
- `software-mansion/react-native-reanimated#9785`
|
||||
- `open`
|
||||
- Android + Fabric + React Navigation stack animation 有効時に `SurfaceMountingManager` で crash。
|
||||
- Android だけ stack animation を `none` にすると止まる、という回避策まで一致している。
|
||||
- `software-mansion/react-native-reanimated#6908`
|
||||
- `closed`
|
||||
- Android Fabric で `entering/exiting animations` 使用時に大量 crash。
|
||||
- 本文中に `RetryableMountingLayerException: Unable to find viewState for tag ... Surface stopped: false` があり、Sentry の症状にかなり近い。
|
||||
- `software-mansion/react-native-reanimated#9636`
|
||||
- `closed`
|
||||
- `NativeProxy.preserveMountedTags` が Fabric の view preallocation 中に `Unable to find view for tag ... Surface stopped: false` で落ちる。
|
||||
- 重い native view が同期イベントを投げると crash しやすいという話で、WebView / map 系にも連想が効く。
|
||||
- `software-mansion/react-native-reanimated#8344`
|
||||
- `open`
|
||||
- multi-surface で `LayoutAnimationsProxy` が surface aware でない、という報告。
|
||||
- 直接 Android の同一事象ではないが、「surface をまたぐ animation / mount transaction が壊れる」という意味で構造が近い。
|
||||
|
||||
## 現時点での対策方針
|
||||
- Android の React Navigation stack animation は無効化する。
|
||||
- Android の Reanimated `entering/exiting/layout` は、実際に使っている箇所から順に止めて再現率を下げる。
|
||||
- 現在 repo 内で確認できた該当箇所は `components/Menu/Carousel/CarouselBox.tsx` の `FadeIn/FadeOut`。
|
||||
|
||||
## 直近の修正
|
||||
- `Apps.tsx`
|
||||
- `DeferredPositionsRoot` の activation 初期値を `false` に変更。
|
||||
- `Top.tsx`
|
||||
- positions stack の activation 初期値を `false` に変更。
|
||||
- `components/Apps.tsx`
|
||||
- positions screen / WebView の activation 初期値を `false` に変更。
|
||||
- `ndView.tsx`
|
||||
- operation WebView の activation 初期値を `false` に変更。
|
||||
|
||||
## 直近の OTA
|
||||
- branch: `gmarket`
|
||||
- message: `initialize tab activation gates from false`
|
||||
- update group ID: `82552333-cb5e-455f-ac58-65ada37f7acd`
|
||||
- Android update ID: `019f2133-c3e6-7050-911c-49052b6b17a1`
|
||||
- iOS update ID: `019f2133-c3e6-79b9-9776-88fd55ff72b8`
|
||||
- commit: `cf3995fcbe34366b32b09ee1cfb6b27a7e42982c`
|
||||
|
||||
## いま ChatGPT に見てほしい論点
|
||||
1. 初期 focused 判定を `false` にしても crash が続くなら、次はどの画面の mount ordering を疑うべきか。
|
||||
2. `positions` ツリーのどこまでが本当に先に active になるのかを、Sentry context と replay でどう切り分けるか。
|
||||
3. Fabric / New Architecture の mount race として扱うべきか、React Navigation の state propagation 問題として扱うべきか。
|
||||
|
||||
## 参考ファイル
|
||||
- [Apps.tsx](/home/ubuntu/jrshikoku/Apps.tsx)
|
||||
- [Top.tsx](/home/ubuntu/jrshikoku/Top.tsx)
|
||||
- [components/Apps.tsx](/home/ubuntu/jrshikoku/components/Apps.tsx)
|
||||
- [components/Apps/WebView.tsx](/home/ubuntu/jrshikoku/components/Apps/WebView.tsx)
|
||||
- [ndView.tsx](/home/ubuntu/jrshikoku/ndView.tsx)
|
||||
- [lib/useWebViewRemount.ts](/home/ubuntu/jrshikoku/lib/useWebViewRemount.ts)
|
||||
|
||||
|
||||
## 現在の安定化パッチの意味
|
||||
- 最新の `gmarket` 配信では、クラッシュ回避のために非表示タブの重い native view をかなり積極的に止めている。
|
||||
- その結果、クラッシュは減った一方で、以下の UX 劣化が意図的に入っている。
|
||||
- 走行位置 / 運行情報の WebView がタブ離脱後に再初期化されやすい
|
||||
- タブ間で状態保持されず、開き直しに見える
|
||||
- 動的ルーティングやバックグラウンド継続性が弱くなっている
|
||||
- トップメニューの重い要素は遷移直後に即 mount されない
|
||||
|
||||
## 現在の安定基準点
|
||||
- `gmarket` で配信済みの `topMenu` heavy mount 遅延パッチでは、少なくとも直近の再現テストでクラッシュ頻度は大きく下がっている。
|
||||
- したがって、ここを一旦「クラッシュしにくい基準点」として扱い、以降は UX を壊している防御を一つずつ戻していく方針が妥当。
|
||||
- この時点で重要なのは「全部戻す」ことではなく、「どの防御を戻すとクラッシュが再発するか」を Sentry で切り分けること。
|
||||
|
||||
## UX 劣化の主因になっている防御
|
||||
- `components/Apps.tsx`
|
||||
- `positions` の WebView を blur 時に teardown / deactivate している。
|
||||
- `Apps.tsx` / `Top.tsx` / `lib/rootNavigation.ts`
|
||||
- `positions` タブ離脱時に root / stack 側も落とす寄りの制御を入れている。
|
||||
- `ndView.tsx`
|
||||
- `information` の WebView も blur 中は deactivate している。
|
||||
- `menu.tsx`
|
||||
- `topMenu` の `MapView` / `CarouselBox` / `LED_vision` を Android で遅延 mount している。
|
||||
|
||||
## ここから戻す優先順位
|
||||
1. `positions` の blur 時 WebView teardown を部分的に戻す
|
||||
- 対象: `components/Apps.tsx`
|
||||
- 理由: 走行位置の開き直し、状態喪失、バックグラウンド継続切断に最も効いているため。
|
||||
2. `positions` の tab blur 時 stack / root teardown を緩める
|
||||
- 対象: `Apps.tsx`, `Top.tsx`, `lib/rootNavigation.ts`
|
||||
- 理由: タブ間データ共有、動的ルーティング、navigation state 維持の破綻に直結しているため。
|
||||
3. `information` の blur 時 WebView teardown を戻す
|
||||
- 対象: `ndView.tsx`
|
||||
- 理由: 運行情報の開き直しや iOS 側の裏ブラウザ保持に関係するため。
|
||||
4. `topMenu` の heavy mount 遅延を必要最小限まで縮める
|
||||
- 対象: `menu.tsx`
|
||||
- 理由: 初回表示のもたつき改善には効くが、状態維持より優先度は下。
|
||||
5. 最終的に teardown をやめて pause ベースに置き換える
|
||||
- 対象: `components/Apps.tsx`, `ndView.tsx` など
|
||||
- 理由: 安定性を維持しながら UX を戻す本命だが、切り分け後にやるべき段階。
|
||||
|
||||
## 次の実施方針
|
||||
- まず `positions` を中心に戻す。
|
||||
- 1 回の変更で戻す範囲は狭くし、再現したらその差分だけを Sentry で見る。
|
||||
- つまり今後の作業は「クラッシュ回避策の全撤廃」ではなく、「UX を壊している要素を優先順に切り戻し、再発ラインを特定する」ことが目的。
|
||||
|
||||
|
||||
## 2026-07-03 時点の基準点
|
||||
|
||||
### 現在の評価
|
||||
- 起動直後の `positions -> topMenu` / `positions -> information` で高頻度に落ちていた主クラッシュは、現時点ではかなり再現率が下がっている。
|
||||
- 少なくとも直近の観測では、以前の `RetryableMountingLayerException` 系の大崩れは前面には出ていない。
|
||||
- そのため、この時点は「主問題をかなり抑え込めた基準点」として保存する価値がある。
|
||||
|
||||
### この時点で残っている別系統の問題
|
||||
- `JR-SHIKOKU-UNOFFICIAL-APPS-S`
|
||||
- `Error: Unsupported top level event type "topUserLocationChange" dispatched`
|
||||
- `root_tab = positions`
|
||||
- `level = fatal`
|
||||
- `ReactFabric-prod` 起点
|
||||
- これは、今回の `topMenu / information / hidden prewarm` 系の問題とは別系統とみなす。
|
||||
- つまり、この時点での主クラッシュ対策の評価をする際は、この issue は切り離して考える。
|
||||
|
||||
### この時点での差分の分類
|
||||
|
||||
#### A. 主クラッシュ抑止のコア差分
|
||||
以下は、現時点の安定化に本質的に効いている可能性が高く、安易に戻さない。
|
||||
|
||||
- `Apps.tsx`
|
||||
- `DeferredPositionsRoot`
|
||||
- `hasVisitedPositions`
|
||||
- startup delayed prewarm
|
||||
- `detachInactiveScreens={false}`
|
||||
- root tab の `positionsLifecycleRef` 連携
|
||||
- `Top.tsx`
|
||||
- `positions stack` の background prewarm
|
||||
- stack activation gate
|
||||
- `components/Apps.tsx`
|
||||
- `positions screen` / `WebView` の activation gate
|
||||
- `hasStableWebViewSession`
|
||||
- blur 時 preserve
|
||||
- unstable session 中の tab exit guard
|
||||
- `components/Apps/WebView.tsx`
|
||||
- WebView 初回安定化通知
|
||||
- message / load / process termination の監視強化
|
||||
- `lib/rootNavigation.ts`
|
||||
- `positionsLifecycleRef`
|
||||
- `lib/useWebViewRemount.ts`
|
||||
- remount reason 付きの共通制御
|
||||
|
||||
#### B. 補助的な安定化差分
|
||||
以下は、主因とは言い切れないが、再現率低下に寄与している可能性がある。
|
||||
|
||||
- `menu.tsx`
|
||||
- top menu heavy content の delayed activation
|
||||
- Android で `MapView` や重い要素をすぐ mount しない制御
|
||||
- `ndView.tsx`
|
||||
- operation WebView の delayed activation
|
||||
- operation 側 remount / parse / settings load の観測
|
||||
- `lib/stackOption.ts`
|
||||
- Android で push animation を切る変更
|
||||
- `components/Settings/settings.tsx`
|
||||
- settings stack へ共通 animation option を適用
|
||||
|
||||
#### C. 観測専用で後から削れる差分
|
||||
以下は、挙動改善というより Sentry で成功/失敗比較を行うための記録であり、安定化の評価が済めば削減可能。
|
||||
|
||||
- `Apps.tsx` の `nav.root` / `nav.tab` / `positions.root` breadcrumb と context の大半
|
||||
- `Top.tsx` の `positions.stack` breadcrumb / context
|
||||
- `components/Apps.tsx` の `positions.screen` breadcrumb / context
|
||||
- `components/Apps/WebView.tsx` の `positions.webview` breadcrumb / context
|
||||
- `MenuPage.tsx` の `topMenu.screen` breadcrumb / context
|
||||
- `menu.tsx` の `top_menu_runtime` breadcrumb / context
|
||||
- `ndView.tsx` の `operation.screen` / `operation.settings` / `operation.webview` breadcrumb / context
|
||||
- `components/Menu/Carousel/CarouselBox.tsx` の `menu.carousel` breadcrumb
|
||||
|
||||
### この時点での重要な判断
|
||||
- 今の差分は広いが、全部が同じ重みではない。
|
||||
- まず守るべきなのは `positions` の activation / preserve / unstable guard 周辺であり、ここが今回の基礎。
|
||||
- 一方、Sentry breadcrumb の大半は観測用なので、安定化判断後にかなり整理できる。
|
||||
- `CarouselBox.tsx` は特に「Android fallback ロジック」と「breadcrumb」の両方が混ざっているため、後で整理する際は分離して扱う。
|
||||
|
||||
### 今後の戻し方の基準
|
||||
- 戻すときは、まず観測用差分からではなく、`UX を壊しているがコアではない差分` を対象にする。
|
||||
- つまり順番としては:
|
||||
1. 観測を維持したまま補助差分を少し戻す
|
||||
2. 再発しないことを確認する
|
||||
3. 最後に breadcrumb / context を掃除する
|
||||
- コア差分を先に剥がすと、再び「どこで壊れたか分からない状態」に戻るので避ける。
|
||||
|
||||
### この時点の結論
|
||||
- `positions` の hidden / delayed activation と lifecycle guard 群は、今回の主クラッシュ対策の核として維持する。
|
||||
- `topMenu` / `operation` / `carousel` 周辺の差分は、主因ではなく補助あるいは観測の可能性があるため、今後の整理対象にできる。
|
||||
- 新たに見つかった `topUserLocationChange` は別問題であり、この基準点の評価には混ぜない。
|
||||
@@ -47,6 +47,12 @@
|
||||
},
|
||||
"production7.0": {
|
||||
"channel": "familymart"
|
||||
},
|
||||
"beta7.1": {
|
||||
"channel": "gmarket"
|
||||
},
|
||||
"production7.1": {
|
||||
"channel": "geekbuying"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
+291
-14
@@ -1,3 +1,6 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
|
||||
import { AS } from '../../storageControl';
|
||||
import { STORAGE_KEYS } from '../../constants/storage';
|
||||
import { TrainEntry } from './webviewXhrInterceptor';
|
||||
@@ -24,6 +27,60 @@ export type TrainRecording = {
|
||||
snapshots: TrainSnapshot[];
|
||||
};
|
||||
|
||||
export type RecordingExportEnvelope = {
|
||||
format: 'jrshikoku-train-recording';
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
recording: TrainRecording;
|
||||
};
|
||||
|
||||
export type RecordingsExportEnvelope = {
|
||||
format: 'jrshikoku-train-recordings';
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
recordings: TrainRecording[];
|
||||
};
|
||||
|
||||
export type RecordingImportResult = {
|
||||
importedCount: number;
|
||||
overwrittenCount: number;
|
||||
};
|
||||
|
||||
const RECORDING_EXPORT_FORMAT = 'jrshikoku-train-recording' as const;
|
||||
const RECORDINGS_EXPORT_FORMAT = 'jrshikoku-train-recordings' as const;
|
||||
const RECORDING_FILE_DIRECTORY = 'train-recordings';
|
||||
|
||||
const getRecordingsDirectory = () => new Directory(Paths.document, RECORDING_FILE_DIRECTORY);
|
||||
|
||||
const ensureRecordingsDirectory = () => {
|
||||
const directory = getRecordingsDirectory();
|
||||
directory.create({ idempotent: true, intermediates: true });
|
||||
return directory;
|
||||
};
|
||||
|
||||
const getRecordingFileName = (id: string) => `${encodeURIComponent(id)}.json`;
|
||||
|
||||
const getRecordingFile = (id: string, createDirectory = false) =>
|
||||
new File(createDirectory ? ensureRecordingsDirectory() : getRecordingsDirectory(), getRecordingFileName(id));
|
||||
|
||||
const getLegacyRecordingStorageKey = (id: string) =>
|
||||
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as string;
|
||||
|
||||
const removeLegacyRecordingStorage = async (id: string) => {
|
||||
const key = getLegacyRecordingStorageKey(id);
|
||||
await AS.removeItem(key).catch(() => {});
|
||||
};
|
||||
|
||||
const cleanupLegacyRecordingStorage = async () => {
|
||||
const prefix = STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX;
|
||||
const keys = await AsyncStorage.getAllKeys().catch(() => []);
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => AsyncStorage.removeItem(key).catch(() => {})),
|
||||
);
|
||||
};
|
||||
|
||||
const parse = <T>(raw: string | null | boolean): T | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
@@ -37,6 +94,99 @@ const parse = <T>(raw: string | null | boolean): T | null => {
|
||||
export const generateRecordingId = (recordedAt: string) =>
|
||||
recordedAt.replace(/[:.]/g, '-');
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const normalizeDirection = (value: unknown): 0 | 1 => (value === 1 ? 1 : 0);
|
||||
|
||||
const normalizeDelay = (value: unknown): number | string =>
|
||||
typeof value === 'number' || typeof value === 'string' ? value : 0;
|
||||
|
||||
const normalizeTrainEntry = (value: unknown): TrainEntry | null => {
|
||||
if (!isPlainObject(value)) return null;
|
||||
if (typeof value.TrainNum !== 'string') return null;
|
||||
if (typeof value.Line !== 'string') return null;
|
||||
if (typeof value.Type !== 'string') return null;
|
||||
|
||||
return {
|
||||
Index: typeof value.Index === 'number' ? value.Index : 0,
|
||||
TrainNum: value.TrainNum,
|
||||
Pos: typeof value.Pos === 'string' ? value.Pos : '',
|
||||
PosNum: typeof value.PosNum === 'number' ? value.PosNum : 0,
|
||||
delay: normalizeDelay(value.delay),
|
||||
Direction: normalizeDirection(value.Direction),
|
||||
Type: value.Type,
|
||||
Line: value.Line,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSnapshot = (value: unknown): TrainSnapshot | null => {
|
||||
if (!isPlainObject(value)) return null;
|
||||
if (!Array.isArray(value.trains)) return null;
|
||||
|
||||
const trains = value.trains
|
||||
.map(normalizeTrainEntry)
|
||||
.filter((entry): entry is TrainEntry => entry !== null);
|
||||
|
||||
if (trains.length !== value.trains.length) return null;
|
||||
|
||||
return {
|
||||
t: typeof value.t === 'number' && Number.isFinite(value.t) ? Math.max(0, value.t) : 0,
|
||||
trains,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeRecording = (value: unknown): TrainRecording | null => {
|
||||
if (!isPlainObject(value)) return null;
|
||||
if (typeof value.recordedAt !== 'string') return null;
|
||||
if (!Array.isArray(value.snapshots)) return null;
|
||||
|
||||
const snapshots = value.snapshots
|
||||
.map(normalizeSnapshot)
|
||||
.filter((snapshot): snapshot is TrainSnapshot => snapshot !== null);
|
||||
|
||||
if (snapshots.length !== value.snapshots.length) return null;
|
||||
|
||||
const durationMs =
|
||||
typeof value.durationMs === 'number' && Number.isFinite(value.durationMs)
|
||||
? Math.max(0, value.durationMs)
|
||||
: snapshots[snapshots.length - 1]?.t ?? 0;
|
||||
|
||||
const id =
|
||||
typeof value.id === 'string' && value.id.trim().length > 0
|
||||
? value.id
|
||||
: generateRecordingId(value.recordedAt);
|
||||
|
||||
return {
|
||||
id,
|
||||
recordedAt: value.recordedAt,
|
||||
durationMs,
|
||||
snapshots,
|
||||
};
|
||||
};
|
||||
|
||||
const extractRecordingsFromImport = (value: unknown): TrainRecording[] => {
|
||||
if (isPlainObject(value) && value.format === RECORDING_EXPORT_FORMAT) {
|
||||
const recording = normalizeRecording(value.recording);
|
||||
return recording ? [recording] : [];
|
||||
}
|
||||
|
||||
if (isPlainObject(value) && value.format === RECORDINGS_EXPORT_FORMAT && Array.isArray(value.recordings)) {
|
||||
return value.recordings
|
||||
.map(normalizeRecording)
|
||||
.filter((recording): recording is TrainRecording => recording !== null);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(normalizeRecording)
|
||||
.filter((recording): recording is TrainRecording => recording !== null);
|
||||
}
|
||||
|
||||
const recording = normalizeRecording(value);
|
||||
return recording ? [recording] : [];
|
||||
};
|
||||
|
||||
/** 録画インデックス(メタ一覧)を読み込む */
|
||||
export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
|
||||
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX).catch(() => null);
|
||||
@@ -45,16 +195,57 @@ export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
|
||||
|
||||
/** フル録画データをIDで読み込む */
|
||||
export const loadRecordingById = async (id: string): Promise<TrainRecording | null> => {
|
||||
const raw = await AS.getItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => null);
|
||||
return parse<TrainRecording>(raw);
|
||||
const file = getRecordingFile(id);
|
||||
if (file.exists) {
|
||||
const raw = await file.text().catch(() => null);
|
||||
const recording = parse<TrainRecording>(raw);
|
||||
if (recording) return recording;
|
||||
}
|
||||
|
||||
const legacyKey = getLegacyRecordingStorageKey(id);
|
||||
const legacyRaw = await AS.getItem(legacyKey).catch(() => null);
|
||||
const legacyRecording = parse<TrainRecording>(legacyRaw);
|
||||
if (!legacyRecording) return null;
|
||||
|
||||
await writeRecordingFile(legacyRecording);
|
||||
await removeLegacyRecordingStorage(id);
|
||||
return legacyRecording;
|
||||
};
|
||||
|
||||
/** 全録画を新しい順で読み込む */
|
||||
export const loadAllRecordings = async (): Promise<TrainRecording[]> => {
|
||||
const list = await loadRecordingList();
|
||||
const recordings = await Promise.all(list.map((meta) => loadRecordingById(meta.id)));
|
||||
return recordings.filter((recording): recording is TrainRecording => recording !== null);
|
||||
};
|
||||
|
||||
const migrateIndexedLegacyRecordings = async (): Promise<boolean> => {
|
||||
const list = await loadRecordingList();
|
||||
if (list.length === 0) return true;
|
||||
|
||||
const results = await Promise.all(
|
||||
list.map(async (meta) => {
|
||||
const recording = await loadRecordingById(meta.id).catch(() => null);
|
||||
return recording !== null;
|
||||
}),
|
||||
);
|
||||
|
||||
return results.every(Boolean);
|
||||
};
|
||||
|
||||
const writeRecordingFile = async (recording: TrainRecording): Promise<void> => {
|
||||
const file = getRecordingFile(recording.id, true);
|
||||
if (file.exists) {
|
||||
file.delete();
|
||||
}
|
||||
file.create({ overwrite: true });
|
||||
file.write(JSON.stringify(recording));
|
||||
};
|
||||
|
||||
/** 録画を保存してインデックスに追加する */
|
||||
export const saveRecording = async (recording: TrainRecording): Promise<void> => {
|
||||
await AS.setItem(
|
||||
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + recording.id) as any,
|
||||
JSON.stringify(recording),
|
||||
);
|
||||
await writeRecordingFile(recording);
|
||||
await removeLegacyRecordingStorage(recording.id);
|
||||
const list = await loadRecordingList();
|
||||
const meta: RecordingMeta = {
|
||||
id: recording.id,
|
||||
@@ -69,7 +260,11 @@ export const saveRecording = async (recording: TrainRecording): Promise<void> =>
|
||||
|
||||
/** 録画をIDで削除する */
|
||||
export const deleteRecordingById = async (id: string): Promise<void> => {
|
||||
await AS.removeItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => {});
|
||||
const file = getRecordingFile(id);
|
||||
if (file.exists) {
|
||||
file.delete();
|
||||
}
|
||||
await removeLegacyRecordingStorage(id);
|
||||
const list = await loadRecordingList();
|
||||
await AS.setItem(
|
||||
STORAGE_KEYS.MOCK_RECORDINGS_INDEX,
|
||||
@@ -78,17 +273,99 @@ export const deleteRecordingById = async (id: string): Promise<void> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 旧フォーマット(MOCK_RECORDING)からマイグレーション。
|
||||
* 新インデックスが空の場合のみ実行する。
|
||||
* 旧フォーマット(MOCK_RECORDING/SQLite保存)からファイル保存へ移行する。
|
||||
*/
|
||||
export const migrateOldRecording = async (): Promise<void> => {
|
||||
const indexedRecordingsMigrated = await migrateIndexedLegacyRecordings();
|
||||
const list = await loadRecordingList();
|
||||
if (list.length > 0) return; // 既に移行済み
|
||||
if (list.length > 0) {
|
||||
if (indexedRecordingsMigrated) {
|
||||
await cleanupLegacyRecordingStorage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => null);
|
||||
const old = parse<Omit<TrainRecording, 'id'>>(raw);
|
||||
if (!old) return;
|
||||
const id = generateRecordingId(old.recordedAt);
|
||||
await saveRecording({ ...old, id });
|
||||
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
|
||||
if (old) {
|
||||
const id = generateRecordingId(old.recordedAt);
|
||||
await saveRecording({ ...old, id });
|
||||
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
|
||||
}
|
||||
|
||||
await cleanupLegacyRecordingStorage();
|
||||
};
|
||||
|
||||
/** 録画1件をファイル/クリップボード用JSON文字列へ変換する */
|
||||
export const buildRecordingExportText = async (id: string): Promise<string> => {
|
||||
const recording = await loadRecordingById(id);
|
||||
if (!recording) {
|
||||
throw new Error('録画データが見つかりませんでした。');
|
||||
}
|
||||
|
||||
const payload: RecordingExportEnvelope = {
|
||||
format: RECORDING_EXPORT_FORMAT,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
recording,
|
||||
};
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
/** 全録画をファイル/クリップボード用JSON文字列へ変換する */
|
||||
export const buildAllRecordingsExportText = async (): Promise<string> => {
|
||||
const recordings = await loadAllRecordings();
|
||||
if (recordings.length === 0) {
|
||||
throw new Error('書き出せる録画がありません。');
|
||||
}
|
||||
|
||||
const payload: RecordingsExportEnvelope = {
|
||||
format: RECORDINGS_EXPORT_FORMAT,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
recordings,
|
||||
};
|
||||
|
||||
return JSON.stringify(payload, null, 2);
|
||||
};
|
||||
|
||||
/** コピペ/ファイルから読み込んだJSON文字列を録画として保存する */
|
||||
export const importRecordingsFromText = async (text: string): Promise<RecordingImportResult> => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('インポートするJSONが空です。');
|
||||
}
|
||||
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
parsedValue = JSON.parse(trimmed);
|
||||
} catch {
|
||||
throw new Error('JSONとして読み取れませんでした。ファイル内容を確認してください。');
|
||||
}
|
||||
|
||||
const recordings = extractRecordingsFromImport(parsedValue);
|
||||
if (recordings.length === 0) {
|
||||
throw new Error('有効な録画データが見つかりませんでした。');
|
||||
}
|
||||
|
||||
if (await migrateIndexedLegacyRecordings()) {
|
||||
await cleanupLegacyRecordingStorage();
|
||||
}
|
||||
|
||||
const existingIds = new Set((await loadRecordingList()).map((meta) => meta.id));
|
||||
let overwrittenCount = 0;
|
||||
|
||||
for (const recording of recordings) {
|
||||
if (existingIds.has(recording.id)) {
|
||||
overwrittenCount += 1;
|
||||
}
|
||||
await saveRecording(recording);
|
||||
existingIds.add(recording.id);
|
||||
}
|
||||
|
||||
return {
|
||||
importedCount: recordings.length,
|
||||
overwrittenCount,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,22 @@ export const rootNavigationRef = createNavigationContainerRef<any>();
|
||||
/** positions タブの Stack.Navigator navigation を登録するグローバルref */
|
||||
export const positionsStackNavRef: { current: any } = { current: null };
|
||||
|
||||
export const positionsLifecycleRef: {
|
||||
current: {
|
||||
isUnstable: boolean;
|
||||
blockTabExit: boolean;
|
||||
resetBeforeLeave: (() => void) | null;
|
||||
deactivateStack: (() => void) | null;
|
||||
};
|
||||
} = {
|
||||
current: {
|
||||
isUnstable: false,
|
||||
blockTabExit: false,
|
||||
resetBeforeLeave: null,
|
||||
deactivateStack: null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 遷移先タブのネストスタックを一度 popToTop してからナビゲートする。
|
||||
* ウィジェットや外部リンクからの遷移時に、既存の開いている画面を閉じてから目的の画面へ移動するために使用する。
|
||||
|
||||
+9
-1
@@ -28,8 +28,9 @@ const forModalPresentationAndroid = (
|
||||
|
||||
export const optionData = {
|
||||
gestureEnabled: true,
|
||||
...TransitionPresets.ModalPresentationIOS,
|
||||
...(Platform.OS === "ios" ? TransitionPresets.ModalPresentationIOS : {}),
|
||||
...(Platform.OS === "android" && {
|
||||
animationEnabled: false,
|
||||
cardStyleInterpolator: forModalPresentationAndroid,
|
||||
}),
|
||||
cardOverlayEnabled: true,
|
||||
@@ -37,3 +38,10 @@ export const optionData = {
|
||||
headerShown: false,
|
||||
detachPreviousScreen: false,
|
||||
};
|
||||
|
||||
export const pushTransitionOptions =
|
||||
Platform.OS === "ios"
|
||||
? TransitionPresets.SlideFromRightIOS
|
||||
: {
|
||||
animationEnabled: false,
|
||||
};
|
||||
|
||||
+73
-13
@@ -2,6 +2,26 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppState, AppStateStatus } from "react-native";
|
||||
import WebView from "react-native-webview";
|
||||
|
||||
type WebViewRemountReason =
|
||||
| "manual"
|
||||
| "app_background"
|
||||
| "render_process_gone"
|
||||
| "content_process_terminated"
|
||||
| "loading_timeout"
|
||||
| "pong_timeout"
|
||||
| "blank_detected";
|
||||
|
||||
type WebViewRemountData = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
type UseWebViewRemountOptions = {
|
||||
pingEnabled?: boolean;
|
||||
backgroundThresholdMs?: number;
|
||||
isFocused?: boolean;
|
||||
pauseWatchdogWhenUnfocused?: boolean;
|
||||
ignoreProcessTerminationWhenUnfocused?: boolean;
|
||||
onRemount?: (reason: WebViewRemountReason, data?: WebViewRemountData) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebView のメモリ解放・プロセス終了による白画面を自動復帰させるフック。
|
||||
*
|
||||
@@ -13,22 +33,32 @@ import WebView from "react-native-webview";
|
||||
* pingHandlers を使う場合は onMessage を上書きせず spread すること。
|
||||
* ping による白画面検知を有効にするには pingEnabled: true を渡す。
|
||||
*/
|
||||
export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundThresholdMs?: number }) {
|
||||
export function useWebViewRemount(options?: UseWebViewRemountOptions) {
|
||||
const pingEnabled = options?.pingEnabled ?? false;
|
||||
const backgroundThresholdMs = options?.backgroundThresholdMs ?? 300_000; // デフォルト5分
|
||||
const isFocused = options?.isFocused ?? true;
|
||||
const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false;
|
||||
const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false;
|
||||
const onRemount = options?.onRemount;
|
||||
const [remountKey, setRemountKey] = useState(0);
|
||||
const backgroundedAt = useRef<number | null>(null);
|
||||
const webViewRef = useRef<WebView>(null);
|
||||
const ignoredProcessTerminationRef = useRef<WebViewRemountReason | null>(null);
|
||||
|
||||
// ping watchdog 用
|
||||
const lastPongAt = useRef(Date.now());
|
||||
const isLoadingRef = useRef(true);
|
||||
|
||||
const remount = useCallback(() => {
|
||||
const triggerRemount = useCallback((reason: WebViewRemountReason, data?: WebViewRemountData) => {
|
||||
onRemount?.(reason, data);
|
||||
lastPongAt.current = Date.now();
|
||||
isLoadingRef.current = true;
|
||||
setRemountKey((k) => k + 1);
|
||||
}, []);
|
||||
}, [onRemount]);
|
||||
|
||||
const remount = useCallback(() => {
|
||||
triggerRemount("manual");
|
||||
}, [triggerRemount]);
|
||||
|
||||
// バックグラウンドから復帰したら再マウント(デフォルト5分超)
|
||||
useEffect(() => {
|
||||
@@ -39,13 +69,13 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
|
||||
const elapsed = Date.now() - backgroundedAt.current;
|
||||
backgroundedAt.current = null;
|
||||
if (elapsed > backgroundThresholdMs) {
|
||||
remount();
|
||||
triggerRemount("app_background", { elapsedMs: elapsed });
|
||||
}
|
||||
}
|
||||
};
|
||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||
return () => subscription.remove();
|
||||
}, [remount, backgroundThresholdMs]);
|
||||
}, [backgroundThresholdMs, triggerRemount]);
|
||||
|
||||
// ping watchdog: 5秒ごとに生存確認と白画面検知を行う
|
||||
// - ローディング中(isLoadingRef=true)でも45秒超なら remount(レンダラー死亡でonLoadEndが来ないケース)
|
||||
@@ -55,29 +85,53 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
|
||||
useEffect(() => {
|
||||
if (!pingEnabled) return;
|
||||
const id = setInterval(() => {
|
||||
if (pauseWatchdogWhenUnfocused && !isFocused) {
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - lastPongAt.current;
|
||||
if (isLoadingRef.current) {
|
||||
// ローディング中でも45秒超はレンダラー死亡と判定
|
||||
if (elapsed > 45_000) remount();
|
||||
if (elapsed > 45_000) triggerRemount("loading_timeout", { elapsedMs: elapsed });
|
||||
return;
|
||||
}
|
||||
// ロード完了後30秒 pong 無応答 → レンダラー死亡
|
||||
if (elapsed > 30_000) {
|
||||
remount();
|
||||
triggerRemount("pong_timeout", { elapsedMs: elapsed });
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
|
||||
`(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
|
||||
);
|
||||
}, 5_000);
|
||||
return () => clearInterval(id);
|
||||
}, [pingEnabled, remount]);
|
||||
}, [isFocused, pauseWatchdogWhenUnfocused, pingEnabled, triggerRemount]);
|
||||
|
||||
const processHandlers = {
|
||||
onRenderProcessGone: () => remount(),
|
||||
onContentProcessDidTerminate: () => remount(),
|
||||
onRenderProcessGone: (event?: any) => {
|
||||
if (ignoreProcessTerminationWhenUnfocused && !isFocused) {
|
||||
ignoredProcessTerminationRef.current = "render_process_gone";
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
}
|
||||
triggerRemount("render_process_gone", { didCrash: event?.nativeEvent?.didCrash ?? null });
|
||||
},
|
||||
onContentProcessDidTerminate: () => {
|
||||
if (ignoreProcessTerminationWhenUnfocused && !isFocused) {
|
||||
ignoredProcessTerminationRef.current = "content_process_terminated";
|
||||
lastPongAt.current = Date.now();
|
||||
return;
|
||||
}
|
||||
triggerRemount("content_process_terminated");
|
||||
},
|
||||
} as const;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused || !ignoredProcessTerminationRef.current) return;
|
||||
ignoredProcessTerminationRef.current = null;
|
||||
lastPongAt.current = Date.now();
|
||||
}, [isFocused]);
|
||||
|
||||
// pingHandlers は onLoadEnd と onMessage を提供する
|
||||
// 既存の onMessage がある場合は手動でマージすること
|
||||
const pingHandlers = pingEnabled ? {
|
||||
@@ -90,7 +144,7 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
|
||||
setTimeout(() => {
|
||||
if (!isLoadingRef.current) {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
|
||||
`(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
|
||||
);
|
||||
}
|
||||
}, 3000);
|
||||
@@ -112,9 +166,15 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
|
||||
if (maxTextLenRef.current > 20 && len < 5) {
|
||||
blankCountRef.current += 1;
|
||||
if (blankCountRef.current >= 3) {
|
||||
const blankCount = blankCountRef.current;
|
||||
const maxTextLength = maxTextLenRef.current;
|
||||
blankCountRef.current = 0;
|
||||
maxTextLenRef.current = 0;
|
||||
remount();
|
||||
triggerRemount("blank_detected", {
|
||||
blankCount,
|
||||
textLength: len,
|
||||
maxTextLength,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blankCountRef.current = 0;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { lineList_LineWebID } from "@/lib/getStationList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { LocationObject } from "expo-location";
|
||||
import { StationSource } from "@/types";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
configureReanimatedLogger({
|
||||
level: ReanimatedLogLevel.error, // Set the log level to error
|
||||
strict: true, // Reanimated runs in strict mode by default
|
||||
@@ -54,12 +55,73 @@ export const Menu: FC<props> = (props) => {
|
||||
const { favoriteStation } = useFavoriteStation();
|
||||
const { originalStationList, getStationDataFromNameBase } = useStationList();
|
||||
const [stationSource, _setStationSource] = useState<StationSource>({ type: "position" });
|
||||
const [isHeavyContentReady, setIsHeavyContentReady] = useState(Platform.OS !== "android");
|
||||
// 時刻表βの遅延レンダリング(画面遷移アニメーション完了後に描画)
|
||||
const [ledReady, setLedReady] = useState(false);
|
||||
const [ledReady, setLedReady] = useState(Platform.OS !== "android");
|
||||
useEffect(() => {
|
||||
const handle = InteractionManager.runAfterInteractions(() => setLedReady(true));
|
||||
return () => handle.cancel();
|
||||
}, []);
|
||||
if (Platform.OS !== "android") {
|
||||
setIsHeavyContentReady(true);
|
||||
setLedReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (!isMenuFocused) {
|
||||
if (!isHeavyContentReady) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.runtime",
|
||||
level: "info",
|
||||
message: "top menu heavy content preserved on blur",
|
||||
data: { mapMode },
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (isHeavyContentReady && ledReady) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.runtime",
|
||||
level: "info",
|
||||
message: "top menu heavy content activation scheduled",
|
||||
data: { mapMode },
|
||||
});
|
||||
|
||||
const handle = InteractionManager.runAfterInteractions(() => {
|
||||
timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
Sentry.addBreadcrumb({
|
||||
category: "topMenu.runtime",
|
||||
level: "info",
|
||||
message: "top menu heavy content activated",
|
||||
data: { mapMode },
|
||||
});
|
||||
setIsHeavyContentReady(true);
|
||||
setLedReady(true);
|
||||
}, 60);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
handle.cancel();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [isHeavyContentReady, isMenuFocused, ledReady, mapMode]);
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.setContext("top_menu_runtime", {
|
||||
focused: isMenuFocused,
|
||||
mapMode,
|
||||
heavyContentReady: isHeavyContentReady,
|
||||
ledReady,
|
||||
});
|
||||
}, [isHeavyContentReady, isMenuFocused, ledReady, mapMode]);
|
||||
// 検索モードを閉じたときに戻るソースを記憶
|
||||
const prevNonSearchTypeRef = useRef<"position" | "favorite">("position");
|
||||
|
||||
@@ -258,7 +320,9 @@ export const Menu: FC<props> = (props) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listUpStation]);
|
||||
useEffect(() => {
|
||||
if (!isHeavyContentReady) return;
|
||||
if (originalStationList == undefined) return;
|
||||
if (!mapsRef.current) return;
|
||||
if (listUpStation.length == 0) return;
|
||||
if (listUpStation[listIndex] == undefined) return;
|
||||
const { lat, lng } = listUpStation[listIndex][0];
|
||||
@@ -279,7 +343,7 @@ export const Menu: FC<props> = (props) => {
|
||||
} else {
|
||||
mapsRef.current.animateToRegion(mapRegion, 1000);
|
||||
}
|
||||
}, [listIndex, listUpStation]);
|
||||
}, [isHeavyContentReady, listIndex, listUpStation]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -313,83 +377,93 @@ export const Menu: FC<props> = (props) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MapView
|
||||
ref={mapsRef}
|
||||
style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }}
|
||||
showsUserLocation={true}
|
||||
loadingEnabled={true}
|
||||
showsMyLocationButton={false}
|
||||
moveOnMarkerPress={false}
|
||||
showsCompass={false}
|
||||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||||
//provider={PROVIDER_GOOGLE}
|
||||
initialRegion={{
|
||||
latitude: 33.774519,
|
||||
longitude: 133.533306,
|
||||
latitudeDelta: 1.8, //小さくなるほどズーム
|
||||
longitudeDelta: 1.8,
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 300,
|
||||
create: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
update: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
});
|
||||
setMapMode(true);
|
||||
goToMap();
|
||||
}}
|
||||
>
|
||||
{listUpStation.map(([{ lat, lng, StationNumber }], index) => (
|
||||
<Marker
|
||||
key={index + StationNumber}
|
||||
coordinate={{
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
}}
|
||||
anchor={{ x: 0.5, y: 1 }}
|
||||
tracksViewChanges={false}
|
||||
onPress={() => {
|
||||
setMapMode(false);
|
||||
setListIndex(index);
|
||||
if (mapsRef.current) {
|
||||
mapsRef.current.animateToRegion(
|
||||
{
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
latitudeDelta: 0.05,
|
||||
longitudeDelta: 0.05,
|
||||
{isHeavyContentReady ? (
|
||||
<MapView
|
||||
ref={mapsRef}
|
||||
style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }}
|
||||
showsUserLocation={true}
|
||||
loadingEnabled={true}
|
||||
showsMyLocationButton={false}
|
||||
moveOnMarkerPress={false}
|
||||
showsCompass={false}
|
||||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||||
//provider={PROVIDER_GOOGLE}
|
||||
initialRegion={{
|
||||
latitude: 33.774519,
|
||||
longitude: 133.533306,
|
||||
latitudeDelta: 1.8, //小さくなるほどズーム
|
||||
longitudeDelta: 1.8,
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 300,
|
||||
create: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
update: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
});
|
||||
setMapMode(true);
|
||||
goToMap();
|
||||
}}
|
||||
>
|
||||
{listUpStation.map(([{ lat, lng, StationNumber }], index) => (
|
||||
<Marker
|
||||
key={index + StationNumber}
|
||||
coordinate={{
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
}}
|
||||
anchor={{ x: 0.5, y: 1 }}
|
||||
tracksViewChanges={false}
|
||||
onPress={() => {
|
||||
setMapMode(false);
|
||||
setListIndex(index);
|
||||
if (mapsRef.current) {
|
||||
mapsRef.current.animateToRegion(
|
||||
{
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
latitudeDelta: 0.05,
|
||||
longitudeDelta: 0.05,
|
||||
},
|
||||
1000
|
||||
);
|
||||
}
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 300,
|
||||
create: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
1000
|
||||
);
|
||||
}
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 300,
|
||||
create: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
update: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
});
|
||||
returnToTop();
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={require("@/assets/reccha-small.png")}
|
||||
style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Marker>
|
||||
))}
|
||||
</MapView>
|
||||
update: {
|
||||
type: LayoutAnimation.Types.easeInEaseOut,
|
||||
property: LayoutAnimation.Properties.opacity,
|
||||
},
|
||||
});
|
||||
returnToTop();
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={require("@/assets/reccha-small.png")}
|
||||
style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Marker>
|
||||
))}
|
||||
</MapView>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: mapMode ? MapFullHeight : mapHeight,
|
||||
backgroundColor: colors.background,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!mapMode && (
|
||||
<CarouselTypeChanger
|
||||
{...{
|
||||
@@ -405,7 +479,7 @@ export const Menu: FC<props> = (props) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{originalStationList.length != 0 && (
|
||||
{originalStationList.length != 0 && isHeavyContentReady && (
|
||||
<>
|
||||
<CarouselBox
|
||||
{...{
|
||||
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
const {
|
||||
getSentryExpoConfig
|
||||
} = require("@sentry/react-native/metro");
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
const config = getSentryExpoConfig(__dirname);
|
||||
|
||||
// Ensure local Expo modules in modules/ are watched and resolved directly
|
||||
config.watchFolders = [
|
||||
@@ -22,4 +24,4 @@ config.transformer = {
|
||||
experimentalImportSupport: true,
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
module.exports = config;
|
||||
+711
-110
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -22,9 +22,15 @@
|
||||
"react-native-responsive-screen",
|
||||
"typescript",
|
||||
"react",
|
||||
"react-dom"
|
||||
"react-dom",
|
||||
"expo-live-activity",
|
||||
"@react-native-vector-icons/fontawesome",
|
||||
"@react-native-vector-icons/material-icons"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autolinking": {
|
||||
"nativeModulesDir": "./modules"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -43,6 +49,7 @@
|
||||
"@react-navigation/stack": "^7.8.6",
|
||||
"@rneui/base": "5.0.0",
|
||||
"@rneui/themed": "5.0.0",
|
||||
"@sentry/react-native": "~7.11.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"expo": "^55.0.8",
|
||||
"expo-alternate-app-icons": "8.0.0",
|
||||
@@ -51,9 +58,10 @@
|
||||
"expo-build-properties": "~55.0.10",
|
||||
"expo-clipboard": "~55.0.9",
|
||||
"expo-constants": "~55.0.9",
|
||||
"expo-dev-client": "~55.0.18",
|
||||
"expo-dev-client": "~55.0.36",
|
||||
"expo-device": "~55.0.10",
|
||||
"expo-felica-reader": "file:./modules/expo-felica-reader",
|
||||
"expo-document-picker": "~55.0.14",
|
||||
"expo-file-system": "~55.0.23",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.9",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
@@ -73,7 +81,7 @@
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native": "0.83.6",
|
||||
"react-native-actions-sheet": "10.1.2",
|
||||
"react-native-android-widget": "^0.20.1",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
@@ -83,6 +91,7 @@
|
||||
"react-native-responsive-screen": "^1.4.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-share": "^12.3.1",
|
||||
"react-native-sortables": "1.9.4",
|
||||
"react-native-storage": "^1.0.1",
|
||||
"react-native-svg": "15.15.3",
|
||||
@@ -91,23 +100,14 @@
|
||||
"react-native-view-shot": "~4.0.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"typescript": "~5.9.3",
|
||||
"expo-live-activity": "file:./modules/expo-live-activity"
|
||||
"react-native-worklets": "0.7.4",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.14",
|
||||
"babel-preset-expo": "~55.0.12",
|
||||
"baseline-browser-mapping": "^2.10.9"
|
||||
},
|
||||
"resolutions": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"expo-asset": "55.0.10",
|
||||
"expo-constants": "55.0.9",
|
||||
"expo-manifests": "55.0.11",
|
||||
"react-native-worklets": "0.7.2"
|
||||
},
|
||||
"private": true,
|
||||
"name": "jrshikoku",
|
||||
"version": "1.0.0"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"compile-web-script": "npx tsx scripts/compile-web-script.ts",
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"eject": "expo eject",
|
||||
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
|
||||
"checkDiagram": "bash ./check.sh"
|
||||
},
|
||||
"expo": {
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"exclude": [
|
||||
"@rneui/base",
|
||||
"@rneui/themed",
|
||||
"@lottiefiles/dotlottie-react",
|
||||
"expo-felica-reader",
|
||||
"react-native-touchable-scale",
|
||||
"react-native-vector-icons",
|
||||
"react-native-responsive-screen",
|
||||
"typescript",
|
||||
"react",
|
||||
"react-dom"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@bacons/apple-targets": "^4.0.6",
|
||||
"@expo/metro-runtime": "~55.0.6",
|
||||
"@expo/ngrok": "^4.1.3",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@gorhom/bottom-sheet": "^5",
|
||||
"@lottiefiles/dotlottie-react": "^0.18.7",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "0.3.2",
|
||||
"@react-native-vector-icons/fontawesome": "^12.4.3",
|
||||
"@react-native-vector-icons/material-icons": "^12.4.3",
|
||||
"@react-navigation/bottom-tabs": "^7.15.6",
|
||||
"@react-navigation/native": "^7.1.34",
|
||||
"@react-navigation/stack": "^7.8.6",
|
||||
"@rneui/base": "5.0.0",
|
||||
"@rneui/themed": "5.0.0",
|
||||
"@sentry/react-native": "^8.16.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"expo": "^55.0.8",
|
||||
"expo-alternate-app-icons": "8.0.0",
|
||||
"expo-asset": "~55.0.10",
|
||||
"expo-audio": "~55.0.9",
|
||||
"expo-build-properties": "~55.0.10",
|
||||
"expo-clipboard": "~55.0.9",
|
||||
"expo-constants": "~55.0.9",
|
||||
"expo-dev-client": "~55.0.18",
|
||||
"expo-device": "~55.0.10",
|
||||
"expo-document-picker": "~55.0.14",
|
||||
"expo-felica-reader": "file:./modules/expo-felica-reader",
|
||||
"expo-file-system": "~55.0.23",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.9",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-keep-awake": "~55.0.4",
|
||||
"expo-linear-gradient": "~55.0.9",
|
||||
"expo-linking": "~55.0.8",
|
||||
"expo-live-activity": "file:./modules/expo-live-activity",
|
||||
"expo-localization": "~55.0.9",
|
||||
"expo-location": "~55.1.4",
|
||||
"expo-notifications": "~55.0.13",
|
||||
"expo-screen-orientation": "~55.0.9",
|
||||
"expo-sharing": "~55.0.14",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "~55.0.10",
|
||||
"expo-updates": "~55.0.15",
|
||||
"expo-video": "~55.0.11",
|
||||
"expo-web-browser": "~55.0.10",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-actions-sheet": "10.1.2",
|
||||
"react-native-android-widget": "^0.20.1",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-maps": "1.27.2",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-reanimated-carousel": "^4.0.3",
|
||||
"react-native-responsive-screen": "^1.4.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-share": "^12.3.1",
|
||||
"react-native-sortables": "1.9.4",
|
||||
"react-native-storage": "^1.0.1",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-touchable-scale": "^2.2.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-view-shot": "~4.0.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.14",
|
||||
"babel-preset-expo": "~55.0.12",
|
||||
"baseline-browser-mapping": "^2.10.9"
|
||||
},
|
||||
"resolutions": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"expo-asset": "55.0.10",
|
||||
"expo-constants": "55.0.9",
|
||||
"expo-manifests": "55.0.11",
|
||||
"react-native-worklets": "0.7.2"
|
||||
},
|
||||
"private": true,
|
||||
"name": "jrshikoku",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
+112
-7
@@ -7,6 +7,10 @@ import React, {
|
||||
useCallback,
|
||||
FC,
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import * as DocumentPicker from "expo-document-picker";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import Share from "react-native-share";
|
||||
|
||||
import { ASCore } from "../storageControl";
|
||||
import { AS } from "../storageControl";
|
||||
@@ -26,12 +30,16 @@ import {
|
||||
import {
|
||||
TrainRecording,
|
||||
RecordingMeta,
|
||||
RecordingImportResult,
|
||||
saveRecording,
|
||||
loadRecordingList,
|
||||
loadRecordingById,
|
||||
deleteRecordingById,
|
||||
migrateOldRecording,
|
||||
generateRecordingId,
|
||||
buildRecordingExportText as buildRecordingExportTextCore,
|
||||
buildAllRecordingsExportText as buildAllRecordingsExportTextCore,
|
||||
importRecordingsFromText as importRecordingsFromTextCore,
|
||||
} from "../lib/mockApi/trainRecorder";
|
||||
|
||||
import { useNotification } from "../stateBox/useNotifications";
|
||||
@@ -111,6 +119,11 @@ const initialState = {
|
||||
seekToSnapshot: (_index: number) => {},
|
||||
addTrainSnapshot: (_trains: TrainEntry[]) => {},
|
||||
deleteRecording: (_id: string): Promise<void> => Promise.resolve(),
|
||||
exportRecordingFile: (_id: string): Promise<void> => Promise.resolve(),
|
||||
exportAllRecordingsFile: (): Promise<void> => Promise.resolve(),
|
||||
importRecordingFile: (): Promise<RecordingImportResult | null> => Promise.resolve(null),
|
||||
importRecordingsFromText: (_content: string): Promise<RecordingImportResult> =>
|
||||
Promise.resolve({ importedCount: 0, overwrittenCount: 0 }),
|
||||
};
|
||||
|
||||
const TrainMenuContext = createContext(initialState);
|
||||
@@ -136,10 +149,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
const [LoadError, setLoadError] = useState(false);
|
||||
|
||||
// バックエンドAPIベースURL(環境設定から読み込み)
|
||||
const [backendApiBaseUrl, setBackendApiBaseUrl] = useState(
|
||||
const [backendApiBaseUrl, setBackendApiBaseUrl] = useState<string>(
|
||||
BACKEND_API_BASE_URLS.production,
|
||||
);
|
||||
const [diagramTodayUrl, setDiagramTodayUrl] = useState(
|
||||
const [diagramTodayUrl, setDiagramTodayUrl] = useState<string>(
|
||||
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
|
||||
);
|
||||
|
||||
@@ -220,9 +233,14 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
const recordingStartTimeRef = useRef<number>(0);
|
||||
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
|
||||
|
||||
const refreshRecordingList = async () => {
|
||||
const list = await loadRecordingList();
|
||||
setRecordingList(list);
|
||||
};
|
||||
|
||||
// 起動時: 旧フォーマット移行 → 一覧読み込み
|
||||
useEffect(() => {
|
||||
migrateOldRecording().then(() => loadRecordingList()).then(setRecordingList).catch(() => {});
|
||||
migrateOldRecording().then(refreshRecordingList).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 再生ループ: playbackIndex / recorderState / playbackPaused が変わるたびに次へ進める
|
||||
@@ -260,8 +278,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
snapshots: snaps,
|
||||
};
|
||||
await saveRecording(recording);
|
||||
const list = await loadRecordingList();
|
||||
setRecordingList(list);
|
||||
await refreshRecordingList();
|
||||
}
|
||||
setRecorderState('idle');
|
||||
};
|
||||
@@ -306,8 +323,92 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
|
||||
const deleteRecording = async (id: string) => {
|
||||
await deleteRecordingById(id);
|
||||
const list = await loadRecordingList();
|
||||
setRecordingList(list);
|
||||
await refreshRecordingList();
|
||||
};
|
||||
|
||||
const downloadTextFileOnWeb = (fileName: string, content: string) => {
|
||||
const web = globalThis as any;
|
||||
if (!web.document || !web.URL || !web.Blob) {
|
||||
throw new Error('この環境ではファイルのダウンロードに対応していません。');
|
||||
}
|
||||
|
||||
const blob = new web.Blob([content], { type: 'application/json' });
|
||||
const url = web.URL.createObjectURL(blob);
|
||||
const anchor = web.document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
web.document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
web.setTimeout(() => web.URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const shareJsonFile = async (fileName: string, content: string) => {
|
||||
if (Platform.OS === 'web') {
|
||||
downloadTextFileOnWeb(fileName, content);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = new File(Paths.cache, fileName);
|
||||
if (file.exists) {
|
||||
file.delete();
|
||||
}
|
||||
file.create({ overwrite: true });
|
||||
file.write(content);
|
||||
|
||||
await Share.open({
|
||||
title: fileName,
|
||||
subject: fileName,
|
||||
url: file.uri,
|
||||
type: 'application/json',
|
||||
filename: fileName.replace(/\.json$/i, ''),
|
||||
failOnCancel: false,
|
||||
saveToFiles: true,
|
||||
useInternalStorage: true,
|
||||
});
|
||||
};
|
||||
|
||||
const exportRecordingFile = async (id: string) => {
|
||||
const content = await buildRecordingExportTextCore(id);
|
||||
await shareJsonFile(`jrshikoku-recording-${id}.json`, content);
|
||||
};
|
||||
|
||||
const exportAllRecordingsFile = async () => {
|
||||
const content = await buildAllRecordingsExportTextCore();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
await shareJsonFile(`jrshikoku-recordings-${timestamp}.json`, content);
|
||||
};
|
||||
|
||||
const readPickedRecordingText = async (asset: DocumentPicker.DocumentPickerAsset) => {
|
||||
const webFile = (asset as any).file;
|
||||
if (Platform.OS === 'web' && webFile && typeof webFile.text === 'function') {
|
||||
return webFile.text();
|
||||
}
|
||||
|
||||
const file = new File(asset.uri);
|
||||
return file.text();
|
||||
};
|
||||
|
||||
const importRecordingFile = async (): Promise<RecordingImportResult | null> => {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: ['application/json', 'text/json', 'text/plain', '*/*'],
|
||||
copyToCacheDirectory: true,
|
||||
multiple: false,
|
||||
base64: false,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.[0]) return null;
|
||||
|
||||
const content = await readPickedRecordingText(result.assets[0]);
|
||||
const importResult = await importRecordingsFromTextCore(content);
|
||||
await refreshRecordingList();
|
||||
return importResult;
|
||||
};
|
||||
|
||||
const importRecordingsFromText = async (content: string): Promise<RecordingImportResult> => {
|
||||
const importResult = await importRecordingsFromTextCore(content);
|
||||
await refreshRecordingList();
|
||||
return importResult;
|
||||
};
|
||||
|
||||
/** PosNum + Line → Pos テキスト(位置マスターから) */
|
||||
@@ -472,6 +573,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
|
||||
seekToSnapshot,
|
||||
addTrainSnapshot,
|
||||
deleteRecording,
|
||||
exportRecordingFile,
|
||||
exportAllRecordingsFile,
|
||||
importRecordingFile,
|
||||
importRecordingsFromText,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user