diff --git a/App.tsx b/App.tsx index 5c1127a..c61cc83 100644 --- a/App.tsx +++ b/App.tsx @@ -25,7 +25,7 @@ import { buildProvidersTree } from "./lib/providerTreeProvider"; import { StationListProvider } from "./stateBox/useStationList"; import { NotificationProvider } from "./stateBox/useNotifications"; import { UserPositionProvider } from "./stateBox/useUserPosition"; -import { rootNavigationRef } from "./lib/rootNavigation"; +import { rootNavigationRef, stackAwareNavigate } from "./lib/rootNavigation"; import { AppThemeProvider } from "./lib/theme"; import StatusbarDetect from "./StatusbarDetect"; @@ -54,12 +54,12 @@ export default function App() { return; } - rootNavigationRef.navigate("topMenu", { + stackAwareNavigate("topMenu", { screen: "setting", params: { screen: "FelicaHistoryPage", }, - } as any); + }); }; const navigateWhenReady = ( @@ -87,26 +87,30 @@ export default function App() { navigateWhenReady(() => openFelicaPage(), url, retryCount); } else if (normalized.includes("open/traininfo")) { navigateWhenReady(() => { - rootNavigationRef.navigate("topMenu", { screen: "menu" } as any); + stackAwareNavigate("topMenu", { screen: "menu" }); setTimeout(() => { SheetManager.show("JRSTraInfo"); }, 450); }, url, retryCount); } else if (normalized.includes("open/operation")) { navigateWhenReady(() => { - rootNavigationRef.navigate("information" as any); + stackAwareNavigate("information"); }, url, retryCount); } else if (normalized.includes("open/settings")) { navigateWhenReady(() => { - rootNavigationRef.navigate("topMenu", { + stackAwareNavigate("topMenu", { screen: "setting", - } as any); + }); }, url, retryCount); } else if (normalized.includes("open/topmenu")) { navigateWhenReady(() => { - rootNavigationRef.navigate("topMenu", { + stackAwareNavigate("topMenu", { screen: "menu", - } as any); + }); + }, url, retryCount); + } else if (normalized.includes("positions/apps")) { + navigateWhenReady(() => { + stackAwareNavigate("positions"); }, url, retryCount); } }; diff --git a/Apps.tsx b/Apps.tsx index 600b009..f2322d5 100644 --- a/Apps.tsx +++ b/Apps.tsx @@ -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 } from "react-native"; +import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar } from "react-native"; import { useNavigationState } from "@react-navigation/native"; import { useFonts } from "expo-font"; import { LinearGradient } from "expo-linear-gradient"; @@ -68,9 +68,7 @@ export function AppContainer() { config: { screens: { positions: { - screens: { - Apps: "positions/apps", - }, + screens: {}, }, topMenu: { screens: { @@ -124,11 +122,11 @@ export function AppContainer() { linking={linking} theme={isDark ? DarkTheme : DefaultTheme} onStateChange={(state) => { - const hasExtra = state?.routes?.some((r) => (r.state?.index ?? 0) > 0) ?? false; + const activeRoute = state?.routes?.[state?.index ?? 0]; + const hasExtra = (activeRoute?.state?.index ?? 0) > 0; setIsExtraWindowOpen(hasExtra); }} > - {/* @ts-expect-error - Tab.Navigator type definition issue */} { @@ -138,8 +136,6 @@ export function AppContainer() { const defaultInactive = isDark ? "#8e8e93" : "#8e8e93"; return { lazy: false, - tabBarHideOnKeyboard: Platform.OS === "android", - animation: Platform.OS === "ios" ? "none" : "shift", sceneContainerStyle: { backgroundColor: defaultBg }, tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive, tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive, diff --git a/GeneralWebView.tsx b/GeneralWebView.tsx index a9a19d7..080c049 100644 --- a/GeneralWebView.tsx +++ b/GeneralWebView.tsx @@ -1,9 +1,17 @@ -import React, { CSSProperties } from "react"; -import { Alert, BackHandler, View, ViewProps } from "react-native"; +import React from "react"; +import { Alert, BackHandler, View } from "react-native"; import { WebView } from "react-native-webview"; import { BigButton } from "./components/atom/BigButton"; import { useFocusEffect, useNavigation } from "@react-navigation/native"; import { useThemeColors } from "@/lib/theme"; +import { AS } from "./storageControl"; +import { STORAGE_KEYS } from "@/constants"; +import { + DEFAULT_JR_DATA_SYSTEM_ENV, + normalizeJrDataSystemEnvironment, + rewriteJrDataSystemUrl, +} from "@/lib/jrDataSystemEnvironment"; + export default ({ route }) => { if (!route.params) { return null; @@ -13,8 +21,38 @@ export default ({ route }) => { const { fixed } = useThemeColors(); const webViewRef = React.useRef(null); const [canGoBack, setCanGoBack] = React.useState(false); + const [selectedEnvironment, setSelectedEnvironment] = React.useState( + DEFAULT_JR_DATA_SYSTEM_ENV, + ); + const [resolvedUri, setResolvedUri] = React.useState(""); + const [isEnvironmentReady, setIsEnvironmentReady] = React.useState(false); const hasAlerted = React.useRef(false); + React.useEffect(() => { + let isMounted = true; + + const applyEnvironment = (value: unknown) => { + if (!isMounted) return; + const nextEnvironment = normalizeJrDataSystemEnvironment(value); + setSelectedEnvironment(nextEnvironment); + setResolvedUri( + rewriteJrDataSystemUrl( + typeof uri === "string" ? uri : "", + nextEnvironment, + ), + ); + setIsEnvironmentReady(true); + }; + + AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV) + .then(applyEnvironment) + .catch(() => applyEnvironment(DEFAULT_JR_DATA_SYSTEM_ENV)); + + return () => { + isMounted = false; + }; + }, [uri]); + useFocusEffect( React.useCallback(() => { const onHardwareBack = () => { @@ -32,15 +70,31 @@ export default ({ route }) => { ); return ( - { + {isEnvironmentReady && ( + { + if (request.isTopFrame === false) { + return true; + } + + const rewrittenUrl = rewriteJrDataSystemUrl( + request.url, + selectedEnvironment, + ); + if (rewrittenUrl !== request.url) { + setResolvedUri(rewrittenUrl); + return false; + } + return true; + }} + onNavigationStateChange={(navState) => { setCanGoBack(navState.canGoBack); if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") { - goBack(); + webViewRef.current?.goBack(); if (!hasAlerted.current) { hasAlerted.current = true; Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [ @@ -49,13 +103,14 @@ export default ({ route }) => { } } }} - onMessage={(event) => { - const { data } = event.nativeEvent; - const { type } = JSON.parse(data); - if (type === "back") return webViewRef.current?.goBack(); - if (type === "windowClose") return goBack(); - }} - /> + onMessage={(event) => { + const { data } = event.nativeEvent; + const { type } = JSON.parse(data); + if (type === "back") return webViewRef.current?.goBack(); + if (type === "windowClose") return goBack(); + }} + /> + )} {useExitButton && } ); diff --git a/MenuPage.tsx b/MenuPage.tsx index 1cb357f..b4b5f40 100644 --- a/MenuPage.tsx +++ b/MenuPage.tsx @@ -84,7 +84,14 @@ export function MenuPage() { setMapFullHeight(MapFullHeight); }, [height, tabBarHeight, width]); useEffect(() => { - const unsubscribe = addListener("tabPress", (e) => { + const unsubscribe = addListener("tabPress", (e: any) => { + if (navigation.isFocused() && stackNavRef.current) { + if (stackNavRef.current.getState()?.index > 0) { + e.preventDefault(); + stackNavRef.current.goBack(); + return; + } + } scrollRef.current?.scrollTo({ y: mapHeightRef.current - verticalScale(80), animated: true, @@ -106,10 +113,16 @@ export function MenuPage() { return unsubscribe; }, [navigation]); + const stackNavRef = useRef(null); + return ( { + stackNavRef.current = stackNav; + return {}; + }} > { - const { isDark } = useThemeColors(); - const barStyle = isDark ? "light-content" : "dark-content"; - return ; + return ; }; export default StatusbarDetect; diff --git a/Top.tsx b/Top.tsx index 0621675..395bb71 100644 --- a/Top.tsx +++ b/Top.tsx @@ -16,6 +16,7 @@ import { news } from "./config/newsUpdate"; import { Linking, Platform } from "react-native"; import GeneralWebView from "./GeneralWebView"; import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView"; +import { positionsStackNavRef } from "./lib/rootNavigation"; const Stack = createStackNavigator(); export const Top = () => { const { webview } = useCurrentTrain(); @@ -38,14 +39,22 @@ export const Top = () => { return unsubscribe; }, []); - const goToTrainMenu = useCallback(() => { + const stackNavRef = positionsStackNavRef; + + const goToTrainMenu = useCallback((e: any) => { if (Platform.OS === "web") { Linking.openURL("https://train.jr-shikoku.co.jp/"); setTimeout(() => navigate("topMenu", { screen: "menu" }), 100); return; } - if (!isFocused()) navigate("positions", { screen: "Apps" }); - else if (mapSwitchRef.current == "true") + if (!isFocused()) return; + const stackNav = stackNavRef.current; + if (stackNav && stackNav.getState()?.index > 0) { + e.preventDefault(); + stackNav.goBack(); + return; + } + if (mapSwitchRef.current == "true") navigate("positions", { screen: "trainMenu" }); else webview.current?.injectJavaScript(`AccordionClassEvent()`); return; @@ -60,6 +69,10 @@ export const Top = () => { { + stackNavRef.current = stackNav; + return {}; + }} > { const actionSheetRef = useRef(null); const maxHeight = useSheetMaxHeight(); + const [sheetOpened, setSheetOpened] = useState(false); + + const handleOpen = () => { + setSheetOpened(true); + }; + + const handleClose = () => { + setSheetOpened(false); + }; + if (!payload) return <>; return ( { drawUnderStatusBar={false} isModal={Platform.OS === "ios" && !Platform.isPad} containerStyle={{ maxHeight }} - + onOpen={handleOpen} + onClose={handleClose} //useBottomSafeAreaPadding={Platform.OS == "android"} > - + ); }; diff --git a/components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx b/components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx index 34b6435..f5363d8 100644 --- a/components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx +++ b/components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx @@ -13,6 +13,7 @@ import { useStationList } from "../../../stateBox/useStationList"; import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram"; import { customTrainDataDetector } from "@/components/custom-train-data"; import type { NavigateFunction } from "@/types"; +import { stackAwareNavigate } from "@/lib/rootNavigation"; type props = { @@ -163,16 +164,16 @@ export const TrainDataView:FC = ({ //disabled={!onLine} //onLongPress={openEditWindow} onLongPress={()=>{ - if (!onLine) return; + if (!currentTrainData) return; setInjectData({ type:"train", value:currentTrainData?.num, fixed:true}); - navigate("positions", { screen: "Apps" }); + stackAwareNavigate("positions"); SheetManager.hide("EachTrainInfo"); }} onPress={() => { if (!onLine) return; setInjectData({ type: "station", value: currentPosition[0], fixed: false }); - navigate("positions", { screen: "Apps" }); + stackAwareNavigate("positions"); SheetManager.hide("EachTrainInfo"); }} > diff --git a/components/ActionSheetComponents/EachTrainInfoCore.tsx b/components/ActionSheetComponents/EachTrainInfoCore.tsx index 721b572..4e37c4f 100644 --- a/components/ActionSheetComponents/EachTrainInfoCore.tsx +++ b/components/ActionSheetComponents/EachTrainInfoCore.tsx @@ -22,6 +22,7 @@ import { ShowSpecialTrain } from "./EachTrainInfo/ShowSpecialTrain"; import { useTrainMenu } from "../../stateBox/useTrainMenu"; import { HeaderText } from "./EachTrainInfoCore/HeaderText"; import { useStationList } from "../../stateBox/useStationList"; +import { useCurrentTrain } from "../../stateBox/useCurrentTrain"; import { useThemeColors } from "@/lib/theme"; import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram"; import { useResponsive } from "@/lib/responsive"; @@ -41,6 +42,7 @@ export const EachTrainInfoCore = ({ openStationACFromEachTrainInfo, from, navigate, + sheetOpened = false, }) => { const { stationList } = useStationList(); const { allCustomTrainData } = useAllTrainDiagram(); @@ -49,6 +51,7 @@ export const EachTrainInfoCore = ({ const { setTrainInfo } = useTrainMenu(); const { height } = useWindowDimensions(); const { isLandscape } = useDeviceOrientationChange(); + const { getCurrentStationData } = useCurrentTrain(); const scrollRef = useRef(null); // Custom hooks for data management @@ -72,7 +75,9 @@ export const EachTrainInfoCore = ({ } = useExtendedStations(trainData, setTrainData); // UI state - const [showThrew, setShowThrew] = useState(false); + // 走行中の列車は初期状態から通過駅を表示する(後から showThrew を true に変更すると + // ActionSheet の onSheetLayout が再発火してスプリングアニメーションが途中でリスタートするため) + const [showThrew, setShowThrew] = useState(() => !!getCurrentStationData(data.trainNum)); const [isJumped, setIsJumped] = useState(false); // Auto scroll to current position @@ -82,7 +87,7 @@ export const EachTrainInfoCore = ({ scrollRef, isJumped, setIsJumped, - setShowThrew + sheetOpened ); // Back button handler diff --git a/components/ActionSheetComponents/EachTrainInfoCore/HeaderText.tsx b/components/ActionSheetComponents/EachTrainInfoCore/HeaderText.tsx index 71532fe..3648fda 100644 --- a/components/ActionSheetComponents/EachTrainInfoCore/HeaderText.tsx +++ b/components/ActionSheetComponents/EachTrainInfoCore/HeaderText.tsx @@ -59,6 +59,7 @@ export const HeaderText: FC = ({ getUnyohubByTrainNumber, getUnyohubEntriesByTrainNumber, useUnyohub: unyohubEnabled, + unyohubData, } = useUnyohub(); const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } = useElesite(); @@ -160,9 +161,8 @@ export const HeaderText: FC = ({ } }, [trainData, trainNum, allCustomTrainData]); - const todayOperation = getTodayOperationByTrainId(trainNum).filter( - (d) => d.state !== 100, - ); + const allTodayOperation = getTodayOperationByTrainId(trainNum); + const todayOperation = allTodayOperation.filter((d) => d.state !== 100); let iconTrainDirection = parseInt(trainNum.replace(/[^\d]/g, "")) % 2 == 0 ? true : false; @@ -171,11 +171,34 @@ export const HeaderText: FC = ({ } const unyohubLookupNum = customTrainData?.train_number_override || trainNum; + const isFreightRetsuban = unyohubLookupNum.includes("レ"); + const unyohubTrainNumForSourceScreen = isFreightRetsuban + ? unyohubLookupNum.replace(/レ/g, "") + : unyohubLookupNum; + const freightUnyohubCandidates = (() => { + const digits = unyohubTrainNumForSourceScreen.replace(/[^\d]/g, ""); + const candidates = new Set(); + if (!digits) return candidates; + candidates.add(digits); + if (/^\d{2}$/.test(digits)) { + candidates.add(`30${digits}`); + candidates.add(`90${digits}`); + } else if (/^(30|90)\d{2}$/.test(digits)) { + candidates.add(digits.slice(-2)); + } + return candidates; + })(); const unyohubFormation = getUnyohubByTrainNumber(unyohubLookupNum); - const unyohubEntries = getUnyohubEntriesByTrainNumber(unyohubLookupNum); + const unyohubEntries = isFreightRetsuban + ? unyohubData.filter((unyo) => + unyo.trains?.some( + (t) => !!t.train_number && freightUnyohubCandidates.has(t.train_number), + ), + ) + : getUnyohubEntriesByTrainNumber(unyohubTrainNumForSourceScreen); const elesiteEntries = getElesiteEntriesByTrainNumber(trainNum); - // 車番(formations)が空でないエントリが1件以上あれば「運用Hub情報あり」と判定 + // 車番(formations) がある場合のみ「運用Hub情報あり」と判定 const hasUnyohubFormation = unyohubEntries.some( (e) => !!e.formations && e.formations.trim() !== "", ); @@ -288,7 +311,7 @@ export const HeaderText: FC = ({ (SheetManager.show as any)("TrainDataSources", { payload: { trainNum, - unyohubTrainNum: unyohubLookupNum, + unyohubTrainNum: unyohubTrainNumForSourceScreen, unyohubEntries, elesiteEntries, todayOperation, diff --git a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useAutoScroll.ts b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useAutoScroll.ts index e719341..5374558 100644 --- a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useAutoScroll.ts +++ b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useAutoScroll.ts @@ -1,5 +1,4 @@ import { useEffect, MutableRefObject } from 'react'; -import { InteractionManager } from 'react-native'; export const useAutoScroll = ( @@ -8,31 +7,27 @@ export const useAutoScroll = ( scrollRef: MutableRefObject, isJumped: boolean, setIsJumped: (value: boolean) => void, - setShowThrew: (value: boolean) => void + sheetOpened: boolean = false ) => { useEffect(() => { - if (isJumped || !points?.length || !scrollRef) return; + // ActionSheetのスプリングアニメーション完了後まで待機 + if (!sheetOpened || isJumped || !points?.length || !scrollRef) return; const currentPositionIndex = points.findIndex((d) => d === true); if (currentPositionIndex === -1) return; - // ActionSheetの開閉アニメーション完了後にレイアウト変更を行う - const handle = InteractionManager.runAfterInteractions(() => { - setShowThrew(true); + // 5駅以内の場合はスクロールしない + if (currentPositionIndex < 5) { + setIsJumped(true); + return; + } - // 5駅以内の場合はスクロールしない - if (currentPositionIndex < 5) { - setIsJumped(true); - return; - } + const scrollPosition = currentPositionIndex * 44 - 50; + const timer = setTimeout(() => { + scrollRef.current?.scrollTo({ y: scrollPosition, animated: true }); + setIsJumped(true); + }, 100); - const scrollPosition = currentPositionIndex * 44 - 50; - setTimeout(() => { - scrollRef.current?.scrollTo({ y: scrollPosition, animated: true }); - setIsJumped(true); - }, 100); - }); - - return () => handle.cancel(); - }, [points, trainDataWithThrough, scrollRef, isJumped, setIsJumped, setShowThrew]); + return () => clearTimeout(timer); + }, [sheetOpened, points, trainDataWithThrough, scrollRef, isJumped, setIsJumped]); }; diff --git a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useStopStationIDs.ts b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useStopStationIDs.ts index 30b4647..468a42f 100644 --- a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useStopStationIDs.ts +++ b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useStopStationIDs.ts @@ -1,25 +1,25 @@ import { useState, useEffect } from 'react'; import { useStationList } from '@/stateBox/useStationList'; +const computeStopStationIDs = (data: string[], stationList: any[][]): string[][] => + data.map((item) => { + const [stationName] = item.split(','); + return stationList + .map((lineStations) => lineStations.filter((s) => s.StationName === stationName)) + .reduce((acc, s) => acc.concat(s), []) + .map((s) => s.StationNumber); + }); + export const useStopStationIDs = (trainDataWithThrough: string[]) => { const { stationList } = useStationList(); - const [stopStationIDList, setStopStationIDList] = useState([]); + + // 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ + const [stopStationIDList, setStopStationIDList] = useState(() => + computeStopStationIDs(trainDataWithThrough, stationList) + ); useEffect(() => { - const stationIDs = trainDataWithThrough.map((item) => { - const [stationName] = item.split(','); - - const matchingStations = stationList - .map((lineStations) => - lineStations.filter((station) => station.StationName === stationName) - ) - .reduce((acc, stations) => acc.concat(stations), []) - .map((station) => station.StationNumber); - - return matchingStations; - }); - - setStopStationIDList(stationIDs); + setStopStationIDList(computeStopStationIDs(trainDataWithThrough, stationList)); }, [trainDataWithThrough, stationList]); return stopStationIDList; diff --git a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useThroughStations.ts b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useThroughStations.ts index aed14c3..8fef145 100644 --- a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useThroughStations.ts +++ b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useThroughStations.ts @@ -2,107 +2,114 @@ import { useState, useEffect } from 'react'; import { lineListPair, stationIDPair } from '@/lib/getStationList'; import { useStationList } from '@/stateBox/useStationList'; -export const useThroughStations = (trainData) => { - const { originalStationList, stationList } = useStationList(); - const [trainDataWithThrough, setTrainDataWithThrough] = useState([]); - const [haveThrough, setHaveThrough] = useState(false); +const computeThroughStations = ( + trainData: string[], + stationList: any[][], + originalStationList: Record +): { trainDataWithThrough: string[]; haveThrough: boolean } => { + if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false }; - useEffect(() => { - if (!trainData.length) { - setTrainDataWithThrough([]); - return; + let haveThrough = false; + const isCancel: boolean[] = []; + + const stopStationList = trainData.map((item, index, array) => { + const [station, se] = item.split(','); + const [, nextSe] = array[index + 1]?.split(',') || []; + + if (nextSe) { + // 運休判定ロジック: + // 1. 両方が休系(休編、休発、休着など)→ 運休区間 + // 2. 着/着編 → 休発/休発編:到着後に運休開始 → 通過駅は通常運行 + // 3. 休着/休着編 → 発/発編:運休終了後に出発 → 通過駅は通常運行 + // 4. その他の休の組み合わせ → 運休区間 + const bothCanceled = se.includes('休') && nextSe.includes('休'); + const normalArrivalToSuspendStart = + (se === '着' || se === '着編') && (nextSe.includes('休') && nextSe.includes('発')); + const suspendEndToNormalDeparture = + (se.includes('休') && se.includes('着')) && (nextSe === '発' || nextSe === '発編'); + + isCancel.push(bothCanceled && !normalArrivalToSuspendStart && !suspendEndToNormalDeparture); } - const isCancel = []; - const stopStationList = trainData.map((item, index, array) => { - const [station, se] = item.split(','); - const [, nextSe] = array[index + 1]?.split(',') || []; + if (se === '通編') haveThrough = true; - if (nextSe) { - // 運休判定ロジック: - // 1. 両方が休系(休編、休発、休着など)→ 運休区間 - // 2. 着/着編 → 休発/休発編:到着後に運休開始 → 通過駅は通常運行 - // 3. 休着/休着編 → 発/発編:運休終了後に出発 → 通過駅は通常運行 - // 4. その他の休の組み合わせ → 運休区間 - const bothCanceled = se.includes('休') && nextSe.includes('休'); - const normalArrivalToSuspendStart = - (se === '着' || se === '着編') && (nextSe.includes('休') && nextSe.includes('発')); - const suspendEndToNormalDeparture = - (se.includes('休') && se.includes('着')) && (nextSe === '発' || nextSe === '発編'); - - const isCanceled = bothCanceled && !normalArrivalToSuspendStart && !suspendEndToNormalDeparture; - isCancel.push(isCanceled); + return stationList.map((a) => a.filter((d) => d.StationName === station)); + }); + + const allThroughStationList = stopStationList.map((firstItem, index, array) => { + if (index === array.length - 1) return []; + + const secondItem = array[index + 1]; + let betweenStationLine = ''; + let baseStationNumberFirst = ''; + let baseStationNumberSecond = ''; + + Object.keys(stationIDPair).forEach((lineName, lineIndex) => { + if (!lineName) return; + const haveFirst = firstItem[lineIndex]; + const haveSecond = secondItem[lineIndex]; + + if (haveFirst?.length && haveSecond?.length) { + betweenStationLine = lineName; + baseStationNumberFirst = haveFirst[0].StationNumber; + baseStationNumberSecond = haveSecond[0].StationNumber; } - - if (se === '通編') setHaveThrough(true); - - return stationList.map((a) => a.filter((d) => d.StationName === station)); }); - const allThroughStationList = stopStationList.map((firstItem, index, array) => { - if (index === array.length - 1) return []; + if (!betweenStationLine) return []; - const secondItem = array[index + 1]; - let betweenStationLine = ''; - let baseStationNumberFirst = ''; - let baseStationNumberSecond = ''; + const allThroughStation: string[] = []; + let reverse = false; - Object.keys(stationIDPair).forEach((lineName, lineIndex) => { - if (!lineName) return; - const haveFirst = firstItem[lineIndex]; - const haveSecond = secondItem[lineIndex]; + originalStationList[lineListPair[stationIDPair[betweenStationLine]]]?.forEach((station) => { + const throughStatus = isCancel[index] ? '通休編' : '通過'; - if (haveFirst?.length && haveSecond?.length) { - betweenStationLine = lineName; - baseStationNumberFirst = haveFirst[0].StationNumber; - baseStationNumberSecond = haveSecond[0].StationNumber; - } - }); - - if (!betweenStationLine) return []; - - const allThroughStation = []; - let reverse = false; - - originalStationList[lineListPair[stationIDPair[betweenStationLine]]]?.forEach((station) => { - const throughStatus = isCancel[index] ? '通休編' : '通過'; - - if ( - station.StationNumber > baseStationNumberFirst && - station.StationNumber < baseStationNumberSecond - ) { - allThroughStation.push(`${station.Station_JP},${throughStatus},`); - setHaveThrough(true); - reverse = false; - } else if ( - station.StationNumber < baseStationNumberFirst && - station.StationNumber > baseStationNumberSecond - ) { - allThroughStation.push(`${station.Station_JP},${throughStatus},`); - setHaveThrough(true); - reverse = true; - } - }); - - if (reverse) allThroughStation.reverse(); - return allThroughStation; + if ( + station.StationNumber > baseStationNumberFirst && + station.StationNumber < baseStationNumberSecond + ) { + allThroughStation.push(`${station.Station_JP},${throughStatus},`); + haveThrough = true; + reverse = false; + } else if ( + station.StationNumber < baseStationNumberFirst && + station.StationNumber > baseStationNumberSecond + ) { + allThroughStation.push(`${station.Station_JP},${throughStatus},`); + haveThrough = true; + reverse = true; + } }); - let mainArray = [...trainData]; - let offset = 0; + if (reverse) allThroughStation.reverse(); + return allThroughStation; + }); - trainData.forEach((_, index) => { - offset += 1; - const throughStations = allThroughStationList[index]; - - if (!throughStations?.length) return; + let mainArray = [...trainData]; + let offset = 0; - mainArray.splice(offset, 0, ...throughStations); - offset += throughStations.length; - }); + trainData.forEach((_, index) => { + offset += 1; + const throughStations = allThroughStationList[index]; + if (!throughStations?.length) return; + mainArray.splice(offset, 0, ...throughStations); + offset += throughStations.length; + }); - setTrainDataWithThrough(mainArray); + return { trainDataWithThrough: mainArray, haveThrough }; +}; + +export const useThroughStations = (trainData) => { + const { originalStationList, stationList } = useStationList(); + + // 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ + const [state, setState] = useState(() => + computeThroughStations(trainData, stationList, originalStationList) + ); + + useEffect(() => { + setState(computeThroughStations(trainData, stationList, originalStationList)); }, [trainData, stationList, originalStationList]); - return { trainDataWithThrough, haveThrough }; + return { trainDataWithThrough: state.trainDataWithThrough, haveThrough: state.haveThrough }; }; diff --git a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useTrainDiagramData.ts b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useTrainDiagramData.ts index 9e098cb..f94293a 100644 --- a/components/ActionSheetComponents/EachTrainInfoCore/hooks/useTrainDiagramData.ts +++ b/components/ActionSheetComponents/EachTrainInfoCore/hooks/useTrainDiagramData.ts @@ -2,28 +2,34 @@ import { useState, useEffect } from 'react'; import { useAllTrainDiagram } from '@/stateBox/useAllTrainDiagram'; import { searchSpecialTrain } from '@/lib/eachTrainInfoCoreLib/searchSpecialTrain'; +const parseTrainData = (trainNum: string, trainList: Record) => { + if (!trainNum) return { data: [], trueIDs: [] }; + const TD = trainList[trainNum]; + if (!TD) { + const specialTrainActualIDs = searchSpecialTrain(trainNum, trainList); + return { data: [], trueIDs: specialTrainActualIDs || [] }; + } + return { data: TD.split('#').filter((d) => d !== ''), trueIDs: [] }; +}; + export const useTrainDiagramData = (trainNum) => { const { allTrainDiagram: trainList } = useAllTrainDiagram(); - const [trainData, setTrainData] = useState([]); - const [trueTrainID, setTrueTrainID] = useState([]); const [isManuallyExtended, setIsManuallyExtended] = useState(false); + // 初回レンダリング時にコンテキストから同期的にデータを取得することで + // ActionSheetのアニメーション中に高さが変わるのを防ぐ + const [trainData, setTrainData] = useState(() => parseTrainData(trainNum, trainList).data); + const [trueTrainID, setTrueTrainID] = useState(() => parseTrainData(trainNum, trainList).trueIDs); + useEffect(() => { if (!trainNum) return; - + // 手動で拡張されている場合は上書きしない if (isManuallyExtended) return; - - const TD = trainList[trainNum]; - - if (!TD) { - const specialTrainActualIDs = searchSpecialTrain(trainNum, trainList); - setTrueTrainID(specialTrainActualIDs || []); - setTrainData([]); - return; - } - - setTrainData(TD.split('#').filter((d) => d !== '')); + + const { data, trueIDs } = parseTrainData(trainNum, trainList); + setTrueTrainID(trueIDs); + setTrainData(data); }, [trainNum, trainList, isManuallyExtended]); const setTrainDataExtended = (data) => { diff --git a/components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx b/components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx index c291c90..9dcbb5f 100644 --- a/components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx +++ b/components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx @@ -132,7 +132,7 @@ export const TrainIconStatus: FC = (props) => { fetch( `https://n8n.haruk.in/webhook/${anpanmanApiPath}?trainNum=${ data.trainNum - }&month=${dayjs().format("M")}&day=${dayjs().format("D")}` + }&month=${dayjs().format("M")}&day=${dayjs().format("D")}`,{ cache: "no-store" } ) .then((d) => d.json()) .then((d) => { diff --git a/components/ActionSheetComponents/JRSTraInfo.tsx b/components/ActionSheetComponents/JRSTraInfo.tsx index 606c2a4..4ebfd76 100644 --- a/components/ActionSheetComponents/JRSTraInfo.tsx +++ b/components/ActionSheetComponents/JRSTraInfo.tsx @@ -36,6 +36,10 @@ export const JRSTraInfo = () => { const maxHeight = useSheetMaxHeight(); const viewShot = useRef(null); + useEffect(() => { + setLoadingDelayData(true); + }, []); + const onCapture = async () => { const url = await viewShot.current.capture(); diff --git a/components/ActionSheetComponents/StationDeteilView/StationTrainPositionButton.tsx b/components/ActionSheetComponents/StationDeteilView/StationTrainPositionButton.tsx index b2c4f69..965b449 100644 --- a/components/ActionSheetComponents/StationDeteilView/StationTrainPositionButton.tsx +++ b/components/ActionSheetComponents/StationDeteilView/StationTrainPositionButton.tsx @@ -3,6 +3,7 @@ import { TouchableOpacity, View, Text, Linking } from "react-native"; import { useCurrentTrain } from "@/stateBox/useCurrentTrain"; import { useThemeColors } from "@/lib/theme"; import AntDesign from "react-native-vector-icons/AntDesign"; +import { stackAwareNavigate } from "@/lib/rootNavigation"; type Props = { stationNumber: string; onExit: () => void; @@ -24,12 +25,12 @@ export const StationTrainPositionButton: FC = (props) => { flex: 1, }} onLongPress={() => { - navigate("positions", { screen: "Apps" }); + stackAwareNavigate("positions"); setInjectData({ type: "station", value:stationNumber, fixed: true }); onExit(); }} onPress={() => { - navigate("positions", { screen: "Apps" }); + stackAwareNavigate("positions"); setInjectData({ type: "station", value: stationNumber, fixed: false }); onExit(); }} diff --git a/components/ActionSheetComponents/TrainDataSources.tsx b/components/ActionSheetComponents/TrainDataSources.tsx index 27f2885..30ceaa2 100644 --- a/components/ActionSheetComponents/TrainDataSources.tsx +++ b/components/ActionSheetComponents/TrainDataSources.tsx @@ -51,7 +51,7 @@ export type TrainDataSourcesPayload = { }; const HUB_LOGO_PNG = require("@/assets/relationLogo/unyohub_logo.webp"); -const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.png"); +const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.jpg"); /** ISO 8601 日時文字列を "HH:MM" 形式にフォーマット */ const formatHHMM = (iso: string): string => { @@ -128,7 +128,27 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({ destinationStation, } = payload; - const hubTrainNum = unyohubTrainNumProp || trainNum; + const isFreightRetsuban = trainNum.includes("レ"); + const hubTrainNum = (unyohubTrainNumProp || trainNum).replace(/レ/g, ""); + const freightUnyohubCandidates = (() => { + const digits = hubTrainNum.replace(/[^\d]/g, ""); + const candidates = new Set(); + if (!digits) return candidates; + candidates.add(digits); + if (/^\d{2}$/.test(digits)) { + candidates.add(`30${digits}`); + candidates.add(`90${digits}`); + } else if (/^(30|90)\d{2}$/.test(digits)) { + candidates.add(digits.slice(-2)); + } + return candidates; + })(); + const matchesHubTrainNum = (candidate?: string | null): boolean => { + if (!candidate) return false; + if (candidate === hubTrainNum) return true; + if (!isFreightRetsuban) return false; + return freightUnyohubCandidates.has(candidate); + }; // 進行方向の確定: // 1. payload.direction が明示されていればそれを使う @@ -226,7 +246,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({ ); - // 鉄道運用Hub: 車番(formations)が空でないエントリのみ抽出して判定 + // 鉄道運用Hub: 車番(formations) が空でないエントリのみ表示対象にする const hasNonEmptyFormations = unyohubEntries.some( (e) => !!e.formations && e.formations.trim() !== "", ); @@ -269,7 +289,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({ // outbound → position_forward 昇順 (pos=1 が宇和島/南端側) // inbound → position_forward 降順 (pos=MAX が宇和島/南端側) const matchedDirection = nonEmptyFormationEntries[0]?.trains?.find( - (t) => t.train_number === hubTrainNum, + (t) => matchesHubTrainNum(t.train_number), )?.direction; const hubSortDescending = matchedDirection === "inbound"; @@ -277,10 +297,10 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({ [...nonEmptyFormationEntries] .sort((a, b) => { const posA = - a.trains?.find((t) => t.train_number === hubTrainNum) + a.trains?.find((t) => matchesHubTrainNum(t.train_number)) ?.position_forward ?? 0; const posB = - b.trains?.find((t) => t.train_number === hubTrainNum) + b.trains?.find((t) => matchesHubTrainNum(t.train_number)) ?.position_forward ?? 0; return hubSortDescending ? posB - posA : posA - posB; }) @@ -942,15 +962,28 @@ const TrainInfoDetail: FC<{ {/* うわさ / optional_text */} {(!!uwasa || !!optional_text) && ( - + {!!uwasa && ( - + - {uwasa} + + 噂情報 + {uwasa} + )} {!!optional_text && ( @@ -1302,15 +1335,35 @@ const styles = StyleSheet.create({ fontSize: 11, }, noteSection: { - gap: 4, - borderLeftWidth: 2, - paddingLeft: 8, + gap: 6, + marginTop: 4, + paddingRight: 10, }, noteRow: { flexDirection: "row", alignItems: "flex-start", gap: 4, }, + noteTextWrap: { + flex: 1, + gap: 2, + }, + rumorRow: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + gap: 6, + marginVertical: 2, + }, + rumorIcon: { + marginTop: 1, + }, + rumorLabel: { + fontSize: 10, + fontWeight: "700", + letterSpacing: 0.6, + }, noteText: { fontSize: 11, lineHeight: 16, diff --git a/components/AllTrainDiagramView.tsx b/components/AllTrainDiagramView.tsx index 75aebf6..2abbae8 100644 --- a/components/AllTrainDiagramView.tsx +++ b/components/AllTrainDiagramView.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, FC } from "react"; import { + Animated, View, Text, TouchableOpacity, @@ -38,7 +39,7 @@ export const AllTrainDiagramView: FC = () => { const [useStationName, setUseStationName] = useState(false); const [useRegex, setUseRegex] = useState(false); const containerRef = useRef(null); - const { keyboardVisible: keyBoardVisible, measuredOffset: measuredPadding } = + const { keyboardVisible: keyBoardVisible, animatedOffset } = useKeyboardAvoid({ measureRef: containerRef, tabBarHeight }); const regexTextStyle = { color: fixed.textOnPrimary, @@ -197,7 +198,7 @@ export const AllTrainDiagramView: FC = () => { ); }; return ( - + { string="閉じる" style={{ display: keyBoardVisible ? "none" : "flex" }} /> - + ); }; diff --git a/components/Apps.tsx b/components/Apps.tsx index 09d5b73..2313e16 100644 --- a/components/Apps.tsx +++ b/components/Apps.tsx @@ -16,7 +16,7 @@ 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 { AppsWebView } from "./Apps/WebView"; import { NewMenu } from "./Apps/NewMenu"; @@ -35,6 +35,7 @@ export default function Apps() { const { originalStationList } = useStationList(); const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu(); const isDark = useColorScheme() === "dark"; + const isFocused = useIsFocused(); const lineColor = selectedLine && stationIDPair[selectedLine] ? lineColorList[stationIDPair[selectedLine]] @@ -83,7 +84,7 @@ export default function Apps() { const bgColor = isDark ? "#1c1c1e" : "#ffffff"; return ( - {lineColor && lineColorDark && ( + {isFocused && mapSwitch === "true" && lineColor && lineColorDark && ( )} - {lineColor && ( - + {isFocused && mapSwitch !== "true" && ( + + )} + {isFocused && ( + )} = ({ /* 定数 */ /* ------------------------------------------------------------------ */ const UNYOHUB_FEATURES: Feature[] = [ - { icon: "calendar-today", label: "運用データ", text: "本日・過去数日から投稿があった運用の継続予測運用情報を表示" }, + { icon: "calendar-today", label: "運用データ", text: "当日・過去数日から投稿があった運用の継続予測運用情報を表示" }, { icon: "map-outline", label: "対象エリア", text: "JR四国全線" }, - { icon: "train", label: "対象運用", text: "JR四国管内営業列車及び貨物列車に対応、臨時列車/突発運用は非対応" }, - { icon: "plus", label: "追加機能", text: "前日、当日、翌日の運用の投稿が可能" }, + { icon: "train", label: "対象運用", text: "JR四国管内営業列車及び貨物列車,定期回送列車に対応、臨時列車/突発運用は非対応" }, + { icon: "pencil", label: "入力方式", text: "アプリ内連携システムにて当日の運用の投稿が可能" }, ]; const ELESITE_FEATURES: Feature[] = [ - { icon: "calendar-today", label: "運用データ", text: "当日に報告のあった運用情報のみ表示" }, - { icon: "map-outline", label: "対象エリア", text: "予讃線/瀬戸大橋線(なお直通している特急などの列番は含みます)" }, + { icon: "calendar-today", label: "運用データ", text: "当日報告のあった運用情報のみ表示" }, + { icon: "map-outline", label: "対象エリア", text: "予讃線/瀬戸大橋線(直通している特急などの列番は含みます)" }, { icon: "train", label: "対象運用", text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応" }, + { icon: "pencil", label: "入力方式", text: "アプリ外リンク連携にて当日の運用の投稿が可能" }, ]; /* ------------------------------------------------------------------ */ @@ -161,8 +168,11 @@ export const DataSourceSettings = () => { const { dataSourcePermission, updatePermission } = useTrainMenu(); const { colors, fixed } = useThemeColors(); const canUseElesite = updatePermission || dataSourcePermission.elesite; + const showDebugSelector = __DEV__ || updatePermission; const [useUnyohub, setUseUnyohub] = useState(false); const [useElesite, setUseElesite] = useState(false); + const [jrDataSystemEnv, setJrDataSystemEnv] = + useState(DEFAULT_JR_DATA_SYSTEM_ENV); useEffect(() => { AS.getItem(STORAGE_KEYS.USE_UNYOHUB).then((value) => { @@ -171,6 +181,13 @@ export const DataSourceSettings = () => { AS.getItem(STORAGE_KEYS.USE_ELESITE).then((value) => { setUseElesite(value === true || value === "true"); }); + AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV) + .then((value) => { + setJrDataSystemEnv(normalizeJrDataSystemEnvironment(value)); + }) + .catch(() => { + setJrDataSystemEnv(DEFAULT_JR_DATA_SYSTEM_ENV); + }); }, []); const handleToggleUnyohub = (value: boolean) => { @@ -184,6 +201,11 @@ export const DataSourceSettings = () => { AS.setItem(STORAGE_KEYS.USE_ELESITE, value.toString()); }; + const handleSelectJrDataSystemEnv = (value: JrDataSystemEnvironmentKey) => { + setJrDataSystemEnv(value); + AS.setItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV, value); + }; + return ( { enabled={useElesite} onToggle={handleToggleElesite} description={ - "えれサイトは、鉄道運用情報を共有するためのサイトです。皆様からの投稿を通じて、鉄道運行に関する情報を共有するサイトです。JR 四国の特急・普通列車を中心に対応しています。\n\nデータがある列車では地図上に緑色の「E」バッジが表示され、列車情報画面の編成表示も更新されます。" + "えれサイトは、鉄道の運用情報を利用者同士で共有するサービスです。皆様からの投稿をもとに、列車のリアルタイムな動きを反映しています。JR四国の特急・普通列車をはじめ、現在は全国の路線に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。" } features={ELESITE_FEATURES} linkLabel="elesite-next.com を開く" @@ -236,6 +258,56 @@ export const DataSourceSettings = () => { データの正確性は保証されません。また、これらの連携情報を利用する時点でそれぞれのサイトの利用規約に同意したものとします。{"\n\n"}外部ソースはJR四国非公式アプリが管理していないデータであるため、お問い合わせは各サービスの窓口までお願いいたします。 + + {showDebugSelector && ( + + デバッグ: 投稿システム接続先 + + 列車情報・編成投稿画面を、本番 / ChatGPT案 / Claude案で切り替えます。 + + + {JR_DATA_SYSTEM_ENV_OPTIONS.map((option) => { + const selected = jrDataSystemEnv === option.key; + return ( + handleSelectJrDataSystemEnv(option.key)} + activeOpacity={0.8} + > + + {option.label} + + + {option.caption} + + + ); + })} + + 現在の接続先: {JR_DATA_SYSTEM_ENV_OPTIONS.find((option) => option.key === jrDataSystemEnv)?.baseUrl} + + )} ); @@ -403,4 +475,42 @@ const styles = StyleSheet.create({ color: "#856404", lineHeight: 18, }, + debugSection: { + borderRadius: 12, + borderWidth: 1, + padding: 14, + gap: 10, + }, + debugTitle: { + fontSize: 15, + fontWeight: "bold", + }, + debugDescription: { + fontSize: 12, + lineHeight: 18, + }, + debugOptionRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + debugOptionButton: { + minWidth: 96, + borderRadius: 10, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 10, + gap: 2, + }, + debugOptionTitle: { + fontSize: 13, + fontWeight: "bold", + }, + debugOptionCaption: { + fontSize: 10, + }, + debugCurrentText: { + fontSize: 11, + lineHeight: 16, + }, }); diff --git a/components/StationDiagram/ExGridView.tsx b/components/StationDiagram/ExGridView.tsx index 9a1dc2f..2e4c692 100644 --- a/components/StationDiagram/ExGridView.tsx +++ b/components/StationDiagram/ExGridView.tsx @@ -308,8 +308,6 @@ export const ExGridView: FC<{ i * 2) : [] diff --git a/components/StationDiagram/ListViewItem.tsx b/components/StationDiagram/ListViewItem.tsx index 9eb96ab..63957d0 100644 --- a/components/StationDiagram/ListViewItem.tsx +++ b/components/StationDiagram/ListViewItem.tsx @@ -2,7 +2,7 @@ import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName"; import { getStringConfig } from "@/lib/getStringConfig"; import { getTrainType } from "@/lib/getTrainType"; import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram"; -import { FC, useCallback, useEffect, useMemo, useState } from "react"; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, TouchableOpacity } from "react-native"; import Animated, { useSharedValue, @@ -240,12 +240,14 @@ export const ListViewItem: FC<{ }, [showVehicle, showAppSource, d.trainNumber, getTodayOperationByTrainId, getUnyohubByTrainNumber, getElesiteByTrainNumber, isDark]); const [sourceIndex, setSourceIndex] = useState(0); + const isCyclingRef = useRef(false); const fadeAnim = useSharedValue(1); const fadeStyle = useAnimatedStyle(() => ({ opacity: fadeAnim.value, })); const advanceSource = useCallback(() => { + isCyclingRef.current = true; setSourceIndex((i) => (i + 1) % vehicleSources.length); }, [vehicleSources.length]); @@ -255,13 +257,20 @@ export const ListViewItem: FC<{ fadeAnim.value = withTiming(0, { duration: 300 }, (finished) => { if (finished) { runOnJS(advanceSource)(); - fadeAnim.value = withTiming(1, { duration: 300 }); } }); }, 3000); return () => clearInterval(cycle); }, [showVehicle, vehicleSources.length, advanceSource]); + // sourceIndex が変わった(= 新コンテンツが描画された)後にフェードインを開始 + useEffect(() => { + if (isCyclingRef.current) { + isCyclingRef.current = false; + fadeAnim.value = withTiming(1, { duration: 300 }); + } + }, [sourceIndex]); + useEffect(() => { setSourceIndex(0); fadeAnim.value = 1; diff --git a/components/StationDiagram/StationDiagramView.tsx b/components/StationDiagram/StationDiagramView.tsx index 3e27156..e832bdf 100644 --- a/components/StationDiagram/StationDiagramView.tsx +++ b/components/StationDiagram/StationDiagramView.tsx @@ -1,5 +1,6 @@ import { FC, useEffect, useRef, useState } from "react"; import { + Animated, View, Text, ScrollView, @@ -70,7 +71,7 @@ export const StationDiagramView: FC = ({ route }) => { const { colors, fixed } = useThemeColors(); const tabBarHeight = useBottomTabBarHeight(); const containerRef = useRef(null); - const { keyboardVisible: keyBoardVisible, measuredOffset: keyboardOffset } = + const { keyboardVisible: keyBoardVisible, animatedOffset: keyboardOffset } = useKeyboardAvoid({ measureRef: containerRef, tabBarHeight }); const [input, setInput] = useState(""); const [displayMode, setDisplayMode] = useState< @@ -274,7 +275,7 @@ export const StationDiagramView: FC = ({ route }) => { }, [currentStationDiagram, currentTrain]); return ( - @@ -613,7 +614,7 @@ export const StationDiagramView: FC = ({ route }) => { {keyBoardVisible || ( goBack()} string="閉じる" /> )} - + ); }; diff --git a/constants/storage.ts b/constants/storage.ts index 8029e43..c1538b3 100644 --- a/constants/storage.ts +++ b/constants/storage.ts @@ -91,6 +91,9 @@ export const STORAGE_KEYS = { /** えれサイト使用設定 */ USE_ELESITE: 'useElesite', + /** 投稿システム接続先(デバッグ用) */ + JR_DATA_SYSTEM_ENV: 'jrDataSystemEnv', + /** えれサイトデータ */ ELESITE_DATA: 'elesiteData', diff --git a/docs/actionsheet-animation-fix.md b/docs/actionsheet-animation-fix.md new file mode 100644 index 0000000..ca6d7dd --- /dev/null +++ b/docs/actionsheet-animation-fix.md @@ -0,0 +1,118 @@ +# EachTrainInfo ActionSheet アニメーション破綻の修正記録 + +**日付:** 2026-04-08 +**ブランチ:** fix/April-Mid-Patch +**コミット:** 8b42644 + +--- + +## 症状 + +iOS (`isModal=true`) でマリンライナー等の**走行中の列車**を EachTrainInfo ActionSheet で表示したとき、スライドアップアニメーションが瞬間表示になる。 +Android では発生しない。 + +--- + +## 根本原因(3層) + +### 1. iOS `onOpen` の発火タイミング(最重要) + +ライブラリ `node_modules/react-native-actions-sheet/dist/src/index.js` line 962: +```js +onShow: props.onOpen, +``` + +iOS の `isModal=true` モードでは ActionSheet の `onOpen` prop が React Native の `Modal.onShow` にバインドされる。 +`Modal.onShow` はスプリングアニメーション**開始前**(モーダルが表示された直後)に発火する。 + +これにより以下の連鎖が起きていた: +1. Modal 表示 → `onOpen` → `setShowThrew(true)` 実行 +2. 通過駅が一気に追加されてシート高さが増加 +3. `onSheetLayout` が再発火してスプリングが「ほぼ終点位置」からリスタート +4. 結果:スライドアップに見えず瞬間表示になる + +### 2. `useEffect` による非同期な高さ変化 + +以下の hooks が `useState([])` (空) で初期化し `useEffect` で計算していた: +- `useTrainDiagramData` — 駅リスト本体 +- `useThroughStations` — 通過駅挿入後のリスト(実際にレンダリングされる) +- `useStopStationIDs` — 駅ID対応表 + +初回レンダリング(空・高さ小)→ `useEffect` 完了(フルリスト・高さ大)という変化が `onSheetLayout` を再トリガーしていた。 + +### 3. `useAutoScroll` の `InteractionManager` が非効果的だった + +`InteractionManager.runAfterInteractions()` は JS スレッドのインタラクション完了を待つが、ActionSheet の Reanimated スプリング(UI スレッド)完了は認識しないため、アニメーション途中でスクロールが実行されることがあった。 + +--- + +## 修正内容 + +### `showThrew` の初期値を同期的に決定 ← 最重要 + +```tsx +// EachTrainInfoCore.tsx(修正前) +const [showThrew, setShowThrew] = useState(false); + +// EachTrainInfoCore.tsx(修正後) +const [showThrew, setShowThrew] = useState(() => !!getCurrentStationData(data.trainNum)); +``` + +走行中の列車は最初から `true` にすることで、アニメーション中に通過駅の追加による高さ変化が起きなくなる。 + +### 各 hooks の lazy initializer 化 + +純粋計算関数を抽出して `useState` の初期化関数に渡すことで、初回レンダリング時から正確な高さを確保: + +```ts +// useThroughStations.ts +const [state, setState] = useState(() => + computeThroughStations(trainData, stationList, originalStationList) +); + +// useStopStationIDs.ts +const [stopStationIDList, setStopStationIDList] = useState(() => + computeStopStationIDs(trainDataWithThrough, stationList) +); + +// useTrainDiagramData.ts +const [trainData, setTrainData] = useState(() => parseTrainData(trainNum, trainList).data); +``` + +### `sheetOpened` フラグによるスクロールのゲート + +```tsx +// EachTrainInfo.tsx +const [sheetOpened, setSheetOpened] = useState(false); +// onOpen → setSheetOpened(true), onClose → setSheetOpened(false) +``` + +```ts +// useAutoScroll.ts — sheetOpened が true になるまでスクロールしない +if (!sheetOpened || isJumped || ...) return; +``` + +### `useAutoScroll` から `setShowThrew` 呼び出しを除去 + +スクロール位置制御と通過駅表示の責務を分離。`setShowThrew` は `EachTrainInfoCore` の初期化時のみで完結。 + +--- + +## 変更ファイル一覧 + +| ファイル | 変更内容 | +|---|---| +| `components/ActionSheetComponents/EachTrainInfo.tsx` | `sheetOpened` state 追加、`onOpen`/`onClose` ハンドラ実装 | +| `components/ActionSheetComponents/EachTrainInfoCore.tsx` | `showThrew` 同期初期化、`useCurrentTrain` import 追加、`setShowThrew` を `useAutoScroll` から除去 | +| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useAutoScroll.ts` | `setShowThrew` 引数削除、`sheetOpened` ゲート追加、`InteractionManager` 廃止 | +| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useTrainDiagramData.ts` | `parseTrainData` 純粋関数抽出、lazy initializer 化 | +| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useThroughStations.ts` | `computeThroughStations` 純粋関数抽出、lazy initializer 化 | +| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useStopStationIDs.ts` | `computeStopStationIDs` 純粋関数抽出、lazy initializer 化 | + +--- + +## 将来の注意点 + +- **ActionSheet に渡すコンテンツの高さはマウント時から固定すること。** `useEffect` で後から高さを変えると `onSheetLayout` が再発火してスプリングアニメーションがリスタートする。 +- **iOS で `isModal=true` の場合、`onOpen` はアニメーション完了前に発火する。** `onOpen` の中で state 変更を行うとアニメーションが破綻する可能性がある。 +- **Reanimated スプリングは UIスレッドで動くため `InteractionManager.runAfterInteractions()` では待てない。** 代わりに `onOpen` フラグでゲートする。 diff --git a/docs/keyboard-animation-tuning-2026-04-08.md b/docs/keyboard-animation-tuning-2026-04-08.md new file mode 100644 index 0000000..bf1cfb0 --- /dev/null +++ b/docs/keyboard-animation-tuning-2026-04-08.md @@ -0,0 +1,28 @@ +# Keyboard Animation Tuning (2026-04-08) + +## Scope +- Map search: `components/Menu/RailScope/SearchUnitBox.tsx` +- Train number search: `components/AllTrainDiagramView.tsx` +- Station diagram search: `components/StationDiagram/StationDiagramView.tsx` + +## What Was Refactored +- Extracted repeated easing/duration values into local constants in each file. +- Extracted repeated `Animated.timing` options in map search into a small helper (`runTiming`). +- Consolidated repeated `LayoutAnimation.configureNext` payload in map search into `SEARCH_LAYOUT_ANIM`. +- Cleaned indentation/readability around map search input/header block. + +## Behavioral Notes +- No intended behavior change in this refactor pass. +- Existing keyboard/search animation behavior remains as tuned earlier. + +## Known Existing Type Warnings (pre-existing) +- `react-native-vector-icons/Ionicons` missing type declaration warning in `SearchUnitBox.tsx`. +- Index signature/implicit any warnings around line color key mapping in `SearchUnitBox.tsx`. + +## If Tuning Again +- Primary knobs: + - `KEYBOARD_BOTTOM_DURATION` + - `SEARCH_MORPH_DURATION` + - `PADDING_ANIM_DURATION` + - `CLOSE_BUTTON_ANIM_DURATION` +- Current easing is unified to `Easing.inOut(Easing.ease)`. diff --git a/docs/keyboard-animation-tuning-2026-04-09.md b/docs/keyboard-animation-tuning-2026-04-09.md new file mode 100644 index 0000000..ecb5825 --- /dev/null +++ b/docs/keyboard-animation-tuning-2026-04-09.md @@ -0,0 +1,138 @@ +# キーボード回避アニメーション改修 (2026-04-09) + +## 概要 + +`useKeyboardAvoid` hook を `LayoutAnimation` ベースから `Animated.timing` / `Animated.spring` ベースに全面的に書き換え、以下の問題を解決した。 + +## 解決した問題 + +| 問題 | 根本原因 | 対策 | +|---|---|---| +| キーボードの開閉アニメーションが動かない | `LayoutAnimation.configureNext` を `measure()` 非同期コールバック内で呼んでいたが、New Architecture (Fabric) では `adjustResize` による commit に消費/無効化されていた | `Animated.timing` / `Animated.spring` に移行。非同期コールバック内からでも確実にアニメーションが発動する | +| 閉じ→すぐ開きで位置が壊れる | 150ms timer 発火後の `measure()` が飛行中の状態で hide イベントが来てもキャンセル不可能。古い座標のコールバックが混入していた | `measureGenRef` 世代カウンタで古い `measure()` コールバックを無効化 | +| Android で 150ms 後の `measure()` が中間座標を返す | `adjustResize` のウィンドウリサイズは非同期で 250-300ms かかるため、150ms では中間状態を拾うことがある | `retryTimerRef` による 500ms リトライで自動訂正 | + +## 変更ファイル + +### `lib/useKeyboardAvoid.ts` + +#### LayoutAnimation → Animated.timing / Animated.spring + +```typescript +// 旧: LayoutAnimation.configureNext → setState (measure() コールバック内で動作しない) +LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG); +setMeasuredOffset(offset); + +// 新: Animated.timing / Animated.spring (どのコンテキストからでも動作する) +if (Platform.OS === "ios") { + Animated.spring(animatedOffset, { + toValue, damping: 500, stiffness: 1000, mass: 3, + useNativeDriver: false, + }).start(); +} else { + Animated.timing(animatedOffset, { + toValue, duration: 250, easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); +} +``` + +- iOS: `Animated.spring` でキーボードの spring アニメーションに追従 +- Android: `Animated.timing` + `Easing.out(Easing.cubic)` で自然な減速カーブ + +#### measureGenRef (世代カウンタ) + +```typescript +const measureGenRef = useRef(0); + +// show イベント: 新しい世代を発行 +const gen = ++measureGenRef.current; +doMeasure(kbInfo.screenY, kbInfo.height, gen); + +// measure() コールバック内: 古い世代なら破棄 +if (gen !== measureGenRef.current) return; + +// hide イベント: 世代をインクリメントして飛行中コールバックを無効化 +measureGenRef.current++; +``` + +#### retryTimerRef (Android リトライ) + +```typescript +// 150ms: 初回 measure (adjustResize 途中の可能性あり) +showTimerRef.current = setTimeout(() => doMeasure(..., gen), 150); + +// 500ms: リトライ (adjustResize 完了後の確定座標で自動訂正) +retryTimerRef.current = setTimeout(() => doMeasure(..., gen), 500); +``` + +#### currentAnimRef (アニメーションキャンセル) + +```typescript +const currentAnimRef = useRef(null); + +const animateTo = (toValue: number) => { + if (currentAnimRef.current) { + currentAnimRef.current.stop(); // 前のアニメをキャンセル + } + const anim = Animated.timing(animatedOffset, { ... }); + currentAnimRef.current = anim; + anim.start(({ finished }) => { + if (finished) currentAnimRef.current = null; + }); +}; +``` + +#### hide debounce + +| プラットフォーム | delay | 理由 | +|---|---|---| +| Android | 300ms | IME 切替時の hide→show 連続発火対策 | +| iOS | 50ms | rapid close→open で `animateTo(0)` が先走るのを防止 | + +### `components/AllTrainDiagramView.tsx` + +- `View` → `Animated.View` に変更 +- `measuredOffset` (plain number) → `animatedOffset` (Animated.Value) に変更 + +### `components/StationDiagram/StationDiagramView.tsx` + +- 同上 + +### `components/Menu/RailScope/SearchUnitBox.tsx` + +- 変更なし(`measuredOffset` (plain number) を引き続き使用。`position: absolute` の `bottom` に Animated.Value は不要) + +## hook の返却値 + +```typescript +interface UseKeyboardAvoidResult { + keyboardVisible: boolean; // キーボードが表示中か + keyboardHeight: number; // キーボードの生の高さ + animatedOffset: Animated.Value; // Animated.View の paddingBottom/bottom 用 + measuredOffset: number; // plain number (SearchUnitBox 等向け後方互換) +} +``` + +## タイミングまとめ (Android) + +``` +t=0ms: keyboardDidShow +t=0ms: setKeyboardVisible(true), setKeyboardHeight(kbHeight) +t=0ms: gen = ++measureGenRef.current +t=150ms: doMeasure(gen) → measure() 開始 (adjustResize 途中の可能性) +t=~165ms: measure() callback → gen チェック → animateTo(offset) +t=500ms: doMeasure(gen) リトライ → measure() 開始 (adjustResize 完了済み) +t=~515ms: measure() callback → gen チェック → animateTo(確定offset) +``` + +## タイミングまとめ (iOS) + +``` +t=0ms: keyboardWillShow +t=0ms: setKeyboardVisible(true), setKeyboardHeight(kbHeight) +t=0ms: gen = ++measureGenRef.current +t=0ms: doMeasure(gen) → measure() 開始 +t=~5ms: measure() callback → gen チェック → Animated.spring 開始 +t=~250ms: spring アニメーション完了(キーボード出現と同期) +``` diff --git a/docs/privacy-policy-future.md b/docs/privacy-policy-future.md new file mode 100644 index 0000000..82817b9 --- /dev/null +++ b/docs/privacy-policy-future.md @@ -0,0 +1,84 @@ +# プライバシーポリシー 将来追加予定セクション + +このファイルは、今後の機能実装時にプライバシーポリシーへ追加する内容の設計メモです。 +各機能のリリース時に privacy-policy.md へ統合してください。 + +--- + +## アカウント機能(OAuthログイン)リリース時に追加 + +### 1.1 アカウント情報(新規セクション) + +本アプリでは、ソーシャルログイン(OAuth)によるアカウント機能を提供します。 + +- ログイン時に、利用する認証プロバイダ(Google、Apple等)から以下の情報を取得します。 + - 認証プロバイダが発行するユーザー識別子(ID) + - 表示名(認証プロバイダが提供する場合) + - メールアドレス(認証プロバイダが提供する場合) +- これらの情報はアカウント管理および設定同期のために開発者のサーバーに保存します。 +- パスワードを開発者側で保持することはありません。認証は各プロバイダに委任されます。 +- アカウントの作成は任意であり、アカウントなしでも本アプリの基本機能は利用できます。 + +### 1.3 ユーザー設定の同期(新規セクション) + +- アカウントにログインしている場合、以下の設定情報をサーバーに同期し、複数端末間で共有できます。 + - お気に入り駅 + - 表示設定(テーマ、レイアウト等) + - 通知設定 + - カスタマイズ設定(アプリアイコン選択等) +- 同期はアカウントにログインしている場合にのみ行われます。未ログイン時はすべて端末内にのみ保存されます。 + +### 3.1 第三者サービス表に追加 + +| OAuth認証プロバイダ(Google、Apple等) | ソーシャルログイン | 各プロバイダ | + +### 6. データの保存期間に追加 + +- **アカウント情報・同期設定:** アカウントが削除されるまで保持します。 +- プッシュ通知トークンの記述に追記: 「またはアカウント削除時に削除されます。」 + +### 7. データの削除に追加 + +#### アカウントの削除 +- アプリ内の設定画面からアカウントを削除できます。アカウント削除時に、サーバーに保存されたアカウント情報、同期設定データ、およびプッシュ通知トークンを削除します。 + +### 9. 児童のプライバシーに追記 + +- 13歳未満の児童がアカウント作成等を通じて個人情報を提供したことが判明した場合、速やかに当該アカウントおよび関連情報を削除します。 + +--- + +## FeliCa OSSデータベース機能リリース時に追加 + +### 1.4 ユーザーが任意で提供する情報(FeliCaオープンデータ)(新規セクション) + +本アプリでは、交通系ICカードの駅コード・改札コードに関するオープンデータベースの構築に参加する機能を提供します。 + +#### 提供されるデータの範囲 +- **提供対象:** 駅コード、改札コード、およびユーザーが入力する実績説明(駅名・改札名の対応情報等) +- **提供対象外:** IDm(製造ID)、残高、個人の乗降履歴、その他個人を特定しうる情報 + +#### データの公開 +- 提供されたデータは、オープンソースのデータベースとして**一般に公開**されます。 +- 公開されるデータには提供者個人を特定する情報は含まれません。 +- 一度公開されたデータは、オープンソースとして第三者が複製・再配布する可能性があるため、**完全な削除を保証することはできません**。 + +#### 同意と任意性 +- データの提供は完全に任意であり、提供しなくても本アプリの機能に制限はありません。 +- データの提供時に、提供内容と公開範囲について個別に同意を求めます。 + +### 3.2 FeliCaオープンデータの公開(新規セクション) + +ユーザーが任意で提供したFeliCa駅コード・改札コードのデータは、オープンソースデータベースとして一般に公開されます。公開データに個人を特定する情報は含まれません。 + +### 6. データの保存期間に追加 + +- **FeliCaオープンデータ:** オープンソースとして無期限に公開されます。 + +### 7. データの削除に追記 + +- アカウント削除後も、FeliCaオープンデータベースに提供済みのデータは、オープンソースデータとして公開が継続されます。 + +### 1.2 ICカード個人データの記述に追記 + +- ICカードのデータ提供機能(後述の1.4)とは明確に区別されます。 diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 0000000..6d7b374 --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,151 @@ +# 『JR四国非公式アプリ』プライバシーポリシー + +最終更新日: 2026年4月3日 + +本プライバシーポリシー(以下「本ポリシー」)は、harukin(以下「開発者」)が提供する「JR四国非公式アプリ」(以下「本アプリ」)における、ユーザー情報の取り扱いについて定めるものです。 + +本アプリはJR四国(四国旅客鉄道株式会社)とは一切関係のない非公式アプリです。 + +--- + +## 1. 収集する情報 + +### 1.1 端末から取得し、端末内のみで利用する情報 + +以下の情報は端末内での処理にのみ使用し、外部サーバーへの送信は行いません。 + +#### 位置情報 +- ユーザーの現在地を取得し、最寄り駅の情報表示や地図上での現在地表示に利用します。 +- 位置情報はアプリがフォアグラウンドで動作している間のみ取得し、アプリを終了すると取得を停止します。 +- 位置情報の利用は任意ですが、許可しない場合は最寄り駅の自動検出などの一部機能が制限されます。 + +#### ICカードデータ(NFC/FeliCa) +- NFC対応端末において、交通系ICカードのIDm(製造ID)、残高、システムコード、利用履歴を読み取ります。 +- 読み取った情報は端末内(AsyncStorage)にのみ保存され、ウィジェット等での残高表示に使用します。 +- IDm、残高、個人の利用履歴等のデータは外部サーバーへ送信しません。 + +#### ユーザー設定・お気に入り +- お気に入り駅、表示設定(テーマ・レイアウト等)、通知設定、カスタマイズ設定などのユーザー設定は、すべて端末内(AsyncStorage)にのみ保存されます。 + +### 1.2 外部サーバーに送信する情報 + +以下の情報は、機能の提供に必要な範囲で開発者が運営するサーバーに送信されます。 + +#### プッシュ通知トークン(Expo Push Token) +- プッシュ通知の配信および一部機能の権限確認のために、端末固有のプッシュ通知トークンを開発者のサーバーに送信します。 +- このトークンはユーザーの識別にも使用されますが、氏名・メールアドレス等の個人を直接特定する情報とは紐づけません。 + +#### 通知設定情報 +- プッシュ通知機能を利用する場合、通知カテゴリの設定内容(遅延情報EX、運行情報、怪レい列車BOTの各ON/OFF)をプッシュ通知トークンとともにサーバーに送信・保存します。 + +--- + +## 2. 情報の利用目的 + +収集した情報は以下の目的でのみ利用します。 + +- 最寄り駅の検出および地図上での現在地表示 +- ICカード残高・利用履歴の端末内表示 +- プッシュ通知の配信(列車遅延情報、運行情報等) +- 一部機能における権限確認 +- アプリの機能改善および不具合対応 + +--- + +## 3. 第三者への情報提供 + +開発者は、収集した情報を第三者に販売または貸与することはありません。 + +ただし、本アプリは以下の第三者サービスを利用しており、各サービスの利用規約およびプライバシーポリシーに基づき、当該サービス提供者が情報を取得する場合があります。 + +| サービス | 用途 | 提供者 | +|---------|------|--------| +| Google Maps SDK | 地図表示・駅マーカー表示 | Google LLC | +| Firebase Cloud Messaging (FCM) | プッシュ通知基盤 | Google LLC | +| Expo Push Notifications | プッシュ通知配信 | Expo | +| Expo Updates | アプリのOTAアップデート配信 | Expo | + +各サービスのプライバシーポリシーについては、以下をご参照ください。 +- Google: https://policies.google.com/privacy +- Expo: https://expo.dev/privacy + +--- + +## 4. WebViewを通じた第三者サイトへのアクセス + +本アプリでは、以下のWebサイトをWebViewにより表示します。これらのサイトにおけるデータの取り扱いは各サイトのプライバシーポリシーに従います。 + +- JR四国 列車走行位置(https://train.jr-shikoku.co.jp/) +- JR四国 運行情報(https://www.jr-shikoku.co.jp/info/) + +--- + +## 5. 第三者データソースの利用 + +本アプリでは、設定画面から任意で以下の第三者データソースとの連携を有効にできます。 + +- 鉄道運用Hub(unyohub) +- えれサイト(elesite) + +これらのデータソースから取得したデータは表示目的でのみ使用し、ユーザーの情報をこれらのサービスに送信することはありません。連携機能を利用する場合は、各サービスの利用規約に同意したものとみなします。 + +一部の第三者データソース(鉄道運用Hub等)は独自のログイン機能を提供しています。ユーザーがWebView内でログインした場合、そのセッション情報(Cookie等)はアプリ内のWebViewストレージに保持されます。開発者がこれらの認証情報にアクセスすることはありません。セッション情報の取り扱いは各サービスのプライバシーポリシーに従います。 + +--- + +## 6. データの保存期間 + +### サーバー側データ +- **プッシュ通知トークン・通知設定:** ユーザーが明示的に削除を要求するまで、またはサービス終了まで保持します。 + +### 端末内データ +- ユーザー設定、お気に入り、ICカードデータ、キャッシュデータ等は、ユーザーがアプリをアンインストールするか、端末内のデータを消去するまで保持されます。 + +--- + +## 7. データの削除 + +### 端末内データの削除 +- アプリのアンインストール、または端末の設定からアプリのデータ消去を行うことで、端末内に保存されたすべてのデータを削除できます。 + +### サーバー側データの削除 +- サーバーに保存されたプッシュ通知トークンおよび通知設定の削除を希望する場合は、後述の問い合わせ先までご連絡ください。 + +--- + +## 8. セキュリティ + +開発者は、収集した情報の漏洩、紛失、改ざんを防止するために合理的な技術的措置を講じます。ただし、インターネットを通じたデータ送信の安全性を完全に保証するものではありません。 + +--- + +## 9. 児童のプライバシー + +本アプリは特定の年齢層を対象としたものではなく、13歳未満の児童から意図的に個人情報を収集することはありません。 + +--- + +## 10. アナリティクス・広告 + +本アプリはアクセス解析ツール(アナリティクス)および広告配信サービスを使用していません。 + +--- + +## 11. 本ポリシーの変更 + +開発者は、本ポリシーを変更する場合があります。重要な変更を行う場合は、アプリ内のお知らせまたは開発者のWebサイトにて通知します。変更後も本アプリを継続利用した場合、変更後のポリシーに同意したものとみなします。 + +--- + +## 12. お問い合わせ先 + +本ポリシーに関するお問い合わせは、以下の窓口までご連絡ください。 + +- **開発者:** harukin(Xprocess) +- **Twitter/X:** [@xprocess_main](https://twitter.com/xprocess_main) +- **マシュマロ(匿名質問箱):** https://marshmallow-qa.com/pag3sl0ju3g1jm7 +- **Webサイト:** https://haruk.in + +--- + +© 2022 - 2026 harukin diff --git a/docs/tutorial-feature-plan.md b/docs/tutorial-feature-plan.md new file mode 100644 index 0000000..f4acfab --- /dev/null +++ b/docs/tutorial-feature-plan.md @@ -0,0 +1,194 @@ +# チュートリアル機能 設計案 + +## 概要 + +JR四国非公式アプリにチュートリアル機能を追加し、ユーザーの初回体験を改善する。 + +--- + +## 案1: 初回起動ウォークスルー(オンボーディング) + +初回起動時に 3〜5 画面のスワイプ式ウォークスルーを表示。 + +| 画面 | 内容 | +|------|------| +| 1 | 「JR四国の列車位置をリアルタイムで確認できます」(マップ画面のスクリーンショット) | +| 2 | 「よく使う駅をお気に入り登録しよう」(お気に入り機能の紹介) | +| 3 | 「駅名標・発車時刻表も見られます」(駅ダイヤグラム紹介) | +| 4 | 「通知設定で遅延情報を受け取れます」(通知設定への誘導) | + +**表示制御**: `AsyncStorage` の `TUTORIAL_COMPLETED` キーで管理。 + +--- + +## 案2: コーチマーク(ツールチップ型ガイド) + +各画面で初めてアクセスした際に、UIパーツをハイライト+吹き出しで説明。 + +- **マップ画面**: 「ピンをタップすると列車情報が見られます」「このボタンで現在地に戻れます」 +- **メニュー画面**: 「横スクロールで路線を切り替えられます」「★で駅をお気に入りに追加」 +- **設定画面**: 「アプリアイコンを変更できます」 + +**表示制御**: `COACH_MARK_{SCREEN}_SHOWN` フラグで画面ごとに制御。 + +--- + +## 案3: コンテキスト依存ヒント + +特定の操作タイミングで自動表示。 + +| トリガー | ヒント内容 | +|----------|-----------| +| お気に入り 0 件でメニュー表示 | 「駅をお気に入りに追加すると、ここからすぐアクセスできます」 | +| FeliCa 対応端末で初回起動 | 「交通系ICカードの履歴を読み取れます」 | +| 列車遅延発生時 | 「通知設定で遅延情報を自動受信できます → 設定へ」 | +| 長押し操作が可能な箇所 | 「長押しで詳細メニューが開きます」 | + +--- + +## 案4: 「使い方」セクション刷新 + +現在の `howto.tsx`(WebView)を、アプリ内ネイティブ画面に置き換え。 + +- カテゴリ別に整理(「基本操作」「お気に入り」「ウィジェット」「FeliCa」など) +- GIF/Lottie アニメーションで操作手順を視覚的に表示(Lottie は導入済み) +- 設定画面からいつでもアクセス可能 + チュートリアルリセットボタン + +--- + +## 案5: 段階的機能開放(プログレッシブ・ディスクロージャー) + +使い込むにつれて高度な機能を提案。 + +``` +初回起動 → 基本操作ガイド +3回目起動 → 「お気に入り登録してみませんか?」 +1週間後 → 「ウィジェットを設定すると便利です」 +``` + +--- + +## 実装の優先順位 + +1. **初回ウォークスルー** — 最もインパクト大、実装も比較的シンプル +2. **コンテキスト依存ヒント** — お気に入り 0 件ヒントなど簡単なものから +3. **コーチマーク** — マップ画面の操作説明に効果的 +4. **使い方セクション刷新** — 既存の howto.tsx を段階的に改善 +5. **段階的機能開放** — 長期的な改善施策 + +--- + +## 実装パターン比較: OTA vs ライブラリ追加 + +以下で詳細に比較する。 + +### パターンA: OTA配信可能(既存依存のみ) + +既にインストール済みのライブラリのみで実装。`expo-updates` 経由の OTA で即座にユーザーへ配信可能。 + +#### 利用可能な既存ライブラリ + +- `react-native-reanimated` (v4.2.1) — アニメーション全般 +- `react-native-reanimated-carousel` (v4.0.3) — スワイプ式カルーセル(ウォークスルーに最適) +- `react-native-gesture-handler` (v2.30.0) — ジェスチャー制御 +- `@gorhom/bottom-sheet` (v5) — ボトムシート型UI +- `react-native-actions-sheet` (v10.1.2) — アクションシート +- `lottie-react-native` (v7.3.1) — アニメーション素材再生 +- `react-native-svg` (v15.15.3) — SVG描画 +- `@react-native-async-storage/async-storage` — 表示状態の永続化 +- `expo-haptics` — 触覚フィードバック + +#### 設計方針 + +| 機能 | 実装方法 | +|------|----------| +| ウォークスルー | `react-native-reanimated-carousel` でページスワイプ + `reanimated` でフェードアニメーション | +| コーチマーク | 自前実装: `react-native-svg` で穴あきオーバーレイ + `View.measure()` でターゲット位置取得 | +| ヒント表示 | `@gorhom/bottom-sheet` or `react-native-actions-sheet` でスナックバー風表示 | +| アニメーション | `lottie-react-native` で手順説明アニメ | +| 状態管理 | `AsyncStorage` でフラグ管理 | + +#### 難易度 + +| 機能 | 難易度 | 工数目安 | 備考 | +|------|--------|----------|------| +| ウォークスルー | ★★☆☆☆ | 小 | carousel がそのまま使える | +| コンテキストヒント | ★★☆☆☆ | 小 | BottomSheet/ActionSheet で簡単 | +| コーチマーク | ★★★★☆ | 大 | 穴あきオーバーレイの自前実装が必要。ターゲット要素の位置計測、スクロール追従、画面回転対応など | +| 使い方画面刷新 | ★★☆☆☆ | 中 | 通常の画面実装 | +| 段階的開放 | ★★☆☆☆ | 小 | AsyncStorage カウンタ + 条件分岐 | + +#### メリット・デメリット + +- ✅ OTA即時配信可能(ストア審査不要) +- ✅ 追加依存なし、バンドルサイズ増加なし +- ❌ コーチマーク(穴あきオーバーレイ)の自前実装コストが高い +- ❌ コーチマークのエッジケース対応(ScrollView内要素、モーダル上など)が大変 + +--- + +### パターンB: ライブラリ追加(ネイティブビルド必要) + +チュートリアル専用ライブラリを導入。次回のストアビルド&審査が必要。 + +#### 追加候補ライブラリ + +| ライブラリ | 用途 | ネイティブモジュール | +|-----------|------|---------------------| +| `react-native-copilot` | コーチマーク(ステップガイド) | なし(JS のみ) | +| `react-native-spotlight-tour` | スポットライト型ガイド | なし(JS のみ) | +| `@nickcarraway/react-native-tooltip-walkthrough` | ツールチップウォークスルー | なし(JS のみ) | + +> **重要**: 上記候補はいずれも **Pure JS ライブラリ**(ネイティブモジュールなし)のため、実際には OTA 配信可能。ただし `node_modules` の変更を含むため EAS Build が推奨される場合がある。 + +#### 設計方針 + +| 機能 | 実装方法 | +|------|----------| +| ウォークスルー | パターンA と同じ(既存 carousel で十分) | +| コーチマーク | `react-native-copilot` の `CopilotProvider` + `walkthroughable()` HOC | +| ヒント表示 | パターンA と同じ | +| アニメーション | パターンA と同じ | + +#### 難易度 + +| 機能 | 難易度 | 工数目安 | 備考 | +|------|--------|----------|------| +| ウォークスルー | ★★☆☆☆ | 小 | パターンAと同じ | +| コンテキストヒント | ★★☆☆☆ | 小 | パターンAと同じ | +| コーチマーク | ★★☆☆☆ | 小 | ライブラリが位置計測・オーバーレイを処理 | +| 使い方画面刷新 | ★★☆☆☆ | 中 | パターンAと同じ | +| 段階的開放 | ★★☆☆☆ | 小 | パターンAと同じ | + +#### メリット・デメリット + +- ✅ コーチマーク実装が大幅に楽(★★★★☆ → ★★☆☆☆) +- ✅ エッジケース(位置計測、スクロール追従)をライブラリが処理 +- ❌ 新規依存追加(バンドルサイズ微増) +- ❌ ライブラリのメンテナンス状況・Expo SDK互換性リスク +- ⚠️ Pure JS ライブラリなら実質 OTA 可能だが、検証が必要 + +--- + +## 結論・推奨アプローチ + +### フェーズ1(OTA配信)— すぐ着手可能 + +1. **ウォークスルー**: `react-native-reanimated-carousel` で実装 → OTA配信 +2. **コンテキストヒント**: `BottomSheet` / `ActionSheet` で実装 → OTA配信 +3. **段階的開放ロジック**: `AsyncStorage` カウンタ → OTA配信 + +### フェーズ2(次回ビルド時)— コーチマークの判断 + +- **コーチマークが必須なら**: `react-native-copilot`(Pure JS)を追加し、次回ビルドに含める +- **コーチマーク不要 or 後回しなら**: フェーズ1だけで十分な体験改善が可能 + +### 差分まとめ + +| 観点 | パターンA(OTA) | パターンB(ライブラリ追加) | +|------|------------------|---------------------------| +| 配信速度 | 即座 | 次回ビルド待ち | +| コーチマーク難易度 | ★★★★☆ | ★★☆☆☆ | +| それ以外の難易度 | 同等 | 同等 | +| 依存リスク | なし | 低(Pure JS) | +| 推奨 | フェーズ1はこちら | コーチマーク実装時に検討 | diff --git a/lib/jrDataSystemEnvironment.ts b/lib/jrDataSystemEnvironment.ts new file mode 100644 index 0000000..1a654d9 --- /dev/null +++ b/lib/jrDataSystemEnvironment.ts @@ -0,0 +1,64 @@ +export const JR_DATA_SYSTEM_ENVS = { + production: { + label: "本番", + caption: "現在の本番環境", + baseUrl: "https://jr-shikoku-data-system.pages.dev", + }, + // chatgpt: { + // label: "ChatGPT", + // caption: "experiment-ux-refactoring-co-3crz", + // baseUrl: + // "https://experiment-ux-refactoring-co-3crz.jr-shikoku-data-system.pages.dev", + // }, + claude: { + label: "Claude", + caption: "experiment-ux-refactoring-co-6cw7", + baseUrl: + "https://experiment-ux-refactoring-co-6cw7.jr-shikoku-data-system.pages.dev", + }, +} as const; + +export type JrDataSystemEnvironmentKey = keyof typeof JR_DATA_SYSTEM_ENVS; + +export const DEFAULT_JR_DATA_SYSTEM_ENV: JrDataSystemEnvironmentKey = + "production"; + +export const JR_DATA_SYSTEM_ENV_OPTIONS = ( + Object.entries(JR_DATA_SYSTEM_ENVS) as [ + JrDataSystemEnvironmentKey, + (typeof JR_DATA_SYSTEM_ENVS)[JrDataSystemEnvironmentKey], + ][] +).map(([key, value]) => ({ + key, + ...value, +})); + +export const normalizeJrDataSystemEnvironment = ( + value: unknown, +): JrDataSystemEnvironmentKey => { + if (typeof value === "string" && value in JR_DATA_SYSTEM_ENVS) { + return value as JrDataSystemEnvironmentKey; + } + return DEFAULT_JR_DATA_SYSTEM_ENV; +}; + +export const rewriteJrDataSystemUrl = ( + uri: string, + environment: unknown, +): string => { + if (typeof uri !== "string" || uri.length === 0) { + return uri; + } + + const envKey = normalizeJrDataSystemEnvironment(environment); + if (envKey === DEFAULT_JR_DATA_SYSTEM_ENV) { + return uri; + } + + const productionBaseUrl = JR_DATA_SYSTEM_ENVS.production.baseUrl; + const targetBaseUrl = JR_DATA_SYSTEM_ENVS[envKey].baseUrl; + + return uri.startsWith(productionBaseUrl) + ? uri.replace(productionBaseUrl, targetBaseUrl) + : uri; +}; diff --git a/lib/rootNavigation.ts b/lib/rootNavigation.ts index 48b8c8e..3e1c164 100644 --- a/lib/rootNavigation.ts +++ b/lib/rootNavigation.ts @@ -1,3 +1,47 @@ -import { createNavigationContainerRef } from "@react-navigation/native"; +import { createNavigationContainerRef, StackActions } from "@react-navigation/native"; export const rootNavigationRef = createNavigationContainerRef(); + +/** positions タブの Stack.Navigator navigation を登録するグローバルref */ +export const positionsStackNavRef: { current: any } = { current: null }; + +/** + * 遷移先タブのネストスタックを一度 popToTop してからナビゲートする。 + * ウィジェットや外部リンクからの遷移時に、既存の開いている画面を閉じてから目的の画面へ移動するために使用する。 + */ +export function stackAwareNavigate(tabName: string, params?: any) { + if (!rootNavigationRef.isReady()) return; + + const doNavigate = () => { + if (params !== undefined) { + rootNavigationRef.navigate(tabName, params); + } else { + rootNavigationRef.navigate(tabName as any); + } + }; + + // positions タブは直接 stackNavRef を使って確実に popToTop する + if (tabName === "positions" && positionsStackNavRef.current) { + const stackNav = positionsStackNavRef.current; + if ((stackNav.getState()?.index ?? 0) > 0) { + stackNav.popToTop(); + setTimeout(doNavigate, 350); + } else { + doNavigate(); + } + return; + } + + const state = rootNavigationRef.getState(); + const tabRoute = state?.routes?.find((r: any) => r.name === tabName); + if (tabRoute?.state && (tabRoute.state.index ?? 0) > 0) { + rootNavigationRef.dispatch({ + ...StackActions.popToTop(), + target: tabRoute.state.key, + }); + // popToTop のアニメーション完了後に navigate を実行 + setTimeout(doNavigate, 350); + } else { + doNavigate(); + } +} diff --git a/lib/stackOption.ts b/lib/stackOption.ts index 2d570e7..ff79c39 100644 --- a/lib/stackOption.ts +++ b/lib/stackOption.ts @@ -1,8 +1,39 @@ -import { TransitionPresets } from "@react-navigation/stack"; +import { Platform } from "react-native"; +import { + CardStyleInterpolators, + TransitionPresets, +} from "@react-navigation/stack"; +import type { StackCardInterpolationProps } from "@react-navigation/stack"; + +/** + * Android用: モーダルのスライドアップはそのまま維持しつつ、 + * 背景カードへのアニメーション(scale, borderRadius等)を無効化する。 + * Android で背景カードのアニメーションが描画の乱れを引き起こすため。 + */ +const forModalPresentationAndroid = ( + props: StackCardInterpolationProps +) => { + const result = CardStyleInterpolators.forModalPresentationIOS(props); + + // 背景カード(next が存在する)にはスタイル変更を適用しない + if (props.next) { + return { + cardStyle: {}, + overlayStyle: result.overlayStyle, + }; + } + + return result; +}; + export const optionData = { gestureEnabled: true, ...TransitionPresets.ModalPresentationIOS, + ...(Platform.OS === "android" && { + cardStyleInterpolator: forModalPresentationAndroid, + }), cardOverlayEnabled: true, headerTransparent: true, headerShown: false, + detachPreviousScreen: false, }; diff --git a/lib/useKeyboardAvoid.ts b/lib/useKeyboardAvoid.ts index 0f97f27..a518567 100644 --- a/lib/useKeyboardAvoid.ts +++ b/lib/useKeyboardAvoid.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Keyboard, LayoutAnimation, Platform } from "react-native"; +import { Animated, Easing, Keyboard, Platform } from "react-native"; interface UseKeyboardAvoidOptions { /** measure()対象のViewのref。指定するとrefの画面座標からオフセットを精密計算する */ @@ -11,22 +11,29 @@ interface UseKeyboardAvoidOptions { interface UseKeyboardAvoidResult { /** キーボードが表示中か */ keyboardVisible: boolean; - /** キーボードの生の高さ(キャッシュ済み) */ + /** キーボードの生の高さ */ keyboardHeight: number; - /** measure()またはfallbackで計算されたオフセット値(paddingBottom/bottomに使う) */ + /** + * Animated.Value によるオフセット(Animated.View の paddingBottom/bottom に使う)。 + * measure() 非同期コールバック内から Animated.timing で駆動するため + * LayoutAnimation と異なりタイミングを問わず正しくアニメーションする。 + */ + animatedOffset: Animated.Value; + /** + * 現在のオフセット数値(Animated.Value を使えない箇所向け)。 + * SearchUnitBox など position:absolute で bottom を直接指定する場合に使う。 + */ measuredOffset: number; } -const LAYOUT_ANIM_CONFIG = { - duration: 250, - update: { type: LayoutAnimation.Types.easeInEaseOut }, -}; +const ANIM_DURATION = 250; /** * キーボード回避の共通hook。 - * - Androidの偽イベント(height<100)をガード+キャッシュで対応 - * - hide→show高速切替時のデバウンス(100ms) - * - Android measure()の150ms遅延 + * - height<=0 の偽イベントをガード、キャッシュで対応 + * - iOS: keyboardWillShow/Hide(アニメーション同期)+ keyboardWillChangeFrame + * - Android: hide→show 高速切替デバウンス(300ms)+ measure() 150ms 遅延 + * - Animated.timing で paddingBottom を駆動(LayoutAnimation 廃止) */ export function useKeyboardAvoid( options: UseKeyboardAvoidOptions = {} @@ -37,15 +44,58 @@ export function useKeyboardAvoid( const [keyboardHeight, setKeyboardHeight] = useState(0); const [measuredOffset, setMeasuredOffset] = useState(0); + // 再レンダーで Value が作り直されないよう useRef で保持 + const animatedOffset = useRef(new Animated.Value(0)).current; + const showTimerRef = useRef | null>(null); const hideTimerRef = useRef | null>(null); + const retryTimerRef = useRef | null>(null); + const keyboardVisibleRef = useRef(false); const lastValidKbRef = useRef<{ height: number; screenY: number; } | null>(null); + // 実行中アニメーションの参照(高速切替時に前アニメをキャンセルするため) + const currentAnimRef = useRef(null); + // 世代カウンタ: hide/show イベント毎にインクリメントし、 + // 飛行中の古い measure() コールバックを無効化する + const measureGenRef = useRef(0); useEffect(() => { - const doMeasure = (kbScreenY: number, kbHeight: number) => { + const animateTo = (toValue: number) => { + // 前のアニメーションを明示的にキャンセルしてから新しいものを開始 + if (currentAnimRef.current) { + currentAnimRef.current.stop(); + currentAnimRef.current = null; + } + setMeasuredOffset(toValue); // SearchUnitBox など plain number が必要な箇所向け + + let anim: Animated.CompositeAnimation; + if (Platform.OS === "ios") { + // iOS: キーボードの spring アニメーションに近い挙動 + anim = Animated.spring(animatedOffset, { + toValue, + damping: 500, + stiffness: 1000, + mass: 3, + useNativeDriver: false, + }); + } else { + // Android: easeOut で自然な減速カーブ + anim = Animated.timing(animatedOffset, { + toValue, + duration: ANIM_DURATION, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }); + } + currentAnimRef.current = anim; + anim.start(({ finished }) => { + if (finished) currentAnimRef.current = null; + }); + }; + + const doMeasure = (kbScreenY: number, kbHeight: number, gen: number) => { if (measureRef?.current) { (measureRef.current as any).measure( ( @@ -56,28 +106,39 @@ export function useKeyboardAvoid( _pageX: number, pageY: number ) => { + // 世代が変わっていれば hide/show が割り込んだ証拠 → 破棄 + if (gen !== measureGenRef.current) return; const bottomY = pageY + h; const offset = Math.max(0, bottomY - kbScreenY); - LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG); - setMeasuredOffset(offset); + // measure() コールバック内から直接 Animated.timing を起動 → OK + animateTo(offset); } ); } else { - LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG); - setMeasuredOffset( - Platform.OS === "ios" ? kbHeight - tabBarHeight : kbHeight - ); + const offset = + Platform.OS === "ios" ? kbHeight - tabBarHeight : kbHeight; + animateTo(offset); } }; - const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => { + const showEventName = + Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow"; + const hideEventName = + Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide"; + + const showSubscription = Keyboard.addListener(showEventName, (e) => { if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; } if (showTimerRef.current) clearTimeout(showTimerRef.current); + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } - const isValid = e.endCoordinates.height >= 100; + // height <= 0 の偽イベントは無視してキャッシュを使う + const isValid = e.endCoordinates.height > 0; const kbInfo = isValid ? { height: e.endCoordinates.height, @@ -88,35 +149,85 @@ export function useKeyboardAvoid( if (isValid) lastValidKbRef.current = kbInfo; setKeyboardVisible(true); + keyboardVisibleRef.current = true; setKeyboardHeight(kbInfo.height); if (Platform.OS === "android") { + // Android: IME が完全に表示されてから measure() する + // 世代をインクリメントしてから timer に渡す + // → timer 発火後に飛行中の measure() callback を世代で識別できる + const gen = ++measureGenRef.current; showTimerRef.current = setTimeout( - () => doMeasure(kbInfo.screenY, kbInfo.height), + () => doMeasure(kbInfo.screenY, kbInfo.height, gen), 150 ); + // adjustResize のウィンドウリサイズは非同期で 250-300ms かかる。 + // 閉じ→すぐ開き の場合、150ms では中間座標を拾うことがあるため + // 500ms 後にリトライして自動訂正する。gen チェックで陳腐化コールバックは破棄される。 + retryTimerRef.current = setTimeout( + () => doMeasure(kbInfo.screenY, kbInfo.height, gen), + 500 + ); } else { - doMeasure(kbInfo.screenY, kbInfo.height); + // iOS: keyboardWillShow のタイミングで開始すればキーボード出現と同期する + const gen = ++measureGenRef.current; + doMeasure(kbInfo.screenY, kbInfo.height, gen); } }); - const hideSubscription = Keyboard.addListener("keyboardDidHide", () => { + const hideSubscription = Keyboard.addListener(hideEventName, () => { if (showTimerRef.current) clearTimeout(showTimerRef.current); + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + // timer 発火済みで measure() が飛行中の場合はタイマークリアでは止められない。 + // 世代をインクリメントすることで、コールバックが返っても破棄させる。 + measureGenRef.current++; + // Android: IME切替時の hide→show 連続発火に備えて 300ms debounce + // iOS: 50ms のバッファを設ける(即 0ms だと rapid close→open で animateTo(0) が + // 先に走りパディングが一瞬ゼロになる cosmetic 問題を回避) + const delay = Platform.OS === "android" ? 300 : 50; hideTimerRef.current = setTimeout(() => { - LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG); setKeyboardVisible(false); + keyboardVisibleRef.current = false; setKeyboardHeight(0); - setMeasuredOffset(0); - }, 100); + animateTo(0); + }, delay); }); + // iOS のみ: キーボード表示中のサイズ変化(絵文字切替等)に追従 + let frameChangeSubscription: ReturnType< + typeof Keyboard.addListener + > | null = null; + if (Platform.OS === "ios") { + frameChangeSubscription = Keyboard.addListener( + "keyboardWillChangeFrame", + (e) => { + if (!keyboardVisibleRef.current) return; + const kbHeight = e.endCoordinates.height; + if (kbHeight <= 0) return; + lastValidKbRef.current = { + height: kbHeight, + screenY: e.endCoordinates.screenY, + }; + setKeyboardHeight(kbHeight); + const gen = ++measureGenRef.current; + doMeasure(e.endCoordinates.screenY, kbHeight, gen); + } + ); + } + return () => { if (showTimerRef.current) clearTimeout(showTimerRef.current); if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + if (currentAnimRef.current) currentAnimRef.current.stop(); showSubscription.remove(); hideSubscription.remove(); + frameChangeSubscription?.remove(); }; }, [measureRef, tabBarHeight]); - return { keyboardVisible, keyboardHeight, measuredOffset }; + return { keyboardVisible, keyboardHeight, animatedOffset, measuredOffset }; } diff --git a/lib/webViewInjectjavascript.ts b/lib/webViewInjectjavascript.ts index 2dbb2ef..db3248d 100644 --- a/lib/webViewInjectjavascript.ts +++ b/lib/webViewInjectjavascript.ts @@ -859,7 +859,7 @@ export const injectJavascriptData = ({ if(hasElesite) { const elesiteOffsetPx = _blueOffset + (hasUnyohub ? 20 : 0); const offsetStyle = badgeVerticalPos + ":" + elesiteOffsetPx + "px;"; - badgeHtml += "
E
"; + badgeHtml += "
"; } 行き先情報.insertAdjacentHTML('beforebegin', "
" + badgeHtml + "

" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "

" + (isWanman ? "ワンマン " : "") + "

" + viaData + "

" + optionalText + "

" + trainName + "

" + (ToData ? ToData + "行" : ToData) + "

" + trainType + "

" + (hasProblem ? "‼️停止中‼️" : "") + "

"); diff --git a/menu.tsx b/menu.tsx index d88a94c..30e80f7 100644 --- a/menu.tsx +++ b/menu.tsx @@ -1,12 +1,11 @@ import React, { useRef, useState, useEffect, useCallback, useMemo, FC } from "react"; -import { Platform, View, ScrollView, LayoutAnimation, Text, InteractionManager, useWindowDimensions, Image } from "react-native"; +import { Platform, View, ScrollView, LayoutAnimation, Text, InteractionManager, useWindowDimensions, Image, StatusBar } from "react-native"; import Constants from "expo-constants"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { configureReanimatedLogger, ReanimatedLogLevel, } from "react-native-reanimated"; -import StatusbarDetect from "@/StatusbarDetect"; import { useThemeColors } from "@/lib/theme"; import { useResponsive } from "@/lib/responsive"; @@ -16,7 +15,7 @@ import { FixedContentBottom } from "@/components/Menu/FixedContentBottom"; import { lineList, stationIDPair } from "@/lib/getStationList"; import { useFavoriteStation } from "@/stateBox/useFavoriteStation"; -import { useNavigation } from "@react-navigation/native"; +import { useNavigation, useIsFocused } from "@react-navigation/native"; import { useStationList } from "@/stateBox/useStationList"; import { TopMenuButton } from "@/components/Menu/TopMenuButton"; import { JRSTraInfoBox } from "@/components/Menu/JRSTraInfoBox"; @@ -49,6 +48,7 @@ export const Menu: FC = (props) => { const { scrollRef, mapHeight, MapFullHeight, mapMode, setMapMode } = props; const { navigate } = useNavigation(); const { colors, isDark } = useThemeColors(); + const isMenuFocused = useIsFocused(); const { verticalScale } = useResponsive(); const insets = useSafeAreaInsets(); const { favoriteStation } = useFavoriteStation(); @@ -289,7 +289,12 @@ export const Menu: FC = (props) => { paddingTop: Platform.OS === "web" ? 0 : insets.top, }} > - + {isMenuFocused && ( + + )} {!mapMode ? : <>} = ({ children }) => { case "139M": returnArray.push("143M"); break; + case "4126M": + returnArray.push("5126M"); + break; + case "5126M": + returnArray.push("4126M"); + break; // 土讃線琴平界隈 case "1263M": returnArray.push("4263M"); diff --git a/stateBox/useCurrentTrain.tsx b/stateBox/useCurrentTrain.tsx index 5b56be1..009a57b 100644 --- a/stateBox/useCurrentTrain.tsx +++ b/stateBox/useCurrentTrain.tsx @@ -141,14 +141,7 @@ export const CurrentTrainProvider: FC = ({ children }) => { } else { inject(`setReload()`); } - }, 15000, false, !!fixedPosition.type); - useEffect(() => { - if (fixedPosition?.type) { - setIntervalState.start(); - } else { - setIntervalState.stop(); - } - }, [fixedPosition]); + }, 15000, true, false); type getPositionFuncType = ( currentTrainData: trainDataType @@ -327,7 +320,7 @@ export const CurrentTrainProvider: FC = ({ children }) => { const [_0, _1] = useInterval(() => { getCurrentTrain(); - }, 10000, true, !!fixedPosition.type); //10秒毎に全在線列車取得 + }, 15000, true, false); //15秒毎に全在線列車取得 return ( = ({ children }) => { AS.setItem(STORAGE_KEYS.LAST_HANDLED_NOTIFICATION, requestId).catch(() => {}); switch (action) { case "delay-ex": - rootNavigationRef.navigate("topMenu", { screen: "menu" }); + stackAwareNavigate("topMenu", { screen: "menu" }); setTimeout(() => { SheetManager.show("JRSTraInfo"); }, 450); break; case "strange-train": - rootNavigationRef.navigate("positions", { screen: "Apps" }); + stackAwareNavigate("positions"); break; case "information": - rootNavigationRef.navigate("information"); + stackAwareNavigate("information"); break; default: break;