Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ export { useHeaderCommonData } from './useHeaderCommonData';
export { useHeaderLangState } from './useHeaderLangState';
export { useHeaderStateText } from './useHeaderStateText';
export { useHeaderStationText } from './useHeaderStationText';
export { useInitialNearbyStation } from './useInitialNearbyStation';
export { useInRadiusStation } from './useInRadiusStation';
export { useInterval } from './useInterval';
export { useIsDifferentStationName } from './useIsDifferentStationName';
export { useIsNextLastStop } from './useIsNextLastStop';
export { useIsPassing } from './useIsPassing';
export { useIsTerminus } from './useIsTerminus';
export { useLazyPrevious } from './useLazyPrevious';
export { useLineSelection } from './useLineSelection';
export { useLocationPermissionsGranted } from './useLocationPermissionsGranted';
export { useLockLandscapeOnActive } from './useLockLandscapeOnActive';
export { useLoopLine } from './useLoopLine';
Expand All @@ -45,19 +47,22 @@ export { useNextStation } from './useNextStation';
export { useNextTrainType } from './useNextTrainType';
export { useNumbering } from './useNumbering';
export { useOpenRouteFromLink } from './useOpenRouteFromLink';
export { usePresetCarouselData } from './usePresetCarouselData';
export { usePrevious } from './usePrevious';
export { usePreviousStation } from './usePreviousStation';
export { useRefreshLeftStations } from './useRefreshLeftStations';
export { useRefreshStation } from './useRefreshStation';
export { useResetMainState } from './useResetMainState';
export { useRouteSearchWalkthrough } from './useRouteSearchWalkthrough';
export { useSavedRoutes } from './useSavedRoutes';
export { useSelectLineWalkthrough } from './useSelectLineWalkthrough';
export { useSettingsWalkthrough } from './useSettingsWalkthrough';
export { useShouldHideTypeChange } from './useShouldHideTypeChange';
export { useSimulationMode } from './useSimulationMode';
export { useSlicedStations } from './useSlicedStations';
export { useStartBackgroundLocationUpdates } from './useStartBackgroundLocationUpdates';
export { useStationNumberIndexFunc } from './useStationNumberIndexFunc';
export { useStationsCache } from './useStationsCache';
export { useStoppingState } from './useStoppingState';
export { useTelemetrySender } from './useTelemetrySender';
export { useThreshold } from './useThreshold';
Expand Down
163 changes: 163 additions & 0 deletions src/hooks/useInitialNearbyStation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { render } from '@testing-library/react-native';
import * as Location from 'expo-location';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type React from 'react';
import { Alert } from 'react-native';
import { createStation } from '~/utils/test/factories';
import type { StationState } from '../store/atoms/station';
import {
type UseInitialNearbyStationResult,
useInitialNearbyStation,
} from './useInitialNearbyStation';

jest.mock('jotai', () => ({
useAtom: jest.fn(),
useAtomValue: jest.fn(),
useSetAtom: jest.fn(),
atom: jest.fn(),
}));

jest.mock('expo-location', () => ({
hasStartedLocationUpdatesAsync: jest.fn().mockResolvedValue(false),
stopLocationUpdatesAsync: jest.fn(),
Accuracy: { Highest: 6, Balanced: 3 },
}));

jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn().mockResolvedValue('true'),
setItem: jest.fn(),
}));

jest.mock('./useFetchNearbyStation', () => ({
useFetchNearbyStation: jest.fn().mockReturnValue({
stations: [],
fetchByCoords: jest
.fn()
.mockResolvedValue({ data: { stationsNearby: [] } }),
isLoading: false,
error: null,
}),
}));

jest.mock('./useFetchCurrentLocationOnce', () => ({
useFetchCurrentLocationOnce: jest.fn().mockReturnValue({
fetchCurrentLocation: jest.fn(),
}),
}));

jest.mock('../translation', () => ({
translate: jest.fn((key: string) => key),
isJapanese: true,
}));

type HookResult = UseInitialNearbyStationResult | null;

const HookBridge: React.FC<{ onReady: (value: HookResult) => void }> = ({
onReady,
}) => {
onReady(useInitialNearbyStation());
return null;
};

describe('useInitialNearbyStation', () => {
const mockSetStationState = jest.fn();
const mockSetNavigationState = jest.fn();
const mockUseAtom = useAtom as unknown as jest.Mock;
const mockUseAtomValue = useAtomValue as unknown as jest.Mock;
const mockUseSetAtom = useSetAtom as unknown as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Alert, 'alert').mockImplementation();

mockUseAtom.mockReturnValue([
{
station: null,
stations: [],
stationsCache: [],
pendingStation: null,
pendingStations: [],
selectedDirection: null,
selectedBound: null,
wantedDestination: null,
arrived: false,
approaching: false,
} satisfies StationState,
mockSetStationState,
]);

mockUseSetAtom.mockReturnValue(mockSetNavigationState);

// locationAtom
mockUseAtomValue.mockReturnValue(null);
});

it('station が null のときは nearbyStationLoading を返す', () => {
const hookRef: { current: HookResult } = { current: null };
render(
<HookBridge
onReady={(v) => {
hookRef.current = v;
}}
/>
);

expect(hookRef.current?.station).toBeNull();
expect(hookRef.current?.nearbyStationLoading).toBe(false);
});

