Merge commit '14e8e3422cad6bded9edb33e63dcd20fb9575173'

This commit is contained in:
harukin-expo-dev-env
2026-04-26 08:49:39 +00:00
44 changed files with 1562 additions and 301 deletions

22
App.tsx
View File

@@ -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);
}
};

View File

@@ -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 */}
<Tab.Navigator
initialRouteName="topMenu"
screenOptions={({ route }) => {
@@ -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,

View File

@@ -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<WebView>(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 (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
<WebView
source={{ uri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
ref={webViewRef}
onNavigationStateChange={(navState) => {
{isEnvironmentReady && (
<WebView
source={{ uri: resolvedUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
ref={webViewRef}
onShouldStartLoadWithRequest={(request) => {
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 && <BigButton onPress={goBack} string="閉じる" />}
</View>
);

View File

@@ -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<any>(null);
return (
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {};
}}
>
<Stack.Screen
name="menu"

View File

@@ -1,11 +1,8 @@
import React, { FC } from "react";
import { Platform, StatusBar } from "react-native";
import { useThemeColors } from "@/lib/theme";
const StatusbarDetect: FC = () => {
const { isDark } = useThemeColors();
const barStyle = isDark ? "light-content" : "dark-content";
return <StatusBar barStyle={barStyle} translucent backgroundColor="transparent" />;
return <StatusBar barStyle="light-content" translucent backgroundColor="transparent" />;
};
export default StatusbarDetect;

19
Top.tsx
View File

@@ -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 = () => {
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {};
}}
>
<Stack.Screen
name="Apps"

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import React, { useRef, useState } from "react";
import { Platform } from "react-native";
import ActionSheet from "react-native-actions-sheet";
import { EachTrainInfoCore } from "./EachTrainInfoCore";
@@ -6,6 +6,16 @@ import { useSheetMaxHeight } from "./useSheetMaxHeight";
export const EachTrainInfo = ({ payload }) => {
const actionSheetRef = useRef(null);
const maxHeight = useSheetMaxHeight();
const [sheetOpened, setSheetOpened] = useState(false);
const handleOpen = () => {
setSheetOpened(true);
};
const handleClose = () => {
setSheetOpened(false);
};
if (!payload) return <></>;
return (
<ActionSheet
@@ -15,10 +25,11 @@ export const EachTrainInfo = ({ payload }) => {
drawUnderStatusBar={false}
isModal={Platform.OS === "ios" && !Platform.isPad}
containerStyle={{ maxHeight }}
onOpen={handleOpen}
onClose={handleClose}
//useBottomSafeAreaPadding={Platform.OS == "android"}
>
<EachTrainInfoCore {...{ actionSheetRef, ...payload }} />
<EachTrainInfoCore {...{ actionSheetRef, sheetOpened, ...payload }} />
</ActionSheet>
);
};

View File

@@ -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<props> = ({
//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");
}}
>

View File

@@ -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<any>(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

View File

@@ -59,6 +59,7 @@ export const HeaderText: FC<Props> = ({
getUnyohubByTrainNumber,
getUnyohubEntriesByTrainNumber,
useUnyohub: unyohubEnabled,
unyohubData,
} = useUnyohub();
const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } =
useElesite();
@@ -160,9 +161,8 @@ export const HeaderText: FC<Props> = ({
}
}, [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<Props> = ({
}
const unyohubLookupNum = customTrainData?.train_number_override || trainNum;
const isFreightRetsuban = unyohubLookupNum.includes("レ");
const unyohubTrainNumForSourceScreen = isFreightRetsuban
? unyohubLookupNum.replace(/レ/g, "")
: unyohubLookupNum;
const freightUnyohubCandidates = (() => {
const digits = unyohubTrainNumForSourceScreen.replace(/[^\d]/g, "");
const candidates = new Set<string>();
if (!digits) return candidates;
candidates.add(digits);
if (/^\d{2}$/.test(digits)) {
candidates.add(`30${digits}`);
candidates.add(`90${digits}`);
} else if (/^(30|90)\d{2}$/.test(digits)) {
candidates.add(digits.slice(-2));
}
return candidates;
})();
const unyohubFormation = getUnyohubByTrainNumber(unyohubLookupNum);
const unyohubEntries = 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<Props> = ({
(SheetManager.show as any)("TrainDataSources", {
payload: {
trainNum,
unyohubTrainNum: unyohubLookupNum,
unyohubTrainNum: unyohubTrainNumForSourceScreen,
unyohubEntries,
elesiteEntries,
todayOperation,

View File

@@ -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<any>,
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]);
};

View File

@@ -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<string[][]>([]);
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [stopStationIDList, setStopStationIDList] = useState<string[][]>(() =>
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;

View File

@@ -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<string, any[]>
): { 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 };
};

View File

@@ -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<string, string>) => {
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) => {

View File

@@ -132,7 +132,7 @@ export const TrainIconStatus: FC<Props> = (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) => {

View File

@@ -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();

View File

@@ -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> = (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();
}}

View File

@@ -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<string>();
if (!digits) return candidates;
candidates.add(digits);
if (/^\d{2}$/.test(digits)) {
candidates.add(`30${digits}`);
candidates.add(`90${digits}`);
} else if (/^(30|90)\d{2}$/.test(digits)) {
candidates.add(digits.slice(-2));
}
return candidates;
})();
const 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 }> = ({
</View>
);
// 鉄道運用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) && (
<View style={[styles.noteSection, { borderLeftColor: colors.borderSecondary }]}>
<View style={styles.noteSection}>
{!!uwasa && (
<View style={styles.noteRow}>
<View
style={[
styles.noteRow,
styles.rumorRow,
{
backgroundColor: "rgba(245, 158, 11, 0.1)",
borderColor: colors.textWarning,
},
]}
>
<MaterialCommunityIcons
name="message-text-outline"
size={12}
color={colors.iconSecondary}
name="alert-circle-outline"
size={14}
color={colors.textWarning}
style={styles.rumorIcon}
/>
<Text style={[styles.noteText, { color: colors.textSecondary }]}>{uwasa}</Text>
<View style={styles.noteTextWrap}>
<Text style={[styles.rumorLabel, { color: colors.textWarning }]}></Text>
<Text style={[styles.noteText, { color: colors.textSecondary }]}>{uwasa}</Text>
</View>
</View>
)}
{!!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,

View File

@@ -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<View>(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 (
<View ref={containerRef} style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: measuredPadding }}>
<Animated.View ref={containerRef} style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: animatedOffset }}>
<FlatList
contentContainerStyle={{ justifyContent: "flex-end", flexGrow: 1 }}
style={{ flex: 1 }}
@@ -358,6 +359,6 @@ export const AllTrainDiagramView: FC = () => {
string="閉じる"
style={{ display: keyBoardVisible ? "none" : "flex" }}
/>
</View>
</Animated.View>
);
};

View File

@@ -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 (
<View style={{ flex: 1, backgroundColor: bgColor }}>
{lineColor && lineColorDark && (
{isFocused && mapSwitch === "true" && lineColor && lineColorDark && (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
@@ -91,10 +92,11 @@ export default function Apps() {
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
/>
)}
{lineColor && (
<StatusBar
barStyle="light-content"
/>
{isFocused && mapSwitch !== "true" && (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: top, backgroundColor: "#0099CC" }} />
)}
{isFocused && (
<StatusBar barStyle="light-content" />
)}
<View
style={{

View File

@@ -8,6 +8,7 @@ import {
Platform,
} from "react-native";
import Ionicons from "react-native-vector-icons/Ionicons";
import { stackAwareNavigate } from "@/lib/rootNavigation";
import { SearchUnitBox } from "@/components/Menu/RailScope/SearchUnitBox";
import { StationSource } from "@/types";
import { STORAGE_KEYS } from "@/constants";
@@ -47,7 +48,7 @@ export const CarouselTypeChanger = ({
if (isGpsFollowing) {
setFixedPosition({ type: null, value: null });
} else {
navigate("positions", { screen: "Apps" } as any);
stackAwareNavigate("positions");
setFixedPosition({ type: "nearestStation", value: null });
}
};

View File

@@ -8,9 +8,15 @@ import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import {
DEFAULT_JR_DATA_SYSTEM_ENV,
JR_DATA_SYSTEM_ENV_OPTIONS,
JrDataSystemEnvironmentKey,
normalizeJrDataSystemEnvironment,
} from "@/lib/jrDataSystemEnvironment";
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");
/* ------------------------------------------------------------------ */
/* DataSourceAccordionCard */
/* ------------------------------------------------------------------ */
@@ -141,16 +147,17 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
/* 定数 */
/* ------------------------------------------------------------------ */
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: "アプリ外リンク連携にて当日の運用の投稿が可能" },
];
/* ------------------------------------------------------------------ */
@@ -158,11 +165,13 @@ const ELESITE_FEATURES: Feature[] = [
/* ------------------------------------------------------------------ */
export const DataSourceSettings = () => {
const navigation = useNavigation();
const { dataSourcePermission, updatePermission } = useTrainMenu();
const { 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<JrDataSystemEnvironmentKey>(DEFAULT_JR_DATA_SYSTEM_ENV);
useEffect(() => {
AS.getItem(STORAGE_KEYS.USE_UNYOHUB).then((value) => {
@@ -171,6 +180,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) => {
@@ -179,17 +195,21 @@ export const DataSourceSettings = () => {
};
const handleToggleElesite = (value: boolean) => {
if (!canUseElesite) return;
setUseElesite(value);
AS.setItem(STORAGE_KEYS.USE_ELESITE, value.toString());
};
const handleSelectJrDataSystemEnv = (value: JrDataSystemEnvironmentKey) => {
setJrDataSystemEnv(value);
AS.setItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV, value);
};
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem
title="情報ソース設定"
LeftItem={{
title: "戻る",
title: " 戻る",
onPress: () => navigation.goBack(),
position: "left",
}}
@@ -212,8 +232,7 @@ export const DataSourceSettings = () => {
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
/>
{canUseElesite && (
<DataSourceAccordionCard
<DataSourceAccordionCard
logo={ELESITE_LOGO_PNG}
accentColor="#44bb44"
title="えれサイト"
@@ -221,13 +240,12 @@ export const DataSourceSettings = () => {
enabled={useElesite}
onToggle={handleToggleElesite}
description={
"えれサイトは、鉄道運用情報を共有するためのサイトです。皆様からの投稿を通じて、鉄道運行に関する情報を共有するサイトです。JR 四国の特急・普通列車を中心に対応しています。\n\nデータがある列車では地図上に緑色の「E」バッジが表示され、列車情報画面の編成表示も更新されます。"
"えれサイトは、鉄道運用情報を利用者同士で共有するサービスです。皆様からの投稿をもとに、列車のリアルタイムな動きを反映しています。JR四国の特急・普通列車をはじめ、現在は全国の路線に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={ELESITE_FEATURES}
linkLabel="elesite-next.com を開く"
linkUrl="https://www.elesite-next.com/"
/>
)}
<View style={[styles.infoSection, { backgroundColor: colors.backgroundTertiary }]}>
<Text style={[styles.infoText, { color: colors.textCaution }]}>
@@ -236,6 +254,56 @@ export const DataSourceSettings = () => {
{"\n\n"}JR四国非公式アプリが管理していないデータであるため
</Text>
</View>
{showDebugSelector && (
<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 }]}>
稿 / ChatGPT案 / Claude案で切り替えます
</Text>
<View style={styles.debugOptionRow}>
{JR_DATA_SYSTEM_ENV_OPTIONS.map((option) => {
const selected = jrDataSystemEnv === option.key;
return (
<TouchableOpacity
key={option.key}
style={[
styles.debugOptionButton,
{
backgroundColor: selected
? fixed.primary
: colors.backgroundTertiary,
borderColor: selected
? fixed.primary
: colors.borderSecondary,
},
]}
onPress={() => handleSelectJrDataSystemEnv(option.key)}
activeOpacity={0.8}
>
<Text
style={[
styles.debugOptionTitle,
{ color: selected ? fixed.textOnPrimary : colors.textPrimary },
]}
>
{option.label}
</Text>
<Text
style={[
styles.debugOptionCaption,
{ color: selected ? fixed.textOnPrimary : colors.textTertiary },
]}
>
{option.caption}
</Text>
</TouchableOpacity>
);
})}
</View>
<Text style={[styles.debugCurrentText, { color: colors.textTertiary }]}>: {JR_DATA_SYSTEM_ENV_OPTIONS.find((option) => option.key === jrDataSystemEnv)?.baseUrl}</Text>
</View>
)}
</ScrollView>
</View>
);
@@ -403,4 +471,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,
},
});

View File

@@ -17,7 +17,7 @@ import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
const versionCode = "7.0.1"; // Update this version code as needed
const versionCode = "7.0.2"; // Update this version code as needed
export const SettingTopPage = ({
testNFC,

View File

@@ -308,8 +308,6 @@ export const ExGridView: FC<{
<Animated.ScrollView
style={[{ width: width }, animatedStyle]}
pinchGestureEnabled={false}
minimumZoomScale={0.5}
maximumZoomScale={3.0}
scrollEnabled={scrollEnabled}
stickyHeaderIndices={
groupKeys.at(0) ? groupKeys.map((_, i) => i * 2) : []

View File

@@ -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;

View File

@@ -1,5 +1,6 @@
import { FC, useEffect, useRef, useState } from "react";
import {
Animated,
View,
Text,
ScrollView,
@@ -70,7 +71,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
const { colors, fixed } = useThemeColors();
const tabBarHeight = useBottomTabBarHeight();
const containerRef = useRef<View>(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<props> = ({ route }) => {
}, [currentStationDiagram, currentTrain]);
return (
<View
<Animated.View
ref={containerRef}
style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: keyboardOffset }}
>
@@ -613,7 +614,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
{keyBoardVisible || (
<BigButton onPress={() => goBack()} string="閉じる" />
)}
</View>
</Animated.View>
);
};

View File

@@ -91,6 +91,9 @@ export const STORAGE_KEYS = {
/** えれサイト使用設定 */
USE_ELESITE: 'useElesite',
/** 投稿システム接続先(デバッグ用) */
JR_DATA_SYSTEM_ENV: 'jrDataSystemEnv',
/** えれサイトデータ */
ELESITE_DATA: 'elesiteData',

View File

@@ -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<string[][]>(() =>
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` フラグでゲートする。

View File

@@ -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)`.

View File

@@ -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<Animated.CompositeAnimation | null>(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 アニメーション完了(キーボード出現と同期)
```

View File

@@ -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)とは明確に区別されます。

151
docs/privacy-policy.md Normal file
View File

@@ -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. 第三者データソースの利用
本アプリでは、設定画面から任意で以下の第三者データソースとの連携を有効にできます。
- 鉄道運用Hubunyohub
- えれサイトelesite
これらのデータソースから取得したデータは表示目的でのみ使用し、ユーザーの情報をこれらのサービスに送信することはありません。連携機能を利用する場合は、各サービスの利用規約に同意したものとみなします。
一部の第三者データソース鉄道運用Hub等は独自のログイン機能を提供しています。ユーザーがWebView内でログインした場合、そのセッション情報Cookie等はアプリ内のWebViewストレージに保持されます。開発者がこれらの認証情報にアクセスすることはありません。セッション情報の取り扱いは各サービスのプライバシーポリシーに従います。
---
## 6. データの保存期間
### サーバー側データ
- **プッシュ通知トークン・通知設定:** ユーザーが明示的に削除を要求するまで、またはサービス終了まで保持します。
### 端末内データ
- ユーザー設定、お気に入り、ICカードデータ、キャッシュデータ等は、ユーザーがアプリをアンインストールするか、端末内のデータを消去するまで保持されます。
---
## 7. データの削除
### 端末内データの削除
- アプリのアンインストール、または端末の設定からアプリのデータ消去を行うことで、端末内に保存されたすべてのデータを削除できます。
### サーバー側データの削除
- サーバーに保存されたプッシュ通知トークンおよび通知設定の削除を希望する場合は、後述の問い合わせ先までご連絡ください。
---
## 8. セキュリティ
開発者は、収集した情報の漏洩、紛失、改ざんを防止するために合理的な技術的措置を講じます。ただし、インターネットを通じたデータ送信の安全性を完全に保証するものではありません。
---
## 9. 児童のプライバシー
本アプリは特定の年齢層を対象としたものではなく、13歳未満の児童から意図的に個人情報を収集することはありません。
---
## 10. アナリティクス・広告
本アプリはアクセス解析ツール(アナリティクス)および広告配信サービスを使用していません。
---
## 11. 本ポリシーの変更
開発者は、本ポリシーを変更する場合があります。重要な変更を行う場合は、アプリ内のお知らせまたは開発者のWebサイトにて通知します。変更後も本アプリを継続利用した場合、変更後のポリシーに同意したものとみなします。
---
## 12. お問い合わせ先
本ポリシーに関するお問い合わせは、以下の窓口までご連絡ください。
- **開発者:** harukinXprocess
- **Twitter/X:** [@xprocess_main](https://twitter.com/xprocess_main)
- **マシュマロ(匿名質問箱):** https://marshmallow-qa.com/pag3sl0ju3g1jm7
- **Webサイト:** https://haruk.in
---
© 2022 - 2026 harukin

View File

@@ -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 可能だが、検証が必要
---
## 結論・推奨アプローチ
### フェーズ1OTA配信— すぐ着手可能
1. **ウォークスルー**: `react-native-reanimated-carousel` で実装 → OTA配信
2. **コンテキストヒント**: `BottomSheet` / `ActionSheet` で実装 → OTA配信
3. **段階的開放ロジック**: `AsyncStorage` カウンタ → OTA配信
### フェーズ2次回ビルド時— コーチマークの判断
- **コーチマークが必須なら**: `react-native-copilot`Pure JSを追加し、次回ビルドに含める
- **コーチマーク不要 or 後回しなら**: フェーズ1だけで十分な体験改善が可能
### 差分まとめ
| 観点 | パターンAOTA | パターンBライブラリ追加 |
|------|------------------|---------------------------|
| 配信速度 | 即座 | 次回ビルド待ち |
| コーチマーク難易度 | ★★★★☆ | ★★☆☆☆ |
| それ以外の難易度 | 同等 | 同等 |
| 依存リスク | なし | 低Pure JS |
| 推奨 | フェーズ1はこちら | コーチマーク実装時に検討 |

View File

@@ -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;
};

View File

@@ -1,3 +1,47 @@
import { createNavigationContainerRef } from "@react-navigation/native";
import { createNavigationContainerRef, StackActions } from "@react-navigation/native";
export const rootNavigationRef = createNavigationContainerRef<any>();
/** 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();
}
}

View File

@@ -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,
};

View File

@@ -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<ReturnType<typeof setTimeout> | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const keyboardVisibleRef = useRef(false);
const lastValidKbRef = useRef<{
height: number;
screenY: number;
} | null>(null);
// 実行中アニメーションの参照(高速切替時に前アニメをキャンセルするため)
const currentAnimRef = useRef<Animated.CompositeAnimation | null>(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 };
}

View File

@@ -859,7 +859,7 @@ export const injectJavascriptData = ({
if(hasElesite) {
const elesiteOffsetPx = _blueOffset + (hasUnyohub ? 20 : 0);
const offsetStyle = badgeVerticalPos + ":" + elesiteOffsetPx + "px;";
badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;'><span style='color:white;font-size:10px;font-weight:bold;line-height:1;'>E</span></div>";
badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;overflow:hidden;display:flex;align-items:center;justify-content:center;'><img src='https://storage.haruk.in/elesite_logo.jpg' style='width:19px;height:19px;'/></div>";
}
行き先情報.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;'>" + badgeHtml + "<p style='font-size:6px;padding:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + (isWanman ? "ワンマン " : "") + "</p><p style='font-size:6px;font-weight:bold;padding:0;color:black;text-align:center;border-style:solid;border-width: "+(!!yosan2Color ? "2px" : "0px")+";border-color:" + yosan2Color + "'>" + viaData + "</p><p style='font-size:8px;font-weight:bold;padding:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + trainName + "</p><div style='width:100%;background:" + gradient + ";'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;align-items:center;align-content:center;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + (ToData ? ToData + "行" : ToData) + "</p></div><div style='width:100%;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";'><p style='font-size:10px;font-weight:bold;font-style:italic;padding:0;color: white;text-align:center;'>" + trainType + "</p></div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color: "+(hasProblem ? "red": "black")+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>");

View File

@@ -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> = (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> = (props) => {
paddingTop: Platform.OS === "web" ? 0 : insets.top,
}}
>
<StatusbarDetect />
{isMenuFocused && (
<StatusBar
barStyle={isDark ? "light-content" : "dark-content"}
translucent
/>
)}
{!mapMode ? <TitleBar /> : <></>}
<ScrollView
ref={scrollRef}

View File

@@ -100,6 +100,12 @@ export const BusAndTrainDataProvider: FC<Props> = ({ children }) => {
case "139M":
returnArray.push("143M");
break;
case "4126M":
returnArray.push("5126M");
break;
case "5126M":
returnArray.push("4126M");
break;
// 土讃線琴平界隈
case "1263M":
returnArray.push("4263M");

View File

@@ -141,14 +141,7 @@ export const CurrentTrainProvider: FC<props> = ({ 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<props> = ({ children }) => {
const [_0, _1] = useInterval(() => {
getCurrentTrain();
}, 10000, true, !!fixedPosition.type); //10秒毎に全在線列車取得
}, 15000, true, false); //15秒毎に全在線列車取得
return (
<CurrentTrainContext.Provider

View File

@@ -3,7 +3,6 @@ import { AS } from "../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { API_ENDPOINTS } from "@/constants";
import type { ElesiteResponse, ElesiteData } from "@/types/unyohub";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
type ElesiteHook = {
/** えれサイト使用設定 */
@@ -19,15 +18,13 @@ type ElesiteHook = {
};
export const useElesite = (): ElesiteHook => {
const { dataSourcePermission, updatePermission } = useTrainMenu();
const canUseElesite = updatePermission || dataSourcePermission.elesite;
const [useElesite, setUseElesiteState] = useState(false);
const [elesiteData, setElesiteData] = useState<ElesiteResponse>([]);
// 初期読み込み
useEffect(() => {
AS.getItem(STORAGE_KEYS.USE_ELESITE).then((value) => {
setUseElesiteState((value === true || value === "true") && canUseElesite);
setUseElesiteState(value === true || value === "true");
});
AS.getItem(STORAGE_KEYS.ELESITE_DATA).then((value) => {
@@ -39,14 +36,7 @@ export const useElesite = (): ElesiteHook => {
}
}
});
}, [canUseElesite]);
// 権限がない場合は設定を強制OFF
useEffect(() => {
if (canUseElesite) return;
setUseElesiteState(false);
AS.setItem(STORAGE_KEYS.USE_ELESITE, "false");
}, [canUseElesite]);
}, []);
// データ更新処理
useEffect(() => {
@@ -114,9 +104,8 @@ export const useElesite = (): ElesiteHook => {
// 設定を更新
const setUseElesite = (value: boolean) => {
const next = value && canUseElesite;
setUseElesiteState(next);
AS.setItem(STORAGE_KEYS.USE_ELESITE, next.toString());
setUseElesiteState(value);
AS.setItem(STORAGE_KEYS.USE_ELESITE, value.toString());
};
return {

View File

@@ -11,7 +11,7 @@ import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { logger } from "@/utils/logger";
import { rootNavigationRef } from "@/lib/rootNavigation";
import { rootNavigationRef, stackAwareNavigate } from "@/lib/rootNavigation";
import { SheetManager } from "react-native-actions-sheet";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
@@ -220,16 +220,16 @@ export const NotificationProvider: FC<Props> = ({ 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;