Skip to content

Commit 20757f3

Browse files
authored
SelectLineScreenの分割リファクタリング (#5368)
1 parent bdb5c34 commit 20757f3

13 files changed

+2222
-706
lines changed

src/hooks/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@ export { useHeaderCommonData } from './useHeaderCommonData';
2828
export { useHeaderLangState } from './useHeaderLangState';
2929
export { useHeaderStateText } from './useHeaderStateText';
3030
export { useHeaderStationText } from './useHeaderStationText';
31+
export { useInitialNearbyStation } from './useInitialNearbyStation';
3132
export { useInRadiusStation } from './useInRadiusStation';
3233
export { useInterval } from './useInterval';
3334
export { useIsDifferentStationName } from './useIsDifferentStationName';
3435
export { useIsNextLastStop } from './useIsNextLastStop';
3536
export { useIsPassing } from './useIsPassing';
3637
export { useIsTerminus } from './useIsTerminus';
3738
export { useLazyPrevious } from './useLazyPrevious';
39+
export { useLineSelection } from './useLineSelection';
3840
export { useLocationPermissionsGranted } from './useLocationPermissionsGranted';
3941
export { useLockLandscapeOnActive } from './useLockLandscapeOnActive';
4042
export { useLoopLine } from './useLoopLine';
@@ -45,19 +47,22 @@ export { useNextStation } from './useNextStation';
4547
export { useNextTrainType } from './useNextTrainType';
4648
export { useNumbering } from './useNumbering';
4749
export { useOpenRouteFromLink } from './useOpenRouteFromLink';
50+
export { usePresetCarouselData } from './usePresetCarouselData';
4851
export { usePrevious } from './usePrevious';
4952
export { usePreviousStation } from './usePreviousStation';
5053
export { useRefreshLeftStations } from './useRefreshLeftStations';
5154
export { useRefreshStation } from './useRefreshStation';
5255
export { useResetMainState } from './useResetMainState';
5356
export { useRouteSearchWalkthrough } from './useRouteSearchWalkthrough';
5457
export { useSavedRoutes } from './useSavedRoutes';
58+
export { useSelectLineWalkthrough } from './useSelectLineWalkthrough';
5559
export { useSettingsWalkthrough } from './useSettingsWalkthrough';
5660
export { useShouldHideTypeChange } from './useShouldHideTypeChange';
5761
export { useSimulationMode } from './useSimulationMode';
5862
export { useSlicedStations } from './useSlicedStations';
5963
export { useStartBackgroundLocationUpdates } from './useStartBackgroundLocationUpdates';
6064
export { useStationNumberIndexFunc } from './useStationNumberIndexFunc';
65+
export { useStationsCache } from './useStationsCache';
6166
export { useStoppingState } from './useStoppingState';
6267
export { useTelemetrySender } from './useTelemetrySender';
6368
export { useThreshold } from './useThreshold';
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import { render } from '@testing-library/react-native';
3+
import * as Location from 'expo-location';
4+
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
5+
import type React from 'react';
6+
import { Alert } from 'react-native';
7+
import { createStation } from '~/utils/test/factories';
8+
import type { StationState } from '../store/atoms/station';
9+
import {
10+
type UseInitialNearbyStationResult,
11+
useInitialNearbyStation,
12+
} from './useInitialNearbyStation';
13+
14+
jest.mock('jotai', () => ({
15+
useAtom: jest.fn(),
16+
useAtomValue: jest.fn(),
17+
useSetAtom: jest.fn(),
18+
atom: jest.fn(),
19+
}));
20+
21+
jest.mock('expo-location', () => ({
22+
hasStartedLocationUpdatesAsync: jest.fn().mockResolvedValue(false),
23+
stopLocationUpdatesAsync: jest.fn(),
24+
Accuracy: { Highest: 6, Balanced: 3 },
25+
}));
26+
27+
jest.mock('@react-native-async-storage/async-storage', () => ({
28+
getItem: jest.fn().mockResolvedValue('true'),
29+
setItem: jest.fn(),
30+
}));
31+
32+
jest.mock('./useFetchNearbyStation', () => ({
33+
useFetchNearbyStation: jest.fn().mockReturnValue({
34+
stations: [],
35+
fetchByCoords: jest
36+
.fn()
37+
.mockResolvedValue({ data: { stationsNearby: [] } }),
38+
isLoading: false,
39+
error: null,
40+
}),
41+
}));
42+
43+
jest.mock('./useFetchCurrentLocationOnce', () => ({
44+
useFetchCurrentLocationOnce: jest.fn().mockReturnValue({
45+
fetchCurrentLocation: jest.fn(),
46+
}),
47+
}));
48+
49+
jest.mock('../translation', () => ({
50+
translate: jest.fn((key: string) => key),
51+
isJapanese: true,
52+
}));
53+
54+
type HookResult = UseInitialNearbyStationResult | null;
55+
56+
const HookBridge: React.FC<{ onReady: (value: HookResult) => void }> = ({
57+
onReady,
58+
}) => {
59+
onReady(useInitialNearbyStation());
60+
return null;
61+
};
62+
63+
describe('useInitialNearbyStation', () => {
64+
const mockSetStationState = jest.fn();
65+
const mockSetNavigationState = jest.fn();
66+
const mockUseAtom = useAtom as unknown as jest.Mock;
67+
const mockUseAtomValue = useAtomValue as unknown as jest.Mock;
68+
const mockUseSetAtom = useSetAtom as unknown as jest.Mock;
69+
70+
beforeEach(() => {
71+
jest.clearAllMocks();
72+
jest.spyOn(Alert, 'alert').mockImplementation();
73+
74+
mockUseAtom.mockReturnValue([
75+
{
76+
station: null,
77+
stations: [],
78+
stationsCache: [],
79+
pendingStation: null,
80+
pendingStations: [],
81+
selectedDirection: null,
82+
selectedBound: null,
83+
wantedDestination: null,
84+
arrived: false,
85+
approaching: false,
86+
} satisfies StationState,
87+
mockSetStationState,
88+
]);
89+
90+
mockUseSetAtom.mockReturnValue(mockSetNavigationState);
91+
92+
// locationAtom
93+
mockUseAtomValue.mockReturnValue(null);
94+
});
95+
96+
it('station が null のときは nearbyStationLoading を返す', () => {
97+
const hookRef: { current: HookResult } = { current: null };
98+
render(
99+
<HookBridge
100+
onReady={(v) => {
101+
hookRef.current = v;
102+
}}
103+
/>
104+
);
105+
106+
expect(hookRef.current?.station).toBeNull();
107+
expect(hookRef.current?.nearbyStationLoading).toBe(false);
108+
});
109+
110+
it('stationFromAtom があればそれを返す', () => {
111+
const existingStation = createStation(1);
112+
mockUseAtom.mockReturnValue([
113+
{
114+
station: existingStation,
115+
stations: [],
116+
stationsCache: [],
117+
pendingStation: null,
118+
pendingStations: [],
119+
selectedDirection: null,
120+
selectedBound: null,
121+
wantedDestination: null,
122+
arrived: false,
123+
approaching: false,
124+
} satisfies StationState,
125+
mockSetStationState,
126+
]);
127+
128+
const hookRef: { current: HookResult } = { current: null };
129+
render(
130+
<HookBridge
131+
onReady={(v) => {
132+
hookRef.current = v;
133+
}}
134+
/>
135+
);
136+
137+
expect(hookRef.current?.station).toBe(existingStation);
138+
});
139+
140+
it('バックグラウンド位置更新を停止する', async () => {
141+
(Location.hasStartedLocationUpdatesAsync as jest.Mock).mockResolvedValue(
142+
true
143+
);
144+
145+
render(<HookBridge onReady={() => {}} />);
146+
147+
await new Promise((r) => setTimeout(r, 0));
148+
expect(Location.stopLocationUpdatesAsync).toHaveBeenCalled();
149+
});
150+
151+
it('初回起動時にアラートを表示する', async () => {
152+
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
153+
154+
render(<HookBridge onReady={() => {}} />);
155+
156+
await new Promise((r) => setTimeout(r, 0));
157+
expect(Alert.alert).toHaveBeenCalledWith(
158+
'notice',
159+
'firstAlertText',
160+
expect.any(Array)
161+
);
162+
});
163+
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import * as Location from 'expo-location';
3+
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
4+
import { useEffect, useMemo, useRef } from 'react';
5+
import { Alert } from 'react-native';
6+
import type { Station } from '~/@types/graphql';
7+
import { ASYNC_STORAGE_KEYS, LOCATION_TASK_NAME } from '../constants';
8+
import { locationAtom, setLocation } from '../store/atoms/location';
9+
import navigationState from '../store/atoms/navigation';
10+
import stationState from '../store/atoms/station';
11+
import { translate } from '../translation';
12+
import { useFetchCurrentLocationOnce } from './useFetchCurrentLocationOnce';
13+
import { useFetchNearbyStation } from './useFetchNearbyStation';
14+
15+
const INITIAL_LOCATION_FALLBACK_DELAY_MS = 800;
16+
17+
export type UseInitialNearbyStationResult = {
18+
station: Station | null;
19+
nearbyStationLoading: boolean;
20+
};
21+
22+
export const useInitialNearbyStation = (): UseInitialNearbyStationResult => {
23+
const [stationAtomState, setStationState] = useAtom(stationState);
24+
const setNavigationState = useSetAtom(navigationState);
25+
const location = useAtomValue(locationAtom);
26+
const latitude = location?.coords.latitude;
27+
const longitude = location?.coords.longitude;
28+
29+
const { station: stationFromAtom } = stationAtomState;
30+
const initialNearbyFetchInFlightRef = useRef(false);
31+
32+
const {
33+
stations: nearbyStations,
34+
fetchByCoords,
35+
isLoading: nearbyStationLoading,
36+
error: nearbyStationFetchError,
37+
} = useFetchNearbyStation();
38+
39+
const { fetchCurrentLocation } = useFetchCurrentLocationOnce();
40+
41+
const station = useMemo(
42+
() => stationFromAtom ?? nearbyStations[0] ?? null,
43+
[stationFromAtom, nearbyStations]
44+
);
45+
46+
// バックグラウンド位置更新を停止
47+
useEffect(() => {
48+
const stopLocationUpdates = async () => {
49+
const hasStartedLocationUpdates =
50+
await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
51+
if (hasStartedLocationUpdates) {
52+
await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
53+
}
54+
};
55+
stopLocationUpdates();
56+
}, []);
57+
58+
// 最寄り駅の取得
59+
useEffect(() => {
60+
const fetchInitialNearbyStationAsync = async (coords?: {
61+
latitude: number;
62+
longitude: number;
63+
}) => {
64+
if (station || initialNearbyFetchInFlightRef.current) return;
65+
initialNearbyFetchInFlightRef.current = true;
66+
67+
try {
68+
let requestCoords = coords;
69+
if (!requestCoords) {
70+
const currentLocation = await fetchCurrentLocation(true);
71+
if (!currentLocation) return;
72+
setLocation(currentLocation);
73+
requestCoords = {
74+
latitude: currentLocation.coords.latitude,
75+
longitude: currentLocation.coords.longitude,
76+
};
77+
}
78+
79+
const data = await fetchByCoords({
80+
latitude: requestCoords.latitude,
81+
longitude: requestCoords.longitude,
82+
limit: 1,
83+
});
84+
85+
const stationFromAPI = data.data?.stationsNearby[0] ?? null;
86+
setStationState((prev) => ({
87+
...prev,
88+
station: stationFromAPI,
89+
}));
90+
setNavigationState((prev) => ({
91+
...prev,
92+
stationForHeader: stationFromAPI,
93+
}));
94+
} catch (error) {
95+
console.error(error);
96+
} finally {
97+
initialNearbyFetchInFlightRef.current = false;
98+
}
99+
};
100+
101+
if (latitude != null && longitude != null) {
102+
fetchInitialNearbyStationAsync({ latitude, longitude });
103+
return;
104+
}
105+
106+
const fallbackTimerId = setTimeout(() => {
107+
fetchInitialNearbyStationAsync();
108+
}, INITIAL_LOCATION_FALLBACK_DELAY_MS);
109+
110+
return () => {
111+
clearTimeout(fallbackTimerId);
112+
};
113+
}, [
114+
fetchByCoords,
115+
fetchCurrentLocation,
116+
latitude,
117+
longitude,
118+
setNavigationState,
119+
setStationState,
120+
station,
121+
]);
122+
123+
// 初回起動アラート
124+
useEffect(() => {
125+
const checkFirstLaunch = async () => {
126+
const firstLaunchPassed = await AsyncStorage.getItem(
127+
ASYNC_STORAGE_KEYS.FIRST_LAUNCH_PASSED
128+
);
129+
if (firstLaunchPassed === null) {
130+
Alert.alert(translate('notice'), translate('firstAlertText'), [
131+
{
132+
text: 'OK',
133+
onPress: (): void => {
134+
AsyncStorage.setItem(
135+
ASYNC_STORAGE_KEYS.FIRST_LAUNCH_PASSED,
136+
'true'
137+
);
138+
},
139+
},
140+
]);
141+
}
142+
};
143+
checkFirstLaunch();
144+
}, []);
145+
146+
// 最寄り駅取得エラーのアラート
147+
useEffect(() => {
148+
if (nearbyStationFetchError) {
149+
console.error(nearbyStationFetchError);
150+
Alert.alert(translate('errorTitle'), translate('apiErrorText'));
151+
}
152+
}, [nearbyStationFetchError]);
153+
154+
return { station, nearbyStationLoading };
155+
};

0 commit comments

Comments
 (0)