diff --git a/android/app/build.gradle b/android/app/build.gradle index 7dd6ed841..f7c106011 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -142,13 +142,13 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000256 - versionName "10.1.0" + versionCode 100000258 + versionName "10.1.1" } prod { dimension "environment" - versionCode 100000256 - versionName "10.1.0" + versionCode 100000258 + versionName "10.1.1" } } } diff --git a/app.config.ts b/app.config.ts index c55e07db9..9d7066714 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,7 +3,7 @@ import type { ConfigContext } from 'expo/config'; export default ({ config }: ConfigContext) => ({ name: 'TrainLCD', slug: 'trainlcd', - version: '10.1.0', + version: '10.1.1', plugins: [ 'expo-font', 'expo-localization', @@ -43,7 +43,7 @@ export default ({ config }: ConfigContext) => ({ }, }, ios: { - buildNumber: '2469', + buildNumber: '2471', bundleIdentifier: process.env.EAS_BUILD_PROFILE === 'production' ? 'me.tinykitten.trainlcd' @@ -60,7 +60,7 @@ export default ({ config }: ConfigContext) => ({ ? 'me.tinykitten.trainlcd' : 'me.tinykitten.trainlcd.dev', permissions: [], - versionCode: 100000256, + versionCode: 100000258, }, owner: 'trainlcd', }); @@ -84,6 +84,8 @@ export default ({ config }: ConfigContext) => ({ + + diff --git a/index.js b/index.js index c4e30f4de..bd0938e1b 100644 --- a/index.js +++ b/index.js @@ -6,24 +6,26 @@ import App from './src'; import { LOCATION_TASK_NAME, MAX_PERMIT_ACCURACY } from './src/constants'; import { setLocation } from './src/store/atoms/location'; -Sentry.init({ - dsn: SENTRY_DSN, - enableAutoSessionTracking: true, - tracesSampleRate: 1.0, - profilesSampleRate: 1.0, - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, - integrations: [ - Sentry.mobileReplayIntegration({ - maskAllText: true, - blockAllMedia: true, - privacyOptions: { - maskAllInputs: true, - blockClass: ['sensitive-screen', 'payment-view'], - }, - }), - ], -}); +if (process.env.NODE_ENV === 'production') { + Sentry.init({ + dsn: SENTRY_DSN, + enableAutoSessionTracking: true, + tracesSampleRate: 1.0, + profilesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + Sentry.mobileReplayIntegration({ + maskAllText: true, + blockAllMedia: true, + privacyOptions: { + maskAllInputs: true, + blockClass: ['sensitive-screen', 'payment-view'], + }, + }), + ], + }); +} if (!TaskManager.isTaskDefined(LOCATION_TASK_NAME)) { TaskManager.defineTask(LOCATION_TASK_NAME, ({ data, error }) => { diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index f7a077650..16ff41619 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2435,7 +2435,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2474,7 +2474,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2533,7 +2533,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; CXX = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2589,7 +2589,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.1.0; + MARKETING_VERSION = 10.1.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; @@ -2639,7 +2639,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; CXX = ""; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2691,7 +2691,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.1.0; + MARKETING_VERSION = 10.1.1; MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; @@ -2718,7 +2718,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2757,7 +2757,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2968,7 +2968,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3019,7 +3019,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3070,7 +3070,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3128,7 +3128,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3179,7 +3179,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3236,7 +3236,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3284,7 +3284,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3335,7 +3335,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3554,7 +3554,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3610,7 +3610,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3660,7 +3660,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3684,7 +3684,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.1.0; + MARKETING_VERSION = 10.1.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3718,7 +3718,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2469; + CURRENT_PROJECT_VERSION = 2471; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3738,7 +3738,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.1.0; + MARKETING_VERSION = 10.1.1; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PODS_ROOT = "${SRCROOT}/Pods"; diff --git a/ios/TrainLCD/Schemes/Prod/Info.plist b/ios/TrainLCD/Schemes/Prod/Info.plist index c74ef0970..69690b97b 100644 --- a/ios/TrainLCD/Schemes/Prod/Info.plist +++ b/ios/TrainLCD/Schemes/Prod/Info.plist @@ -43,6 +43,8 @@ $(CURRENT_PROJECT_VERSION) CURRENT_SCHEME_NAME ProdTrainLCD + ITSAppUsesNonExemptEncryption + LSApplicationCategoryType LSRequiresIPhoneOS diff --git a/package.json b/package.json index 2c6bd0943..21280ba68 100644 --- a/package.json +++ b/package.json @@ -147,5 +147,5 @@ } }, "name": "trainlcd", - "version": "10.1.0" + "version": "10.1.1" } diff --git a/src/components/HeaderE235.tsx b/src/components/HeaderE235.tsx index 782ec9dbd..c664efb45 100644 --- a/src/components/HeaderE235.tsx +++ b/src/components/HeaderE235.tsx @@ -20,7 +20,6 @@ const styles = StyleSheet.create({ }, boundContainer: { width: '100%', - height: '50%', justifyContent: 'flex-end', }, bound: { @@ -177,7 +176,9 @@ const HeaderE235: React.FC = (props) => { }, ]} adjustsFontSizeToFit - numberOfLines={1} + numberOfLines={2} + lineBreakStrategyIOS="push-out" + textBreakStrategy="balanced" > {boundText} diff --git a/src/components/PresetCard.tsx b/src/components/PresetCard.tsx index beb7553ef..162e742fe 100644 --- a/src/components/PresetCard.tsx +++ b/src/components/PresetCard.tsx @@ -86,6 +86,11 @@ const styles = StyleSheet.create({ fontWeight: 'bold', textAlignVertical: 'auto', }, + stationCodeParen: { + fontSize: RFValue(8), + fontWeight: 'bold', + textAlignVertical: 'auto', + }, lineDot: { width: 16, height: 16, @@ -94,6 +99,28 @@ const styles = StyleSheet.create({ }, }); +const renderTextWithSmallerParens = ( + text: string, + baseStyle: typeof styles.stationCode, + parenStyle: typeof styles.stationCodeParen, + color: string +) => { + const parts = text.split(/([(\uFF08][^)\uFF09]*[)\uFF09])/); + if (parts.length === 1) return text; + return parts.map((part, i) => { + const key = `${i}-${part}`; + return /^[(\uFF08]/.test(part) ? ( + + {part} + + ) : ( + + {part} + + ); + }); +}; + const BrokenIcon = () => ( {/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */} @@ -243,7 +270,12 @@ const PresetCardBase: React.FC = ({ title, from, to }) => { {leftName} - {leftCode} + {renderTextWithSmallerParens( + leftCode, + styles.stationCode, + styles.stationCodeParen, + metaFg + )} @@ -268,7 +300,12 @@ const PresetCardBase: React.FC = ({ title, from, to }) => { {rightName} - {rightCode} + {renderTextWithSmallerParens( + rightCode, + styles.stationCode, + styles.stationCodeParen, + metaFg + )} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ae55fa524..718938e6d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -28,6 +28,7 @@ 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'; @@ -35,6 +36,7 @@ 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'; @@ -45,6 +47,7 @@ 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'; @@ -52,12 +55,14 @@ 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'; diff --git a/src/hooks/useInitialNearbyStation.test.tsx b/src/hooks/useInitialNearbyStation.test.tsx new file mode 100644 index 000000000..39a81e61b --- /dev/null +++ b/src/hooks/useInitialNearbyStation.test.tsx @@ -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( + { + 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( + { + hookRef.current = v; + }} + /> + ); + + expect(hookRef.current?.station).toBe(existingStation); + }); + + it('バックグラウンド位置更新を停止する', async () => { + (Location.hasStartedLocationUpdatesAsync as jest.Mock).mockResolvedValue( + true + ); + + render( {}} />); + + await new Promise((r) => setTimeout(r, 0)); + expect(Location.stopLocationUpdatesAsync).toHaveBeenCalled(); + }); + + it('初回起動時にアラートを表示する', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + + render( {}} />); + + await new Promise((r) => setTimeout(r, 0)); + expect(Alert.alert).toHaveBeenCalledWith( + 'notice', + 'firstAlertText', + expect.any(Array) + ); + }); +}); diff --git a/src/hooks/useInitialNearbyStation.ts b/src/hooks/useInitialNearbyStation.ts new file mode 100644 index 000000000..982aac3b4 --- /dev/null +++ b/src/hooks/useInitialNearbyStation.ts @@ -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 }; +}; diff --git a/src/hooks/useLineSelection.test.tsx b/src/hooks/useLineSelection.test.tsx new file mode 100644 index 000000000..7df2b81e1 --- /dev/null +++ b/src/hooks/useLineSelection.test.tsx @@ -0,0 +1,287 @@ +import { useLazyQuery } from '@apollo/client/react'; +import { act, render } from '@testing-library/react-native'; +import { useAtomValue, useSetAtom } from 'jotai'; +import type React from 'react'; +import type { Line, TrainType } from '~/@types/graphql'; +import { createLine, createStation } from '~/utils/test/factories'; +import type { LineState } from '../store/atoms/line'; +import type { NavigationState } from '../store/atoms/navigation'; +import type { StationState } from '../store/atoms/station'; +import { + type UseLineSelectionResult, + useLineSelection, +} from './useLineSelection'; + +jest.mock('@apollo/client/react', () => ({ + useLazyQuery: jest.fn(), +})); +jest.mock('jotai', () => ({ + useSetAtom: jest.fn(), + useAtomValue: jest.fn(), + atom: jest.fn(), +})); + +type HookResult = UseLineSelectionResult | null; + +const HookBridge: React.FC<{ onReady: (value: HookResult) => void }> = ({ + onReady, +}) => { + onReady(useLineSelection()); + return null; +}; + +const createStationState = ( + overrides: Partial = {} +): StationState => ({ + arrived: false, + approaching: false, + station: null, + stations: [], + stationsCache: [], + pendingStation: null, + pendingStations: [], + selectedDirection: null, + selectedBound: null, + wantedDestination: null, + ...overrides, +}); + +const createNavigationState = ( + overrides: Partial = {} +): NavigationState => ({ + headerState: 'CURRENT', + trainType: null, + bottomState: 'LINE', + leftStations: [], + stationForHeader: null, + enabledLanguages: [], + fetchedTrainTypes: [], + autoModeEnabled: false, + isAppLatest: false, + firstStop: true, + presetsFetched: false, + presetRoutes: [], + pendingTrainType: null, + ...overrides, +}); + +const createLineState = (overrides: Partial = {}): LineState => ({ + selectedLine: null, + pendingLine: null, + ...overrides, +}); + +describe('useLineSelection', () => { + const mockUseLazyQuery = useLazyQuery as unknown as jest.Mock; + const mockUseSetAtom = useSetAtom as unknown as jest.Mock; + const mockUseAtomValue = useAtomValue as unknown as jest.Mock; + + const setupMolecules = () => { + const mockSetStationState = jest.fn(); + const mockSetLineState = jest.fn(); + const mockSetNavigationState = jest.fn(); + + // useSetAtom は stationState, lineStateAtom, navigationState の順で呼ばれる + // React 19 の double-invoke でも安定するよう mockImplementation を使用 + const setters = [ + mockSetStationState, + mockSetLineState, + mockSetNavigationState, + ]; + let setterIndex = 0; + mockUseSetAtom.mockImplementation(() => { + const setter = setters[setterIndex % setters.length]; + setterIndex++; + return setter; + }); + + // useAtomValue(locationAtom) + mockUseAtomValue.mockReturnValue(null); + + return { mockSetStationState, mockSetLineState, mockSetNavigationState }; + }; + + const setupQueries = ({ + lineLoading = false, + groupLoading = false, + trainTypesLoading = false, + lineError, + groupError, + trainTypesError, + }: { + lineLoading?: boolean; + groupLoading?: boolean; + trainTypesLoading?: boolean; + lineError?: Error; + groupError?: Error; + trainTypesError?: Error; + } = {}) => { + const mockFetchByLineId = jest.fn(); + const mockFetchByGroupId = jest.fn(); + const mockFetchTrainTypes = jest.fn(); + + // useLazyQuery は GET_LINE_STATIONS, GET_LINE_GROUP_STATIONS, GET_STATION_TRAIN_TYPES_LIGHT の順 + const queryResults = [ + [mockFetchByLineId, { loading: lineLoading, error: lineError }], + [mockFetchByGroupId, { loading: groupLoading, error: groupError }], + [ + mockFetchTrainTypes, + { loading: trainTypesLoading, error: trainTypesError }, + ], + ]; + let queryIndex = 0; + mockUseLazyQuery.mockImplementation(() => { + const result = queryResults[queryIndex % queryResults.length]; + queryIndex++; + return result; + }); + + return { mockFetchByLineId, mockFetchByGroupId, mockFetchTrainTypes }; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('handleLineSelected が路線の駅を取得して state を更新する', async () => { + const { mockSetStationState, mockSetLineState, mockSetNavigationState } = + setupMolecules(); + const { mockFetchByLineId } = setupQueries(); + + const stations = [ + createStation(10, { line: { id: 100 } }), + createStation(20, { line: { id: 100 } }), + ]; + mockFetchByLineId.mockResolvedValue({ + data: { lineStations: stations }, + }); + + const line = createLine(100, { + station: { id: 10, hasTrainTypes: false } as Line['station'], + }); + + const hookRef: { current: HookResult } = { current: null }; + render( + { + hookRef.current = v; + }} + /> + ); + + await act(async () => { + await hookRef.current?.handleLineSelected(line); + }); + + expect(mockFetchByLineId).toHaveBeenCalledWith({ + variables: { lineId: 100, stationId: 10 }, + }); + + // 最初の呼び出し: 初期リセット + const firstStationSetter = mockSetStationState.mock.calls[0][0]; + const firstResult = firstStationSetter(createStationState()); + expect(firstResult.pendingStation).toBeNull(); + expect(firstResult.selectedDirection).toBeNull(); + + // 2回目の呼び出し: 取得した駅で更新 + const secondStationSetter = mockSetStationState.mock.calls[1][0]; + const secondResult = secondStationSetter(createStationState()); + expect(secondResult.pendingStation?.id).toBe(10); + expect(secondResult.pendingStations).toEqual(stations); + + // lineState 更新 + const lineSetter = mockSetLineState.mock.calls[0][0]; + const lineResult = lineSetter(createLineState()); + expect(lineResult.pendingLine?.id).toBe(100); + + // navigationState リセット + const navSetter = mockSetNavigationState.mock.calls[0][0]; + const navResult = navSetter(createNavigationState()); + expect(navResult.fetchedTrainTypes).toEqual([]); + expect(navResult.pendingTrainType).toBeNull(); + }); + + it('handleTrainTypeSelect が groupId で駅を取得する', async () => { + const { mockSetStationState, mockSetNavigationState } = setupMolecules(); + const { mockFetchByGroupId } = setupQueries(); + + const stations = [createStation(30)]; + mockFetchByGroupId.mockResolvedValue({ + data: { lineGroupStations: stations }, + }); + + const trainType = { + id: 1, + groupId: 500, + name: 'Express', + } as TrainType; + + const hookRef: { current: HookResult } = { current: null }; + render( + { + hookRef.current = v; + }} + /> + ); + + await act(async () => { + await hookRef.current?.handleTrainTypeSelect(trainType); + }); + + expect(mockFetchByGroupId).toHaveBeenCalledWith({ + variables: { lineGroupId: 500 }, + }); + + const stationSetter = mockSetStationState.mock.calls[0][0]; + const result = stationSetter(createStationState()); + expect(result.pendingStations).toEqual(stations); + + const navSetter = mockSetNavigationState.mock.calls[0][0]; + const navResult = navSetter(createNavigationState()); + expect(navResult.pendingTrainType).toBe(trainType); + }); + + it('handleCloseSelectBoundModal が isSelectBoundModalOpen を false にする', () => { + setupMolecules(); + setupQueries(); + + const hookRef: { current: HookResult } = { current: null }; + render( + { + hookRef.current = v; + }} + /> + ); + + expect(hookRef.current?.isSelectBoundModalOpen).toBe(false); + + act(() => { + hookRef.current?.handleCloseSelectBoundModal(); + }); + + expect(hookRef.current?.isSelectBoundModalOpen).toBe(false); + }); + + it('loading/error フラグを集約する', () => { + setupMolecules(); + + const lineError = new Error('line error'); + setupQueries({ lineLoading: true, lineError }); + + const hookRef: { current: HookResult } = { current: null }; + render( + { + hookRef.current = v; + }} + /> + ); + + expect(hookRef.current?.fetchStationsByLineIdLoading).toBe(true); + expect(hookRef.current?.fetchStationsByLineIdError?.message).toBe( + 'line error' + ); + }); +}); diff --git a/src/hooks/useLineSelection.ts b/src/hooks/useLineSelection.ts new file mode 100644 index 000000000..f3a4d8db9 --- /dev/null +++ b/src/hooks/useLineSelection.ts @@ -0,0 +1,344 @@ +import type { ErrorLike } from '@apollo/client/core'; +import { useLazyQuery } from '@apollo/client/react'; +import findNearest from 'geolib/es/findNearest'; +import orderByDistance from 'geolib/es/orderByDistance'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { useCallback, useState } from 'react'; +import type { Line, Station, TrainType } from '~/@types/graphql'; +import { + GET_LINE_GROUP_STATIONS, + GET_LINE_STATIONS, + GET_STATION_TRAIN_TYPES_LIGHT, +} from '~/lib/graphql/queries'; +import type { SavedRoute } from '~/models/SavedRoute'; +import lineStateAtom from '../store/atoms/line'; +import { locationAtom } from '../store/atoms/location'; +import navigationState from '../store/atoms/navigation'; +import stationState from '../store/atoms/station'; + +type GetLineStationsData = { + lineStations: Station[]; +}; + +type GetLineStationsVariables = { + lineId: number; + stationId?: number; +}; + +type GetLineGroupStationsData = { + lineGroupStations: Station[]; +}; + +type GetLineGroupStationsVariables = { + lineGroupId: number; +}; + +type GetStationTrainTypesData = { + stationTrainTypes: TrainType[]; +}; + +type GetStationTrainTypesVariables = { + stationId: number; +}; + +export type UseLineSelectionResult = { + handleLineSelected: (line: Line) => Promise; + handleTrainTypeSelect: (trainType: TrainType) => Promise; + handlePresetPress: (route: SavedRoute) => Promise; + handleCloseSelectBoundModal: () => void; + isSelectBoundModalOpen: boolean; + fetchTrainTypesLoading: boolean; + fetchStationsByLineIdLoading: boolean; + fetchStationsByLineGroupIdLoading: boolean; + fetchTrainTypesError: ErrorLike | undefined; + fetchStationsByLineIdError: ErrorLike | undefined; + fetchStationsByLineGroupIdError: ErrorLike | undefined; +}; + +export const useLineSelection = (): UseLineSelectionResult => { + const [isSelectBoundModalOpen, setIsSelectBoundModalOpen] = useState(false); + const setStationState = useSetAtom(stationState); + const setLineState = useSetAtom(lineStateAtom); + const setNavigationState = useSetAtom(navigationState); + const location = useAtomValue(locationAtom); + const latitude = location?.coords.latitude; + const longitude = location?.coords.longitude; + + const [ + fetchStationsByLineId, + { + loading: fetchStationsByLineIdLoading, + error: fetchStationsByLineIdError, + }, + ] = useLazyQuery( + GET_LINE_STATIONS + ); + const [ + fetchStationsByLineGroupId, + { + loading: fetchStationsByLineGroupIdLoading, + error: fetchStationsByLineGroupIdError, + }, + ] = useLazyQuery( + GET_LINE_GROUP_STATIONS + ); + const [ + fetchTrainTypes, + { loading: fetchTrainTypesLoading, error: fetchTrainTypesError }, + ] = useLazyQuery( + GET_STATION_TRAIN_TYPES_LIGHT + ); + + const handleLineSelected = useCallback( + async (line: Line) => { + const lineId = line.id; + const lineStationId = line.station?.id; + if (!lineId || !lineStationId) return; + + setIsSelectBoundModalOpen(true); + + setStationState((prev) => ({ + ...prev, + pendingStation: null, + pendingStations: [], + selectedDirection: null, + wantedDestination: null, + selectedBound: null, + })); + setLineState((prev) => ({ + ...prev, + pendingLine: line ?? null, + })); + setNavigationState((prev) => ({ + ...prev, + fetchedTrainTypes: [], + trainType: null, + pendingTrainType: null, + })); + + const result = await fetchStationsByLineId({ + variables: { lineId, stationId: lineStationId }, + }); + const fetchedStations = result.data?.lineStations ?? []; + + const pendingStation = + fetchedStations.find((s) => s.id === lineStationId) ?? null; + + setStationState((prev) => ({ + ...prev, + pendingStation, + pendingStations: fetchedStations, + })); + + if (line.station?.hasTrainTypes) { + const result = await fetchTrainTypes({ + variables: { + stationId: lineStationId, + }, + }); + const fetchedTrainTypes = result.data?.stationTrainTypes ?? []; + const designatedTrainTypeId = + fetchedStations.find((s) => s.id === lineStationId)?.trainType?.id ?? + null; + const designatedTrainType = + fetchedTrainTypes.find((tt) => tt.id === designatedTrainTypeId) ?? + null; + setNavigationState((prev) => ({ + ...prev, + fetchedTrainTypes, + pendingTrainType: designatedTrainType as TrainType | null, + })); + } + }, + [ + fetchTrainTypes, + setNavigationState, + setStationState, + setLineState, + fetchStationsByLineId, + ] + ); + + const handleTrainTypeSelect = useCallback( + async (trainType: TrainType) => { + if (trainType.groupId == null) return; + const res = await fetchStationsByLineGroupId({ + variables: { + lineGroupId: trainType.groupId, + }, + }); + setStationState((prev) => ({ + ...prev, + pendingStations: res.data?.lineGroupStations ?? [], + })); + setNavigationState((prev) => ({ + ...prev, + pendingTrainType: trainType, + })); + }, + [fetchStationsByLineGroupId, setStationState, setNavigationState] + ); + + const openModalByLineId = useCallback( + async (lineId: number) => { + const result = await fetchStationsByLineId({ + variables: { lineId }, + }); + const stations = result.data?.lineStations ?? []; + if (!stations.length) return; + + const nearestCoordinates = + latitude && longitude + ? (findNearest( + { latitude, longitude }, + stations.map((sta: Station) => ({ + latitude: sta.latitude as number, + longitude: sta.longitude as number, + })) + ) as { latitude: number; longitude: number }) + : stations.map((s) => ({ + latitude: s.latitude, + longitude: s.longitude, + }))[0]; + + const station = stations.find( + (sta: Station) => + sta.latitude === nearestCoordinates.latitude && + sta.longitude === nearestCoordinates.longitude + ); + + if (!station) return; + + setStationState((prev) => ({ + ...prev, + selectedDirection: null, + pendingStation: station, + pendingStations: stations, + wantedDestination: null, + })); + setLineState((prev) => ({ + ...prev, + pendingLine: (station.line as Line) ?? null, + })); + setNavigationState((prev) => ({ + ...prev, + fetchedTrainTypes: [], + pendingTrainType: null, + })); + }, + [ + fetchStationsByLineId, + latitude, + longitude, + setStationState, + setLineState, + setNavigationState, + ] + ); + + const openModalByTrainTypeId = useCallback( + async (lineGroupId: number) => { + const result = await fetchStationsByLineGroupId({ + variables: { lineGroupId }, + }); + const stations = result.data?.lineGroupStations ?? []; + if (!stations.length) return; + + const sortedStationCoords = + latitude && longitude + ? (orderByDistance( + { lat: latitude, lon: longitude }, + stations.map((sta) => ({ + latitude: sta.latitude as number, + longitude: sta.longitude as number, + })) + ) as { latitude: number; longitude: number }[]) + : stations.map((sta) => ({ + latitude: sta.latitude, + longitude: sta.longitude, + })); + + const sortedStations = stations.slice().sort((a, b) => { + const aIndex = sortedStationCoords.findIndex( + (coord) => + coord.latitude === a.latitude && coord.longitude === a.longitude + ); + const bIndex = sortedStationCoords.findIndex( + (coord) => + coord.latitude === b.latitude && coord.longitude === b.longitude + ); + return aIndex - bIndex; + }); + + const station = sortedStations.find( + (sta: Station) => sta.trainType?.groupId === lineGroupId + ); + + if (!station) return; + + setStationState((prev) => ({ + ...prev, + selectedDirection: null, + pendingStation: station, + pendingStations: stations, + wantedDestination: null, + })); + setLineState((prev) => ({ + ...prev, + pendingLine: station?.line ?? null, + })); + + const fetchedTrainTypesData = await fetchTrainTypes({ + variables: { + stationId: station.id as number, + }, + }); + const trainTypes = fetchedTrainTypesData.data?.stationTrainTypes ?? []; + + setNavigationState((prev) => ({ + ...prev, + pendingTrainType: station.trainType ?? null, + fetchedTrainTypes: trainTypes, + })); + }, + [ + fetchStationsByLineGroupId, + fetchTrainTypes, + setNavigationState, + setStationState, + setLineState, + latitude, + longitude, + ] + ); + + const handlePresetPress = useCallback( + async (route: SavedRoute) => { + setIsSelectBoundModalOpen(true); + if (route.hasTrainType) { + await openModalByTrainTypeId(route.trainTypeId); + } else { + await openModalByLineId(route.lineId); + } + }, + [openModalByLineId, openModalByTrainTypeId] + ); + + const handleCloseSelectBoundModal = useCallback(() => { + setIsSelectBoundModalOpen(false); + }, []); + + return { + handleLineSelected, + handleTrainTypeSelect, + handlePresetPress, + handleCloseSelectBoundModal, + isSelectBoundModalOpen, + fetchTrainTypesLoading, + fetchStationsByLineIdLoading, + fetchStationsByLineGroupIdLoading, + fetchTrainTypesError, + fetchStationsByLineIdError, + fetchStationsByLineGroupIdError, + }; +}; diff --git a/src/hooks/usePresetCarouselData.test.ts b/src/hooks/usePresetCarouselData.test.ts new file mode 100644 index 000000000..2d9676a70 --- /dev/null +++ b/src/hooks/usePresetCarouselData.test.ts @@ -0,0 +1,173 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { gqlClient } from '~/lib/gql'; +import type { SavedRoute } from '~/models/SavedRoute'; +import { createStation } from '~/utils/test/factories'; +import { usePresetCarouselData } from './usePresetCarouselData'; +import { useSavedRoutes } from './useSavedRoutes'; + +jest.mock('expo-sqlite', () => ({ + openDatabaseSync: jest.fn(() => ({ + execAsync: jest.fn(), + getAllAsync: jest.fn().mockResolvedValue([]), + runAsync: jest.fn(), + })), +})); + +jest.mock('~/lib/gql', () => ({ + gqlClient: { query: jest.fn() }, +})); + +jest.mock('~/lib/graphql/queries', () => ({ + GET_LINE_LIST_STATIONS_PRESET: 'GET_LINE_LIST_STATIONS_PRESET', + GET_LINE_GROUP_LIST_STATIONS_PRESET: 'GET_LINE_GROUP_LIST_STATIONS_PRESET', +})); + +jest.mock('./useSavedRoutes'); + +const createLineRoute = (id: string, lineId: number): SavedRoute => ({ + id, + name: `Route ${lineId}`, + lineId, + trainTypeId: null, + hasTrainType: false, + createdAt: new Date('2024-01-01'), +}); + +const createTrainTypeRoute = ( + id: string, + lineId: number, + trainTypeId: number +): SavedRoute => ({ + id, + name: `Route ${trainTypeId}`, + lineId, + trainTypeId, + hasTrainType: true, + createdAt: new Date('2024-01-01'), +}); + +describe('usePresetCarouselData', () => { + const mockUpdateRoutes = jest.fn(); + const mockQuery = gqlClient.query as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + (useSavedRoutes as jest.Mock).mockReturnValue({ + routes: [], + updateRoutes: mockUpdateRoutes, + isInitialized: false, + }); + }); + + it('DB が初期化されるまで updateRoutes を呼ばない', () => { + renderHook(() => usePresetCarouselData()); + + expect(mockUpdateRoutes).not.toHaveBeenCalled(); + }); + + it('DB 初期化後に updateRoutes を呼ぶ', () => { + (useSavedRoutes as jest.Mock).mockReturnValue({ + routes: [], + updateRoutes: mockUpdateRoutes, + isInitialized: true, + }); + + renderHook(() => usePresetCarouselData()); + + expect(mockUpdateRoutes).toHaveBeenCalled(); + }); + + it('lineRoute のみの場合、lineListStations で駅を取得する', async () => { + const route = createLineRoute('uuid-1', 100); + const stations = [ + createStation(10, { line: { id: 100 } }), + createStation(11, { line: { id: 100 } }), + ]; + + mockQuery.mockResolvedValue({ + data: { lineListStations: stations }, + }); + + (useSavedRoutes as jest.Mock).mockReturnValue({ + routes: [route], + updateRoutes: mockUpdateRoutes, + isInitialized: true, + }); + + const { result } = renderHook(() => usePresetCarouselData()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(mockQuery).toHaveBeenCalledWith({ + query: 'GET_LINE_LIST_STATIONS_PRESET', + variables: { lineIds: [100] }, + }); + expect(result.current.carouselData).toHaveLength(1); + expect(result.current.carouselData[0].stations).toEqual(stations); + expect(result.current.carouselData[0].__k).toBe('uuid-1-0'); + }); + + it('trainTypeRoute の場合、lineGroupListStations で駅を取得する', async () => { + const route = createTrainTypeRoute('uuid-2', 200, 300); + const stations = [ + createStation(20, { trainType: { groupId: 300 } } as Parameters< + typeof createStation + >[1]), + ]; + + mockQuery.mockResolvedValue({ + data: { lineGroupListStations: stations }, + }); + + (useSavedRoutes as jest.Mock).mockReturnValue({ + routes: [route], + updateRoutes: mockUpdateRoutes, + isInitialized: true, + }); + + const { result } = renderHook(() => usePresetCarouselData()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(mockQuery).toHaveBeenCalledWith({ + query: 'GET_LINE_GROUP_LIST_STATIONS_PRESET', + variables: { lineGroupIds: [300] }, + }); + expect(result.current.carouselData).toHaveLength(1); + expect(result.current.carouselData[0].stations).toEqual(stations); + }); + + it('同一 routes key の場合は再取得しない', async () => { + const route = createLineRoute('uuid-1', 100); + const stations = [createStation(10, { line: { id: 100 } })]; + + mockQuery.mockResolvedValue({ + data: { lineListStations: stations }, + }); + + (useSavedRoutes as jest.Mock).mockReturnValue({ + routes: [route], + updateRoutes: mockUpdateRoutes, + isInitialized: true, + }); + + const { rerender } = renderHook(() => usePresetCarouselData()); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + rerender({}); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // 同じ routes なので query は1回のみ + expect(mockQuery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/usePresetCarouselData.ts b/src/hooks/usePresetCarouselData.ts new file mode 100644 index 000000000..808cab57a --- /dev/null +++ b/src/hooks/usePresetCarouselData.ts @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react'; +import type { Station } from '~/@types/graphql'; +import { gqlClient } from '~/lib/gql'; +import { + GET_LINE_GROUP_LIST_STATIONS_PRESET, + GET_LINE_LIST_STATIONS_PRESET, +} from '~/lib/graphql/queries'; +import type { SavedRoute } from '~/models/SavedRoute'; +import type { LoopItem } from '../store/atoms/navigation'; +import { useSavedRoutes } from './useSavedRoutes'; + +export type UsePresetCarouselDataResult = { + carouselData: LoopItem[]; + routes: SavedRoute[]; + isRoutesDBInitialized: boolean; +}; + +export const usePresetCarouselData = (): UsePresetCarouselDataResult => { + const [carouselData, setCarouselData] = useState([]); + const prevRoutesKeyRef = useRef(''); + + const { + routes, + updateRoutes, + isInitialized: isRoutesDBInitialized, + } = useSavedRoutes(); + + useEffect(() => { + if (!isRoutesDBInitialized) return; + updateRoutes(); + }, [isRoutesDBInitialized, updateRoutes]); + + useEffect(() => { + const routesKey = routes + .map((r) => `${r.id}:${r.lineId}:${r.trainTypeId}:${r.hasTrainType}`) + .join(','); + if (routesKey === prevRoutesKeyRef.current) return; + + const fetchAsync = async () => { + try { + const lineRoutes = routes.filter((r) => !r.hasTrainType); + const trainTypeRoutes = routes.filter((r) => r.hasTrainType); + + // !hasTrainType のルートを lineListStations で一括取得 + const lineStationsMap = new Map(); + const validLineRoutes = lineRoutes.filter((r) => r.lineId !== null); + if (validLineRoutes.length > 0) { + const lineIds = validLineRoutes.map((r) => r.lineId); + const result = await gqlClient.query<{ + lineListStations: Station[]; + }>({ + query: GET_LINE_LIST_STATIONS_PRESET, + variables: { lineIds }, + }); + for (const s of result.data?.lineListStations ?? []) { + const lid = s.line?.id; + if (lid == null) continue; + const arr = lineStationsMap.get(lid); + if (arr) { + arr.push(s); + } else { + lineStationsMap.set(lid, [s]); + } + } + } + + // hasTrainType のルートを lineGroupListStations で一括取得 + const trainTypeStationsMap = new Map(); + if (trainTypeRoutes.length > 0) { + const lineGroupIds = trainTypeRoutes.map((r) => r.trainTypeId); + const result = await gqlClient.query<{ + lineGroupListStations: Station[]; + }>({ + query: GET_LINE_GROUP_LIST_STATIONS_PRESET, + variables: { lineGroupIds }, + }); + for (const s of result.data?.lineGroupListStations ?? []) { + const gid = s.trainType?.groupId; + if (gid == null) continue; + const arr = trainTypeStationsMap.get(gid); + if (arr) { + arr.push(s); + } else { + trainTypeStationsMap.set(gid, [s]); + } + } + } + + setCarouselData( + routes.map((r, i) => ({ + ...r, + __k: `${r.id}-${i}`, + stations: r.hasTrainType + ? (trainTypeStationsMap.get(r.trainTypeId) ?? []) + : (lineStationsMap.get(r.lineId) ?? []), + })) + ); + prevRoutesKeyRef.current = routesKey; + } catch (err) { + console.error(err); + } + }; + fetchAsync(); + }, [routes]); + + return { carouselData, routes, isRoutesDBInitialized }; +}; diff --git a/src/hooks/useSelectLineWalkthrough.test.tsx b/src/hooks/useSelectLineWalkthrough.test.tsx new file mode 100644 index 000000000..c11cda8b0 --- /dev/null +++ b/src/hooks/useSelectLineWalkthrough.test.tsx @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { useSelectLineWalkthrough } from './useSelectLineWalkthrough'; +import { useWalkthroughCompleted } from './useWalkthroughCompleted'; + +jest.mock('./useWalkthroughCompleted'); + +const mockSetSpotlightArea = jest.fn(); +const mockNextStep = jest.fn(); +const mockGoToStep = jest.fn(); +const mockSkipWalkthrough = jest.fn(); + +const createMockWalkthrough = ( + overrides: Partial> = {} +): ReturnType => ({ + isWalkthroughCompleted: false, + isWalkthroughActive: true, + currentStepIndex: 0, + currentStepId: 'welcome', + currentStep: { + id: 'welcome', + titleKey: 'walkthroughTitle1', + descriptionKey: 'walkthroughDescription1', + tooltipPosition: 'bottom', + }, + totalSteps: 5, + nextStep: mockNextStep, + goToStep: mockGoToStep, + skipWalkthrough: mockSkipWalkthrough, + setSpotlightArea: mockSetSpotlightArea, + ...overrides, +}); + +describe('useSelectLineWalkthrough', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useWalkthroughCompleted as jest.Mock).mockReturnValue( + createMockWalkthrough() + ); + }); + + it('useWalkthroughCompleted の値をそのまま返す', () => { + const { result } = renderHook(() => useSelectLineWalkthrough()); + + expect(result.current.isWalkthroughActive).toBe(true); + expect(result.current.totalSteps).toBe(5); + expect(result.current.currentStepIndex).toBe(0); + }); + + it('changeLocation ステップで nowHeaderLayout が設定されるとスポットライトが設定される', () => { + (useWalkthroughCompleted as jest.Mock).mockReturnValue( + createMockWalkthrough({ currentStepId: 'changeLocation' }) + ); + + const { result } = renderHook(() => useSelectLineWalkthrough()); + + act(() => { + result.current.setNowHeaderLayout({ + x: 10, + y: 20, + width: 300, + height: 50, + }); + }); + + expect(mockSetSpotlightArea).toHaveBeenCalledWith({ + x: 10, + y: 20, + width: 300, + height: 50, + borderRadius: 16, + }); + }); + + it('customize ステップで settingsButtonLayout が設定されるとスポットライトが設定される', () => { + (useWalkthroughCompleted as jest.Mock).mockReturnValue( + createMockWalkthrough({ currentStepId: 'customize' }) + ); + + const { result } = renderHook(() => useSelectLineWalkthrough()); + + act(() => { + result.current.setSettingsButtonLayout({ + x: 50, + y: 700, + width: 48, + height: 48, + }); + }); + + expect(mockSetSpotlightArea).toHaveBeenCalledWith({ + x: 50, + y: 700, + width: 48, + height: 48, + borderRadius: 24, + }); + }); + + it('nextStep / goToStep / skipWalkthrough を透過的に公開する', () => { + const { result } = renderHook(() => useSelectLineWalkthrough()); + + result.current.nextStep(); + expect(mockNextStep).toHaveBeenCalled(); + + result.current.goToStep(2); + expect(mockGoToStep).toHaveBeenCalledWith(2); + + result.current.skipWalkthrough(); + expect(mockSkipWalkthrough).toHaveBeenCalled(); + }); + + it('ref が公開されている', () => { + const { result } = renderHook(() => useSelectLineWalkthrough()); + + expect(result.current.lineListRef).toBeDefined(); + expect(result.current.presetsRef).toBeDefined(); + }); +}); diff --git a/src/hooks/useSelectLineWalkthrough.ts b/src/hooks/useSelectLineWalkthrough.ts new file mode 100644 index 000000000..f2e403fee --- /dev/null +++ b/src/hooks/useSelectLineWalkthrough.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { View } from 'react-native'; +import type { ButtonLayout } from '~/components/FooterTabBar'; +import type { HeaderLayout } from '~/components/NowHeader'; +import { useWalkthroughCompleted } from './useWalkthroughCompleted'; + +type Layout = { + x: number; + y: number; + width: number; + height: number; +}; + +export const useSelectLineWalkthrough = () => { + const { + isWalkthroughActive, + currentStepIndex, + currentStepId, + currentStep, + totalSteps, + nextStep, + goToStep, + skipWalkthrough, + setSpotlightArea, + } = useWalkthroughCompleted(); + + const [settingsButtonLayout, setSettingsButtonLayout] = + useState(null); + const [nowHeaderLayout, setNowHeaderLayout] = useState( + null + ); + const [lineListLayout, setLineListLayout] = useState(null); + const [presetsLayout, setPresetsLayout] = useState(null); + const lineListRef = useRef(null); + const presetsRef = useRef(null); + + // NowHeader をハイライト + useEffect(() => { + if (currentStepId === 'changeLocation' && nowHeaderLayout) { + setSpotlightArea({ + x: nowHeaderLayout.x, + y: nowHeaderLayout.y, + width: nowHeaderLayout.width, + height: nowHeaderLayout.height, + borderRadius: 16, + }); + } + }, [currentStepId, nowHeaderLayout, setSpotlightArea]); + + // 路線一覧をハイライト + useEffect(() => { + if (currentStepId === 'selectLine' && lineListLayout) { + setSpotlightArea({ + x: lineListLayout.x, + y: lineListLayout.y, + width: lineListLayout.width, + height: lineListLayout.height, + borderRadius: 12, + }); + } + }, [currentStepId, lineListLayout, setSpotlightArea]); + + // プリセットエリアをハイライト(marginTop: -16 を補正) + useEffect(() => { + if (currentStepId === 'savedRoutes' && presetsLayout) { + setSpotlightArea({ + x: presetsLayout.x, + y: presetsLayout.y - 16, + width: presetsLayout.width, + height: presetsLayout.height, + borderRadius: 12, + }); + } + }, [currentStepId, presetsLayout, setSpotlightArea]); + + // 設定ボタンをハイライト + useEffect(() => { + if (currentStepId === 'customize' && settingsButtonLayout) { + setSpotlightArea({ + x: settingsButtonLayout.x, + y: settingsButtonLayout.y, + width: settingsButtonLayout.width, + height: settingsButtonLayout.height, + borderRadius: 24, + }); + } + }, [currentStepId, settingsButtonLayout, setSpotlightArea]); + + const handlePresetsLayout = useCallback(() => { + if (presetsRef.current) { + presetsRef.current.measureInWindow( + (x: number, y: number, width: number, height: number) => { + setPresetsLayout({ x, y, width, height }); + } + ); + } + }, []); + + const handleLineListLayout = useCallback(() => { + if (lineListRef.current) { + lineListRef.current.measureInWindow( + (x: number, y: number, width: number, height: number) => { + setLineListLayout({ x, y, width, height }); + } + ); + } + }, []); + + return { + isWalkthroughActive, + currentStepIndex, + currentStep, + totalSteps, + nextStep, + goToStep, + skipWalkthrough, + setSettingsButtonLayout, + setNowHeaderLayout, + lineListRef, + presetsRef, + handlePresetsLayout, + handleLineListLayout, + }; +}; diff --git a/src/hooks/useStationsCache.test.ts b/src/hooks/useStationsCache.test.ts new file mode 100644 index 000000000..2d4a5867a --- /dev/null +++ b/src/hooks/useStationsCache.test.ts @@ -0,0 +1,99 @@ +import { renderHook } from '@testing-library/react-native'; +import { useSetAtom } from 'jotai'; +import type { Station } from '~/@types/graphql'; +import { gqlClient } from '~/lib/gql'; +import { createStation } from '~/utils/test/factories'; +import { useStationsCache } from './useStationsCache'; + +jest.mock('jotai', () => ({ + useSetAtom: jest.fn(), + atom: jest.fn(), +})); + +jest.mock('~/lib/gql', () => ({ + gqlClient: { query: jest.fn() }, +})); + +jest.mock('~/lib/graphql/queries', () => ({ + GET_LINE_LIST_STATIONS_LIGHT: 'GET_LINE_LIST_STATIONS_LIGHT', +})); + +describe('useStationsCache', () => { + const mockSetStationState = jest.fn(); + const mockQuery = gqlClient.query as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + (useSetAtom as unknown as jest.Mock).mockReturnValue(mockSetStationState); + }); + + it('station が null のときは何もしない', () => { + renderHook(() => useStationsCache(null)); + + expect(mockQuery).not.toHaveBeenCalled(); + expect(mockSetStationState).not.toHaveBeenCalled(); + }); + + it('station の lines が空のときは query を呼ばない', async () => { + const station = createStation(1, { lines: [] }); + + renderHook(() => useStationsCache(station)); + + // useEffect は非同期だが、lineIds.length === 0 で即 return するため query は呼ばれない + await new Promise((r) => setTimeout(r, 0)); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('station の全路線の駅を取得して stationsCache を更新する', async () => { + const lineAStations = [ + createStation(10, { line: { id: 100 } }), + createStation(11, { line: { id: 100 } }), + ]; + const lineBStations = [createStation(20, { line: { id: 200 } })]; + + mockQuery.mockResolvedValue({ + data: { + lineListStations: [...lineAStations, ...lineBStations], + }, + }); + + const station = createStation(1, { + lines: [ + { __typename: 'LineNested', id: 100 }, + { __typename: 'LineNested', id: 200 }, + ] as Station['lines'], + } as Parameters[1]); + + renderHook(() => useStationsCache(station)); + + await new Promise((r) => setTimeout(r, 0)); + + expect(mockQuery).toHaveBeenCalledWith({ + query: 'GET_LINE_LIST_STATIONS_LIGHT', + variables: { lineIds: [100, 200] }, + }); + + expect(mockSetStationState).toHaveBeenCalledWith(expect.any(Function)); + const updater = mockSetStationState.mock.calls[0][0]; + const result = updater({ stationsCache: [] }); + expect(result.stationsCache).toHaveLength(2); + expect(result.stationsCache[0]).toEqual(lineAStations); + expect(result.stationsCache[1]).toEqual(lineBStations); + }); + + it('query がエラーの場合は stationsCache を更新しない', async () => { + mockQuery.mockRejectedValue(new Error('network error')); + + const station = createStation(1, { + lines: [{ __typename: 'LineNested', id: 100 }] as Station['lines'], + } as Parameters[1]); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + renderHook(() => useStationsCache(station)); + + await new Promise((r) => setTimeout(r, 0)); + + expect(mockSetStationState).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useStationsCache.ts b/src/hooks/useStationsCache.ts new file mode 100644 index 000000000..e77c70274 --- /dev/null +++ b/src/hooks/useStationsCache.ts @@ -0,0 +1,68 @@ +import { useSetAtom } from 'jotai'; +import { useCallback, useEffect } from 'react'; +import type { LineNested, Station } from '~/@types/graphql'; +import { gqlClient } from '~/lib/gql'; +import { GET_LINE_LIST_STATIONS_LIGHT } from '~/lib/graphql/queries'; +import stationState from '../store/atoms/station'; + +const updateStationsCache = async ( + station: Station, + setStationState: ReturnType> +) => { + const fetchedLines = (station.lines ?? []).filter( + (line): line is LineNested => line?.id != null + ); + + const lineIds = fetchedLines.map((line) => line.id as number); + if (lineIds.length === 0) return; + + let allStations: Station[]; + try { + const result = await gqlClient.query<{ + lineListStations: Station[]; + }>({ + query: GET_LINE_LIST_STATIONS_LIGHT, + variables: { lineIds }, + }); + allStations = result.data?.lineListStations ?? []; + } catch (err) { + console.error(err); + return; + } + const stationsByLineId = new Map(); + for (const s of allStations) { + const lid = s.line?.id; + if (lid == null) continue; + const arr = stationsByLineId.get(lid); + if (arr) { + arr.push(s); + } else { + stationsByLineId.set(lid, [s]); + } + } + + const stationsCache: Station[][] = fetchedLines.map( + (line) => stationsByLineId.get(line.id as number) ?? [] + ); + + setStationState((prev) => ({ + ...prev, + stationsCache, + })); +}; + +export const useStationsCache = (station: Station | null): void => { + const setStationState = useSetAtom(stationState); + + const updateCache = useCallback( + async (station: Station) => { + await updateStationsCache(station, setStationState); + }, + [setStationState] + ); + + useEffect(() => { + if (!station) return; + updateCache(station); + }, [station, updateCache]); +}; diff --git a/src/screens/SelectLineScreen.render.test.tsx b/src/screens/SelectLineScreen.render.test.tsx new file mode 100644 index 000000000..f811d79d9 --- /dev/null +++ b/src/screens/SelectLineScreen.render.test.tsx @@ -0,0 +1,523 @@ +import { render } from '@testing-library/react-native'; +import { useAtomValue } from 'jotai'; +import type React from 'react'; +import type { Line, Station } from '~/@types/graphql'; +import { TransportType } from '~/@types/graphql'; +import { useDeviceOrientation } from '~/hooks/useDeviceOrientation'; +import { useInitialNearbyStation } from '~/hooks/useInitialNearbyStation'; +import { useLineSelection } from '~/hooks/useLineSelection'; +import { usePresetCarouselData } from '~/hooks/usePresetCarouselData'; +import { useSelectLineWalkthrough } from '~/hooks/useSelectLineWalkthrough'; +import { useStationsCache } from '~/hooks/useStationsCache'; +import { createLine, createStation } from '~/utils/test/factories'; +import SelectLineScreen from './SelectLineScreen'; + +// --- モジュールモック --- + +jest.mock('jotai', () => ({ + useAtomValue: jest.fn(), + atom: jest.fn(), +})); + +// usePresetCarouselData → useSavedRoutes → expo-sqlite のチェーンを断つ +jest.mock('expo-sqlite', () => ({ + openDatabaseSync: jest.fn(() => ({ + execAsync: jest.fn(), + getAllAsync: jest.fn().mockResolvedValue([]), + runAsync: jest.fn(), + })), +})); + +// usePresetCarouselData → gqlClient → react-native-device-info のチェーンを断つ +jest.mock('~/lib/gql', () => ({ + gqlClient: { query: jest.fn() }, +})); + +jest.mock('expo-screen-orientation', () => ({ + unlockAsync: jest.fn().mockResolvedValue(undefined), + Orientation: { + PORTRAIT_UP: 1, + PORTRAIT_DOWN: 2, + LANDSCAPE_LEFT: 3, + LANDSCAPE_RIGHT: 4, + }, +})); + +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: ({ children, ...props }: { children: React.ReactNode }) => { + const { View } = require('react-native'); + return {children}; + }, + useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0, left: 0, right: 0 })), +})); + +jest.mock('react-native-skeleton-placeholder', () => { + const { View } = require('react-native'); + const SkeletonPlaceholder = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + SkeletonPlaceholder.Item = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + return SkeletonPlaceholder; +}); + +// カスタムフック +jest.mock('~/hooks/useInitialNearbyStation'); +jest.mock('~/hooks/useStationsCache'); +jest.mock('~/hooks/usePresetCarouselData'); +jest.mock('~/hooks/useLineSelection'); +jest.mock('~/hooks/useSelectLineWalkthrough'); +jest.mock('~/hooks/useDeviceOrientation'); + +// 子コンポーネント +jest.mock('~/components/CommonCard', () => ({ + CommonCard: ({ + testID, + line, + }: { + testID?: string; + line: { nameShort?: string }; + }) => { + const { View, Text } = require('react-native'); + return ( + + {line?.nameShort ?? ''} + + ); + }, +})); + +jest.mock('~/components/NowHeader', () => ({ + NowHeader: ({ station }: { station: Station | null }) => { + const { View, Text } = require('react-native'); + return ( + + {station?.name ?? ''} + + ); + }, +})); + +jest.mock('~/components/SelectBoundModal', () => ({ + SelectBoundModal: ({ + visible, + loading, + }: { + visible: boolean; + loading: boolean; + }) => { + const { View } = require('react-native'); + return visible ? ( + + {loading && } + + ) : null; + }, +})); + +jest.mock('~/components/WalkthroughOverlay', () => { + const { View } = require('react-native'); + return ({ + visible, + currentStepIndex, + }: { + visible: boolean; + currentStepIndex: number; + }) => + visible ? ( + + ) : null; +}); + +jest.mock('../components/FooterTabBar', () => { + const { View } = require('react-native'); + const FooterTabBar = ({ active }: { active: string }) => ( + + ); + FooterTabBar.FOOTER_BASE_HEIGHT = 72; + return { + __esModule: true, + default: FooterTabBar, + FOOTER_BASE_HEIGHT: 72, + }; +}); + +jest.mock('../components/Heading', () => ({ + Heading: ({ children }: { children: React.ReactNode }) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('~/components/EmptyLineSeparator', () => ({ + EmptyLineSeparator: () => { + const { View } = require('react-native'); + return ; + }, +})); + +jest.mock('./SelectLineScreenPresets', () => ({ + SelectLineScreenPresets: ({ + isPresetsLoading, + carouselData, + }: { + isPresetsLoading: boolean; + carouselData: unknown[]; + }) => { + const { View, Text } = require('react-native'); + return ( + + {carouselData.length} + {isPresetsLoading && } + + ); + }, +})); + +jest.mock('../translation', () => ({ + translate: jest.fn((key: string) => key), + isJapanese: true, +})); + +jest.mock('../utils/generateTestID', () => ({ + generateLineTestId: jest.fn( + (line: { id: number | null }) => `line_${line.id}` + ), +})); + +jest.mock('~/utils/line', () => ({ + isBusLine: jest.fn( + (line: { transportType?: string } | null) => + line?.transportType === 'BUS' || line?.transportType === 'Bus' + ), +})); + +// --- ヘルパー --- + +const mockHandleLineSelected = jest.fn(); +const mockHandleTrainTypeSelect = jest.fn(); +const mockHandlePresetPress = jest.fn(); +const mockHandleCloseSelectBoundModal = jest.fn(); +const mockNextStep = jest.fn(); +const mockGoToStep = jest.fn(); +const mockSkipWalkthrough = jest.fn(); + +const defaultLineSelection = () => ({ + handleLineSelected: mockHandleLineSelected, + handleTrainTypeSelect: mockHandleTrainTypeSelect, + handlePresetPress: mockHandlePresetPress, + handleCloseSelectBoundModal: mockHandleCloseSelectBoundModal, + isSelectBoundModalOpen: false, + fetchTrainTypesLoading: false, + fetchStationsByLineIdLoading: false, + fetchStationsByLineGroupIdLoading: false, + fetchTrainTypesError: undefined, + fetchStationsByLineIdError: undefined, + fetchStationsByLineGroupIdError: undefined, +}); + +const defaultWalkthrough = () => ({ + isWalkthroughActive: false, + currentStepIndex: 0, + currentStep: null as Record | null, + totalSteps: 5, + nextStep: mockNextStep, + goToStep: mockGoToStep, + skipWalkthrough: mockSkipWalkthrough, + setSettingsButtonLayout: jest.fn(), + setNowHeaderLayout: jest.fn(), + lineListRef: { current: null }, + presetsRef: { current: null }, + handlePresetsLayout: jest.fn(), + handleLineListLayout: jest.fn(), +}); + +const setupDefaults = ({ + station = null as Station | null, + nearbyStationLoading = false, + carouselData = [] as unknown[], + isRoutesDBInitialized = true, + lineSelection = defaultLineSelection(), + walkthrough = defaultWalkthrough(), + stationsCache = [] as Station[][], +} = {}) => { + (useInitialNearbyStation as jest.Mock).mockReturnValue({ + station, + nearbyStationLoading, + }); + (useStationsCache as jest.Mock).mockImplementation(() => {}); + (usePresetCarouselData as jest.Mock).mockReturnValue({ + carouselData, + routes: [], + isRoutesDBInitialized, + }); + (useLineSelection as jest.Mock).mockReturnValue(lineSelection); + (useSelectLineWalkthrough as jest.Mock).mockReturnValue(walkthrough); + (useDeviceOrientation as jest.Mock).mockReturnValue(1); // PORTRAIT_UP + // stationState を1回目、isLEDThemeAtom を2回目に返す + let atomCallCount = 0; + (useAtomValue as jest.Mock).mockImplementation(() => { + atomCallCount++; + if (atomCallCount % 2 === 1) return { stationsCache }; + return false; // isLEDTheme + }); +}; + +// --- テスト --- + +describe('SelectLineScreen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('ローディング状態', () => { + it('nearbyStationLoading=true のときスケルトンが表示される', () => { + setupDefaults({ nearbyStationLoading: true }); + + const { getByTestId, queryByTestId } = render(); + + expect(getByTestId('skeleton-placeholder')).toBeTruthy(); + expect(queryByTestId('presets')).toBeNull(); + }); + + it('nearbyStationLoading=false のときプリセットが表示される', () => { + setupDefaults({ nearbyStationLoading: false }); + + const { getByTestId, queryByTestId } = render(); + + expect(queryByTestId('skeleton-placeholder')).toBeNull(); + expect(getByTestId('presets')).toBeTruthy(); + }); + }); + + describe('路線一覧の表示', () => { + it('駅がない場合はヘッディングが表示されない', () => { + setupDefaults({ station: null }); + + const { queryByTestId } = render(); + + expect(queryByTestId('heading')).toBeNull(); + }); + + it('鉄道路線がある駅が設定されると路線カードが表示される', () => { + const railLine = createLine(100, { + nameShort: '山手線', + station: { id: 1, hasTrainTypes: false } as Line['station'], + transportType: null, + }); + const station = createStation(1, { + name: '渋谷', + nameRoman: 'Shibuya', + lines: [railLine] as Station['lines'], + } as Parameters[1]); + + setupDefaults({ station, stationsCache: [[createStation(10)]] }); + + const { getByTestId, getAllByTestId } = render(); + + // ヘッディングが表示される + const headings = getAllByTestId('heading'); + expect(headings.length).toBeGreaterThan(0); + + // 路線カードが表示される + expect(getByTestId('line_100')).toBeTruthy(); + }); + + it('バス路線がある場合はバスセクションも表示される', () => { + const railLine = createLine(100, { + nameShort: '山手線', + station: { id: 1, hasTrainTypes: false } as Line['station'], + transportType: null, + }); + const busLine = createLine(200, { + nameShort: '都営バス', + station: { id: 1, hasTrainTypes: false } as Line['station'], + transportType: TransportType.Bus, + }); + const station = createStation(1, { + name: '新宿', + nameRoman: 'Shinjuku', + lines: [railLine, busLine] as Station['lines'], + } as Parameters[1]); + + setupDefaults({ station, stationsCache: [[], []] }); + + const { getByTestId, getAllByTestId } = render(); + + // 鉄道とバスの両方のヘッディングが表示される + const headings = getAllByTestId('heading'); + expect(headings.length).toBe(2); + + // 両方の路線カードが表示される + expect(getByTestId('line_100')).toBeTruthy(); + expect(getByTestId('line_200')).toBeTruthy(); + }); + }); + + describe('NowHeader', () => { + it('station が NowHeader に渡される', () => { + const station = createStation(1, { name: '東京' }); + setupDefaults({ station }); + + const { getByTestId } = render(); + + expect(getByTestId('now-header')).toBeTruthy(); + expect(getByTestId('now-header-station').props.children).toBe('東京'); + }); + + it('station が null の場合も NowHeader が表示される', () => { + setupDefaults({ station: null }); + + const { getByTestId } = render(); + + expect(getByTestId('now-header')).toBeTruthy(); + expect(getByTestId('now-header-station').props.children).toBe(''); + }); + }); + + describe('FooterTabBar', () => { + it('home タブがアクティブで表示される', () => { + setupDefaults(); + + const { getByTestId } = render(); + + expect(getByTestId('footer-tab-home')).toBeTruthy(); + }); + }); + + describe('SelectBoundModal', () => { + it('isSelectBoundModalOpen=false のときモーダルは非表示', () => { + setupDefaults(); + + const { queryByTestId } = render(); + + expect(queryByTestId('select-bound-modal')).toBeNull(); + }); + + it('isSelectBoundModalOpen=true のときモーダルが表示される', () => { + setupDefaults({ + lineSelection: { + ...defaultLineSelection(), + isSelectBoundModalOpen: true, + }, + }); + + const { getByTestId } = render(); + + expect(getByTestId('select-bound-modal')).toBeTruthy(); + }); + + it('loading 中はモーダル内にローディング表示', () => { + setupDefaults({ + lineSelection: { + ...defaultLineSelection(), + isSelectBoundModalOpen: true, + fetchStationsByLineIdLoading: true, + }, + }); + + const { getByTestId } = render(); + + expect(getByTestId('modal-loading')).toBeTruthy(); + }); + }); + + describe('ウォークスルー', () => { + it('currentStep が null のときウォークスルーは非表示', () => { + setupDefaults(); + + const { queryByTestId } = render(); + + expect(queryByTestId(/walkthrough-overlay/)).toBeNull(); + }); + + it('currentStep がある場合はウォークスルーが表示される', () => { + setupDefaults({ + walkthrough: { + ...defaultWalkthrough(), + isWalkthroughActive: true, + currentStep: { + id: 'welcome', + titleKey: 'walkthroughTitle1', + descriptionKey: 'walkthroughDescription1', + tooltipPosition: 'bottom' as const, + }, + currentStepIndex: 0, + }, + }); + + const { getByTestId } = render(); + + expect(getByTestId('walkthrough-overlay-step-0')).toBeTruthy(); + }); + }); + + describe('プリセット', () => { + it('DB 未初期化時はプリセットがローディング状態', () => { + setupDefaults({ isRoutesDBInitialized: false }); + + const { getByTestId } = render(); + + expect(getByTestId('presets-loading')).toBeTruthy(); + }); + + it('carouselData の件数がプリセットに渡される', () => { + const carouselData = [ + { + id: '1', + __k: '1-0', + stations: [], + name: 'a', + lineId: 1, + trainTypeId: null, + hasTrainType: false, + createdAt: new Date(), + }, + { + id: '2', + __k: '2-1', + stations: [], + name: 'b', + lineId: 2, + trainTypeId: null, + hasTrainType: false, + createdAt: new Date(), + }, + ]; + setupDefaults({ carouselData, isRoutesDBInitialized: true }); + + const { getByTestId } = render(); + + expect(getByTestId('presets-count').props.children).toBe(2); + }); + }); + + describe('フック呼び出しの統合', () => { + it('useStationsCache に station が渡される', () => { + const station = createStation(1); + setupDefaults({ station }); + + render(); + + expect(useStationsCache).toHaveBeenCalledWith(station); + }); + + it('station が null のとき useStationsCache に null が渡される', () => { + setupDefaults({ station: null }); + + render(); + + expect(useStationsCache).toHaveBeenCalledWith(null); + }); + + it('ScreenOrientation.unlockAsync が呼ばれる', () => { + setupDefaults(); + + render(); + + const ScreenOrientation = require('expo-screen-orientation'); + expect(ScreenOrientation.unlockAsync).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/SelectLineScreen.tsx b/src/screens/SelectLineScreen.tsx index c207f4e17..f6c68e496 100644 --- a/src/screens/SelectLineScreen.tsx +++ b/src/screens/SelectLineScreen.tsx @@ -1,19 +1,8 @@ -import { useLazyQuery } from '@apollo/client/react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as Location from 'expo-location'; import * as ScreenOrientation from 'expo-screen-orientation'; import { Orientation } from 'expo-screen-orientation'; -import findNearest from 'geolib/es/findNearest'; -import orderByDistance from 'geolib/es/orderByDistance'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { Alert, StyleSheet, View } from 'react-native'; +import { useAtomValue } from 'jotai'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; import Animated, { LinearTransition, useAnimatedScrollHandler, @@ -24,78 +13,34 @@ import { useSafeAreaInsets, } from 'react-native-safe-area-context'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; -import type { Line, LineNested, Station, TrainType } from '~/@types/graphql'; +import type { Line, LineNested } from '~/@types/graphql'; import { CommonCard } from '~/components/CommonCard'; import { EmptyLineSeparator } from '~/components/EmptyLineSeparator'; -import { type HeaderLayout, NowHeader } from '~/components/NowHeader'; +import { NowHeader } from '~/components/NowHeader'; import { SelectBoundModal } from '~/components/SelectBoundModal'; import WalkthroughOverlay from '~/components/WalkthroughOverlay'; import { useDeviceOrientation } from '~/hooks/useDeviceOrientation'; -import { useWalkthroughCompleted } from '~/hooks/useWalkthroughCompleted'; -import { gqlClient } from '~/lib/gql'; -import { - GET_LINE_GROUP_LIST_STATIONS_PRESET, - GET_LINE_GROUP_STATIONS, - GET_LINE_LIST_STATIONS_LIGHT, - GET_LINE_LIST_STATIONS_PRESET, - GET_LINE_STATIONS, - GET_STATION_TRAIN_TYPES_LIGHT, -} from '~/lib/graphql/queries'; -import type { SavedRoute } from '~/models/SavedRoute'; +import { useInitialNearbyStation } from '~/hooks/useInitialNearbyStation'; +import { useLineSelection } from '~/hooks/useLineSelection'; +import { usePresetCarouselData } from '~/hooks/usePresetCarouselData'; +import { useSelectLineWalkthrough } from '~/hooks/useSelectLineWalkthrough'; +import { useStationsCache } from '~/hooks/useStationsCache'; import isTablet from '~/utils/isTablet'; import { isBusLine } from '~/utils/line'; -import FooterTabBar, { - type ButtonLayout, - FOOTER_BASE_HEIGHT, -} from '../components/FooterTabBar'; +import FooterTabBar, { FOOTER_BASE_HEIGHT } from '../components/FooterTabBar'; import { Heading } from '../components/Heading'; -import { ASYNC_STORAGE_KEYS, LOCATION_TASK_NAME } from '../constants'; -import { useFetchCurrentLocationOnce, useFetchNearbyStation } from '../hooks'; -import { useSavedRoutes } from '../hooks/useSavedRoutes'; -import lineStateAtom from '../store/atoms/line'; -import { locationAtom, setLocation } from '../store/atoms/location'; -import navigationState, { type LoopItem } from '../store/atoms/navigation'; import stationState from '../store/atoms/station'; import { isLEDThemeAtom } from '../store/atoms/theme'; import { isJapanese, translate } from '../translation'; import { generateLineTestId } from '../utils/generateTestID'; import { SelectLineScreenPresets } from './SelectLineScreenPresets'; -type GetLineStationsData = { - lineStations: Station[]; -}; - -type GetLineStationsVariables = { - lineId: number; - stationId?: number; -}; - -type GetLineGroupStationsData = { - lineGroupStations: Station[]; -}; - -type GetLineGroupStationsVariables = { - lineGroupId: number; -}; - -type GetStationTrainTypesData = { - stationTrainTypes: TrainType[]; -}; - -type GetStationTrainTypesVariables = { - stationId: number; -}; - const styles = StyleSheet.create({ root: { paddingHorizontal: 24, flex: 1 }, listContainerStyle: { paddingBottom: 24, paddingHorizontal: 24, }, - lineName: { - fontSize: 14, - fontWeight: 'bold', - }, heading: { fontSize: 24, fontWeight: 'bold', @@ -118,57 +63,54 @@ const NearbyStationLoader = () => ( ); -const INITIAL_LOCATION_FALLBACK_DELAY_MS = 800; - const SelectLineScreen = () => { const [nowHeaderHeight, setNowHeaderHeight] = useState(0); - const [carouselData, setCarouselData] = useState([]); - const [isSelectBoundModalOpen, setIsSelectBoundModalOpen] = useState(false); - - const [stationAtomState, setStationState] = useAtom(stationState); - const [, setLineState] = useAtom(lineStateAtom); - const { station: stationFromAtom, stationsCache } = stationAtomState; - const setNavigationState = useSetAtom(navigationState); - const insets = useSafeAreaInsets(); - const scrollY = useSharedValue(0); - - const [settingsButtonLayout, setSettingsButtonLayout] = - useState(null); - const [nowHeaderLayout, setNowHeaderLayout] = useState( - null - ); - const [lineListLayout, setLineListLayout] = useState<{ - x: number; - y: number; - width: number; - height: number; - } | null>(null); - const [presetsLayout, setPresetsLayout] = useState<{ - x: number; - y: number; - width: number; - height: number; - } | null>(null); - const lineListRef = useRef(null); - const presetsRef = useRef(null); - const prevRoutesKeyRef = useRef(''); - const initialNearbyFetchInFlightRef = useRef(false); + // --- カスタムフック --- + const { station, nearbyStationLoading } = useInitialNearbyStation(); + useStationsCache(station); + const { carouselData, isRoutesDBInitialized } = usePresetCarouselData(); + const { + handleLineSelected, + handleTrainTypeSelect, + handlePresetPress, + handleCloseSelectBoundModal, + isSelectBoundModalOpen, + fetchTrainTypesLoading, + fetchStationsByLineIdLoading, + fetchStationsByLineGroupIdLoading, + fetchTrainTypesError, + fetchStationsByLineIdError, + fetchStationsByLineGroupIdError, + } = useLineSelection(); const { isWalkthroughActive, currentStepIndex, - currentStepId, currentStep, totalSteps, nextStep, goToStep, skipWalkthrough, - setSpotlightArea, - } = useWalkthroughCompleted(); + setSettingsButtonLayout, + setNowHeaderLayout, + lineListRef, + presetsRef, + handlePresetsLayout, + handleLineListLayout, + } = useSelectLineWalkthrough(); + + // --- atom 読み取り --- + const { stationsCache } = useAtomValue(stationState); + const isLEDTheme = useAtomValue(isLEDThemeAtom); + const insets = useSafeAreaInsets(); + const scrollY = useSharedValue(0); + + // --- 画面回転ロック解除 --- + useEffect(() => { + ScreenOrientation.unlockAsync().catch(console.error); + }, []); - const location = useAtomValue(locationAtom); - const latitude = location?.coords.latitude; - const longitude = location?.coords.longitude; + // --- 派生値 --- const footerHeight = FOOTER_BASE_HEIGHT + Math.max(insets.bottom, 8); const listPaddingBottom = useMemo(() => { const flattened = StyleSheet.flatten(styles.listContainerStyle) as { @@ -177,7 +119,6 @@ const SelectLineScreen = () => { return (flattened?.paddingBottom ?? 24) + footerHeight; }, [footerHeight]); - const isLEDTheme = useAtomValue(isLEDThemeAtom); const orientation = useDeviceOrientation(); const isPortraitOrientation = useMemo( () => @@ -190,17 +131,6 @@ const SelectLineScreen = () => { [isPortraitOrientation] ); - const { - stations: nearbyStations, - fetchByCoords, - isLoading: nearbyStationLoading, - error: nearbyStationFetchError, - } = useFetchNearbyStation(); - const station = useMemo( - () => stationFromAtom ?? nearbyStations[0] ?? null, - [stationFromAtom, nearbyStations] - ); - const stationLines = useMemo(() => { return (station?.lines ?? []).filter( (line): line is LineNested => line?.id != null && !isBusLine(line) @@ -212,589 +142,6 @@ const SelectLineScreen = () => { ); }, [station?.lines]); - const { fetchCurrentLocation } = useFetchCurrentLocationOnce(); - const { - routes, - updateRoutes, - isInitialized: isRoutesDBInitialized, - } = useSavedRoutes(); - - const [ - fetchStationsByLineId, - { - loading: fetchStationsByLineIdLoading, - error: fetchStationsByLineIdError, - }, - ] = useLazyQuery( - GET_LINE_STATIONS - ); - const [ - fetchStationsByLineGroupId, - { - loading: fetchStationsByLineGroupIdLoading, - error: fetchStationsByLineGroupIdError, - }, - ] = useLazyQuery( - GET_LINE_GROUP_STATIONS - ); - const [ - fetchTrainTypes, - { loading: fetchTrainTypesLoading, error: fetchTrainTypesError }, - ] = useLazyQuery( - GET_STATION_TRAIN_TYPES_LIGHT - ); - - useEffect(() => { - ScreenOrientation.unlockAsync().catch(console.error); - }, []); - - useEffect(() => { - if (!isRoutesDBInitialized) return; - updateRoutes(); - }, [isRoutesDBInitialized, updateRoutes]); - - useEffect(() => { - const routesKey = routes - .map((r) => `${r.id}:${r.lineId}:${r.trainTypeId}:${r.hasTrainType}`) - .join(','); - if (routesKey === prevRoutesKeyRef.current) return; - - const fetchAsync = async () => { - try { - const lineRoutes = routes.filter((r) => !r.hasTrainType); - const trainTypeRoutes = routes.filter((r) => r.hasTrainType); - - // !hasTrainType のルートを lineListStations で一括取得 - const lineStationsMap = new Map(); - const validLineRoutes = lineRoutes.filter((r) => r.lineId !== null); - if (validLineRoutes.length > 0) { - const lineIds = validLineRoutes.map((r) => r.lineId); - const result = await gqlClient.query<{ - lineListStations: Station[]; - }>({ - query: GET_LINE_LIST_STATIONS_PRESET, - variables: { lineIds }, - }); - for (const s of result.data?.lineListStations ?? []) { - const lid = s.line?.id; - if (lid == null) continue; - const arr = lineStationsMap.get(lid); - if (arr) { - arr.push(s); - } else { - lineStationsMap.set(lid, [s]); - } - } - } - - // hasTrainType のルートを lineGroupListStations で一括取得 - const trainTypeStationsMap = new Map(); - if (trainTypeRoutes.length > 0) { - const lineGroupIds = trainTypeRoutes.map((r) => r.trainTypeId); - const result = await gqlClient.query<{ - lineGroupListStations: Station[]; - }>({ - query: GET_LINE_GROUP_LIST_STATIONS_PRESET, - variables: { lineGroupIds }, - }); - for (const s of result.data?.lineGroupListStations ?? []) { - const gid = s.trainType?.groupId; - if (gid == null) continue; - const arr = trainTypeStationsMap.get(gid); - if (arr) { - arr.push(s); - } else { - trainTypeStationsMap.set(gid, [s]); - } - } - } - - setCarouselData( - routes.map((r, i) => ({ - ...r, - __k: `${r.id}-${i}`, - stations: r.hasTrainType - ? (trainTypeStationsMap.get(r.trainTypeId) ?? []) - : (lineStationsMap.get(r.lineId) ?? []), - })) - ); - prevRoutesKeyRef.current = routesKey; - } catch (err) { - console.error(err); - } - }; - fetchAsync(); - }, [routes]); - - useEffect(() => { - const stopLocationUpdates = async () => { - const hasStartedLocationUpdates = - await Location.hasStartedLocationUpdatesAsync(LOCATION_TASK_NAME); - if (hasStartedLocationUpdates) { - await Location.stopLocationUpdatesAsync(LOCATION_TASK_NAME); - } - }; - stopLocationUpdates(); - }, []); - - const updateStationsCache = useCallback( - async (station: Station) => { - const fetchedLines = (station.lines ?? []).filter( - (line): line is LineNested => line?.id != null - ); - - const lineIds = fetchedLines.map((line) => line.id as number); - if (lineIds.length === 0) return; - - let allStations: Station[]; - try { - const result = await gqlClient.query<{ - lineListStations: Station[]; - }>({ - query: GET_LINE_LIST_STATIONS_LIGHT, - variables: { lineIds }, - }); - allStations = result.data?.lineListStations ?? []; - } catch (err) { - console.error(err); - return; - } - const stationsByLineId = new Map(); - for (const s of allStations) { - const lid = s.line?.id; - if (lid == null) continue; - const arr = stationsByLineId.get(lid); - if (arr) { - arr.push(s); - } else { - stationsByLineId.set(lid, [s]); - } - } - - const stationsCache: Station[][] = fetchedLines.map( - (line) => stationsByLineId.get(line.id as number) ?? [] - ); - - setStationState((prev) => ({ - ...prev, - stationsCache, - })); - }, - [setStationState] - ); - - 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(() => { - if (!station) return; - updateStationsCache(station); - }, [station, updateStationsCache]); - - 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]); - - // ウォークスルーの「現在地を変更」ステップでNowHeaderをハイライト - useEffect(() => { - if (currentStepId === 'changeLocation' && nowHeaderLayout) { - setSpotlightArea({ - x: nowHeaderLayout.x, - y: nowHeaderLayout.y, - width: nowHeaderLayout.width, - height: nowHeaderLayout.height, - borderRadius: 16, - }); - } - }, [currentStepId, nowHeaderLayout, setSpotlightArea]); - - // ウォークスルーの「路線を選択」ステップで路線一覧をハイライト - useEffect(() => { - if (currentStepId === 'selectLine' && lineListLayout) { - setSpotlightArea({ - x: lineListLayout.x, - y: lineListLayout.y, - width: lineListLayout.width, - height: lineListLayout.height, - borderRadius: 12, - }); - } - }, [currentStepId, lineListLayout, setSpotlightArea]); - - // ウォークスルーの「プリセット」ステップでプリセットエリアをハイライト - // SelectLineScreenPresetsのmarginTop: -16を補正 - useEffect(() => { - if (currentStepId === 'savedRoutes' && presetsLayout) { - setSpotlightArea({ - x: presetsLayout.x, - y: presetsLayout.y - 16, - width: presetsLayout.width, - height: presetsLayout.height, - borderRadius: 12, - }); - } - }, [currentStepId, presetsLayout, setSpotlightArea]); - - // ウォークスルーの「カスタマイズ」ステップで設定ボタンをハイライト - useEffect(() => { - if (currentStepId === 'customize' && settingsButtonLayout) { - setSpotlightArea({ - x: settingsButtonLayout.x, - y: settingsButtonLayout.y, - width: settingsButtonLayout.width, - height: settingsButtonLayout.height, - borderRadius: 24, - }); - } - }, [currentStepId, settingsButtonLayout, setSpotlightArea]); - - const handlePresetsLayout = useCallback(() => { - if (presetsRef.current) { - presetsRef.current.measureInWindow( - (x: number, y: number, width: number, height: number) => { - setPresetsLayout({ x, y, width, height }); - } - ); - } - }, []); - - const handleLineListLayout = useCallback(() => { - if (lineListRef.current) { - lineListRef.current.measureInWindow( - (x: number, y: number, width: number, height: number) => { - setLineListLayout({ x, y, width, height }); - } - ); - } - }, []); - - const handleLineSelected = useCallback( - async (line: Line) => { - const lineId = line.id; - const lineStationId = line.station?.id; - if (!lineId || !lineStationId) return; - - setIsSelectBoundModalOpen(true); - - setStationState((prev) => ({ - ...prev, - pendingStation: null, - pendingStations: [], - selectedDirection: null, - wantedDestination: null, - selectedBound: null, - })); - setLineState((prev) => ({ - ...prev, - pendingLine: line ?? null, - })); - setNavigationState((prev) => ({ - ...prev, - fetchedTrainTypes: [], - trainType: null, - pendingTrainType: null, - })); - - const result = await fetchStationsByLineId({ - variables: { lineId, stationId: lineStationId }, - }); - const fetchedStations = result.data?.lineStations ?? []; - - const pendingStation = - fetchedStations.find((s) => s.id === lineStationId) ?? null; - - setStationState((prev) => ({ - ...prev, - pendingStation, - pendingStations: fetchedStations, - })); - - if (line.station?.hasTrainTypes) { - const result = await fetchTrainTypes({ - variables: { - stationId: lineStationId, - }, - }); - const fetchedTrainTypes = result.data?.stationTrainTypes ?? []; - const designatedTrainTypeId = - fetchedStations.find((s) => s.id === lineStationId)?.trainType?.id ?? - null; - const designatedTrainType = - fetchedTrainTypes.find((tt) => tt.id === designatedTrainTypeId) ?? - null; - setNavigationState((prev) => ({ - ...prev, - fetchedTrainTypes, - pendingTrainType: designatedTrainType as TrainType | null, - })); - } - }, - [ - fetchTrainTypes, - setNavigationState, - setStationState, - setLineState, - fetchStationsByLineId, - ] - ); - - const handleTrainTypeSelect = useCallback( - async (trainType: TrainType) => { - if (trainType.groupId == null) return; - const res = await fetchStationsByLineGroupId({ - variables: { - lineGroupId: trainType.groupId, - }, - }); - setStationState((prev) => ({ - ...prev, - pendingStations: res.data?.lineGroupStations ?? [], - })); - setNavigationState((prev) => ({ - ...prev, - pendingTrainType: trainType, - })); - }, - [fetchStationsByLineGroupId, setStationState, setNavigationState] - ); - - // PresetCard押下時のモーダル表示ロジック - const openModalByLineId = useCallback( - async (lineId: number) => { - const result = await fetchStationsByLineId({ - variables: { lineId }, - }); - const stations = result.data?.lineStations ?? []; - if (!stations.length) return; - - const nearestCoordinates = - latitude && longitude - ? (findNearest( - { latitude, longitude }, - stations.map((sta: Station) => ({ - latitude: sta.latitude as number, - longitude: sta.longitude as number, - })) - ) as { latitude: number; longitude: number }) - : stations.map((s) => ({ - latitude: s.latitude, - longitude: s.longitude, - }))[0]; - - const station = stations.find( - (sta: Station) => - sta.latitude === nearestCoordinates.latitude && - sta.longitude === nearestCoordinates.longitude - ); - - if (!station) return; - - setStationState((prev) => ({ - ...prev, - selectedDirection: null, - pendingStation: station, - pendingStations: stations, - wantedDestination: null, - })); - setLineState((prev) => ({ - ...prev, - pendingLine: (station.line as Line) ?? null, - })); - setNavigationState((prev) => ({ - ...prev, - fetchedTrainTypes: [], - pendingTrainType: null, - })); - }, - [ - fetchStationsByLineId, - latitude, - longitude, - setStationState, - setLineState, - setNavigationState, - ] - ); - - // PresetCard押下時のモーダル表示ロジック - const openModalByTrainTypeId = useCallback( - async (lineGroupId: number) => { - const result = await fetchStationsByLineGroupId({ - variables: { lineGroupId }, - }); - const stations = result.data?.lineGroupStations ?? []; - if (!stations.length) return; - - const sortedStationCoords = - latitude && longitude - ? (orderByDistance( - { lat: latitude, lon: longitude }, - stations.map((sta) => ({ - latitude: sta.latitude as number, - longitude: sta.longitude as number, - })) - ) as { latitude: number; longitude: number }[]) - : stations.map((sta) => ({ - latitude: sta.latitude, - longitude: sta.longitude, - })); - - const sortedStations = stations.slice().sort((a, b) => { - const aIndex = sortedStationCoords.findIndex( - (coord) => - coord.latitude === a.latitude && coord.longitude === a.longitude - ); - const bIndex = sortedStationCoords.findIndex( - (coord) => - coord.latitude === b.latitude && coord.longitude === b.longitude - ); - return aIndex - bIndex; - }); - - const station = sortedStations.find( - (sta: Station) => sta.trainType?.groupId === lineGroupId - ); - - if (!station) return; - - setStationState((prev) => ({ - ...prev, - selectedDirection: null, - pendingStation: station, - pendingStations: stations, - wantedDestination: null, - })); - setLineState((prev) => ({ - ...prev, - pendingLine: station?.line ?? null, - })); - - const fetchedTrainTypesData = await fetchTrainTypes({ - variables: { - stationId: station.id as number, - }, - }); - const trainTypes = fetchedTrainTypesData.data?.stationTrainTypes ?? []; - - setNavigationState((prev) => ({ - ...prev, - pendingTrainType: station.trainType ?? null, - fetchedTrainTypes: trainTypes, - })); - }, - [ - fetchStationsByLineGroupId, - fetchTrainTypes, - setNavigationState, - setStationState, - setLineState, - latitude, - longitude, - ] - ); - - const handlePresetPress = useCallback( - async (route: SavedRoute) => { - setIsSelectBoundModalOpen(true); - if (route.hasTrainType) { - await openModalByTrainTypeId(route.trainTypeId); - } else { - await openModalByLineId(route.lineId); - } - }, - [openModalByLineId, openModalByTrainTypeId] - ); - - const handleCloseSelectBoundModal = useCallback(() => { - setIsSelectBoundModalOpen(false); - }, []); - const headingTitleForRailway = useMemo(() => { if (!station) return translate('selectLineTitle'); const re = /\([^()]*\)/g; @@ -808,12 +155,6 @@ const SelectLineScreen = () => { }); }, [station]); - const handleScroll = useAnimatedScrollHandler({ - onScroll: (e) => { - scrollY.value = e.contentOffset.y; - }, - }); - const isPresetsLoading = useMemo( () => !isRoutesDBInitialized || @@ -826,6 +167,14 @@ const SelectLineScreen = () => { ] ); + // --- スクロールハンドラ --- + const handleScroll = useAnimatedScrollHandler({ + onScroll: (e) => { + scrollY.value = e.contentOffset.y; + }, + }); + + // --- レンダーコールバック --- const renderLineCard = useCallback( (line: Line, index: number) => { if (fetchStationsByLineIdLoading) { @@ -901,6 +250,7 @@ const SelectLineScreen = () => { [numColumns, renderLineCard, renderPlaceholders] ); + // --- JSX --- return ( <>