it('stationFromAtom があればそれを返す', () => {
const existingStation = createStation(1);
mockUseAtom.mockReturnValue([
{
station: existingStation,
stations: [],
stationsCache: [],
pendingStation: null,
pendingStations: [],
selectedDirection: null,
selectedBound: null,
wantedDestination: null,
arrived: false,
approaching: false,
} satisfies StationState,
mockSetStationState,
]);

const hookRef: { current: HookResult } = { current: null };
render(
<HookBridge
onReady={(v) => {
hookRef.current = v;
}}
/>
);

expect(hookRef.current?.station).toBe(existingStation);
});

it('バックグラウンド位置更新を停止する', async () => {
(Location.hasStartedLocationUpdatesAsync as jest.Mock).mockResolvedValue(
true
);

render(<HookBridge onReady={() => {}} />);

await new Promise((r) => setTimeout(r, 0));
expect(Location.stopLocationUpdatesAsync).toHaveBeenCalled();
});

it('初回起動時にアラートを表示する', async () => {
(AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);

render(<HookBridge onReady={() => {}} />);

await new Promise((r) => setTimeout(r, 0));
expect(Alert.alert).toHaveBeenCalledWith(
'notice',
'firstAlertText',
expect.any(Array)
);
});
});
155 changes: 155 additions & 0 deletions src/hooks/useInitialNearbyStation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Location from 'expo-location';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useEffect, useMemo, useRef } from 'react';
import { Alert } from 'react-native';
import type { Station } from '~/@types/graphql';
import { ASYNC_STORAGE_KEYS, LOCATION_TASK_NAME } from '../constants';
import { locationAtom, setLocation } from '../store/atoms/location';
import navigationState from '../store/atoms/navigation';
import stationState from '../store/atoms/station';
import { translate } from '../translation';
import { useFetchCurrentLocationOnce } from './useFetchCurrentLocationOnce';
import { useFetchNearbyStation } from './useFetchNearbyStation';

const INITIAL_LOCATION_FALLBACK_DELAY_MS = 800;

export type UseInitialNearbyStationResult = {
station: Station | null;
nearbyStationLoading: boolean;
};

export const useInitialNearbyStation = (): UseInitialNearbyStationResult => {
const [stationAtomState, setStationState] = useAtom(stationState);
const setNavigationState = useSetAtom(navigationState);
const location = useAtomValue(locationAtom);
const latitude = location?.coords.latitude;
const longitude = location?.coords.longitude;

const { station: stationFromAtom } = stationAtomState;
const initialNearbyFetchInFlightRef = useRef(false);

const {
stations: nearbyStations,
fetchByCoords,
isLoading: nearbyStationLoading,
error: nearbyStationFetchError,
} = useFetchNearbyStation();

const { fetchCurrentLocation } = useFetchCurrentLocationOnce();

const station = useMemo(
() => stationFromAtom ?? nearbyStations[0] ?? null,
[stationFromAtom, nearbyStations]
);

// バックグラウンド位置更新を停止
useEffect(() => {
const stopLocationUpdates = async () => {
const hasStartedLocationUpdates =
await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME);
if (hasStartedLocationUpdates) {
await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME);
}
};
stopLocationUpdates();
}, []);

// 最寄り駅の取得
useEffect(() => {
const fetchInitialNearbyStationAsync = async (coords?: {
latitude: number;
longitude: number;
}) => {
if (station || initialNearbyFetchInFlightRef.current) return;
initialNearbyFetchInFlightRef.current = true;

try {
let requestCoords = coords;
if (!requestCoords) {
const currentLocation = await fetchCurrentLocation(true);
if (!currentLocation) return;
setLocation(currentLocation);
requestCoords = {
latitude: currentLocation.coords.latitude,
longitude: currentLocation.coords.longitude,
};
}

const data = await fetchByCoords({
latitude: requestCoords.latitude,
longitude: requestCoords.longitude,
limit: 1,
});

const stationFromAPI = data.data?.stationsNearby[0] ?? null;
setStationState((prev) => ({
...prev,
station: stationFromAPI,
}));
setNavigationState((prev) => ({
...prev,
stationForHeader: stationFromAPI,
}));
} catch (error) {
console.error(error);
} finally {
initialNearbyFetchInFlightRef.current = false;
}
};

if (latitude != null && longitude != null) {
fetchInitialNearbyStationAsync({ latitude, longitude });
return;
}

const fallbackTimerId = setTimeout(() => {
fetchInitialNearbyStationAsync();
}, INITIAL_LOCATION_FALLBACK_DELAY_MS);

return () => {
clearTimeout(fallbackTimerId);
};
}, [
fetchByCoords,
fetchCurrentLocation,
latitude,
longitude,
setNavigationState,
setStationState,
station,
]);

// 初回起動アラート
useEffect(() => {
const checkFirstLaunch = async () => {
const firstLaunchPassed = await AsyncStorage.getItem(
ASYNC_STORAGE_KEYS.FIRST_LAUNCH_PASSED
);
if (firstLaunchPassed === null) {
Alert.alert(translate('notice'), translate('firstAlertText'), [
{
text: 'OK',
onPress: (): void => {
AsyncStorage.setItem(
ASYNC_STORAGE_KEYS.FIRST_LAUNCH_PASSED,
'true'
);
},
},
]);
}
};
checkFirstLaunch();
}, []);

// 最寄り駅取得エラーのアラート
useEffect(() => {
if (nearbyStationFetchError) {
console.error(nearbyStationFetchError);
Alert.alert(translate('errorTitle'), translate('apiErrorText'));
}
}, [nearbyStationFetchError]);

return { station, nearbyStationLoading };
};
Loading