diff --git a/android/app/src/main/assets/translations/en.json b/android/app/src/main/assets/translations/en.json index b88600c5f..1f84774e0 100644 --- a/android/app/src/main/assets/translations/en.json +++ b/android/app/src/main/assets/translations/en.json @@ -155,5 +155,6 @@ "longPressNotice": "Press and hold the screen to open the menu.", "autoAnnounceBackgroundTitle":"Enable background voice announcement", "loadingLocation": "Getting location information...", - "loadingAPI": "Communicating with server..." + "loadingAPI": "Communicating with server...", + "routeSearchTitle": "Find a route" } diff --git a/android/app/src/main/assets/translations/ja.json b/android/app/src/main/assets/translations/ja.json index 0bb5c22a9..c41615e15 100644 --- a/android/app/src/main/assets/translations/ja.json +++ b/android/app/src/main/assets/translations/ja.json @@ -156,5 +156,6 @@ "longPressNotice": "画面を長押しするとメニューが開けます", "autoAnnounceBackgroundTitle":"バックグラウンド音声を有効にする", "loadingLocation": "位置情報を取得中です", - "loadingAPI": "サーバーと通信中です" + "loadingAPI": "サーバーと通信中です", + "routeSearchTitle": "経路を検索" } diff --git a/ios/TrainLCD/translations/en.json b/ios/TrainLCD/translations/en.json index dca390a76..5997843bd 100644 --- a/ios/TrainLCD/translations/en.json +++ b/ios/TrainLCD/translations/en.json @@ -155,5 +155,6 @@ "longPressNotice": "Press and hold the screen to open the menu.", "autoAnnounceBackgroundTitle":"Enable background voice announcement", "loadingLocation": "Getting location information...", - "loadingAPI": "Communicating with server..." + "loadingAPI": "Communicating with server...", + "routeSearchTitle": "Find a route" } diff --git a/ios/TrainLCD/translations/ja.json b/ios/TrainLCD/translations/ja.json index a81e5a07f..e4a495fa3 100644 --- a/ios/TrainLCD/translations/ja.json +++ b/ios/TrainLCD/translations/ja.json @@ -156,5 +156,6 @@ "longPressNotice": "画面を長押しするとメニューが開けます", "autoAnnounceBackgroundTitle":"バックグラウンド音声を有効にする", "loadingLocation": "位置情報を取得中です", - "loadingAPI": "サーバーと通信中です" + "loadingAPI": "サーバーと通信中です", + "routeSearchTitle": "経路を検索" } diff --git a/proto b/proto index d259ec90b..65d1377c5 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit d259ec90b5f856c0df298924bfaee8ef9eeb9c01 +Subproject commit 65d1377c5e7a233f4a2fd34bc50f1f0738ad1b20 diff --git a/src/components/RouteList.tsx b/src/components/RouteList.tsx new file mode 100644 index 000000000..561afd887 --- /dev/null +++ b/src/components/RouteList.tsx @@ -0,0 +1,107 @@ +import React, { useCallback, useMemo } from 'react' +import { FlatList, StyleSheet, TouchableOpacity, View } from 'react-native' +import { RFValue } from 'react-native-responsive-fontsize' +import { Route } from '../../gen/proto/stationapi_pb' +import { useCurrentStation } from '../hooks/useCurrentStation' +import { useThemeStore } from '../hooks/useThemeStore' +import { APP_THEME } from '../models/Theme' +import { isJapanese } from '../translation' +import Typography from './Typography' + +const styles = StyleSheet.create({ + cell: { padding: 12 }, + stationNameText: { + fontSize: RFValue(14), + }, + descriptionText: { + fontSize: RFValue(11), + marginTop: 8, + }, + separator: { height: 1, width: '100%', backgroundColor: '#aaa' }, +}) + +const Separator = () => + +const ItemCell = ({ + item, + onSelect, +}: { + item: Route + onSelect: (item: Route) => void +}) => { + const currentStation = useCurrentStation() + + const lineNameTitle = useMemo(() => { + const trainType = item.stops.find( + (stop) => stop.groupId === Number(currentStation?.groupId) + )?.trainType + + if (!isJapanese) { + const lineName = item.stops.find( + (s) => s.groupId === currentStation?.groupId + )?.line?.nameRoman + const typeName = trainType?.nameRoman ?? 'Local' + + return `${lineName} ${typeName}` + } + const lineName = item.stops.find( + (s) => s.groupId === currentStation?.groupId + )?.line?.nameShort + const typeName = trainType?.name ?? '普通または各駅停車' + + return `${lineName} ${typeName}` + }, [currentStation?.groupId, item.stops]) + + const bottomText = useMemo(() => { + return `${item.stops[0]?.name}から${ + item.stops[item.stops.length - 1]?.name + }まで` + }, [item.stops]) + + return ( + onSelect(item)}> + {lineNameTitle} + + {bottomText} + + + ) +} + +export const RouteList = ({ + data, + onSelect, +}: { + data: Route[] + onSelect: (item: Route) => void +}) => { + const isLEDTheme = useThemeStore((state) => state === APP_THEME.LED) + + const renderItem = useCallback( + ({ item }: { item: Route; index: number }) => { + return + }, + [onSelect] + ) + const keyExtractor = useCallback((item: Route) => item.id.toString(), []) + + return ( + + ) +} diff --git a/src/components/RouteListModal.tsx b/src/components/RouteListModal.tsx new file mode 100644 index 000000000..d460f46ba --- /dev/null +++ b/src/components/RouteListModal.tsx @@ -0,0 +1,112 @@ +import React from 'react' +import { Modal, StyleSheet, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { Route } from '../../gen/proto/stationapi_pb' +import { LED_THEME_BG_COLOR } from '../constants' +import { useThemeStore } from '../hooks/useThemeStore' +import { APP_THEME } from '../models/Theme' +import { translate } from '../translation' +import isTablet from '../utils/isTablet' +import FAB from './FAB' +import Heading from './Heading' +import { RouteList } from './RouteList' + +type Props = { + visible: boolean + routes: Route[] + loading: boolean + error: Error + onClose: () => void + onSelect: (route: Route) => void +} + +const styles = StyleSheet.create({ + modalContainer: { + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + width: '100%', + height: '100%', + }, + modalView: { + justifyContent: 'center', + alignItems: 'center', + }, + buttons: { + flexDirection: 'row', + justifyContent: 'center', + flexWrap: 'wrap', + alignItems: 'center', + alignSelf: 'center', + marginTop: 12, + }, +}) + +const SAFE_AREA_FALLBACK = 32 + +export const RouteListModal: React.FC = ({ + visible, + routes, + loading, + onClose, + onSelect, +}: Props) => { + const isLEDTheme = useThemeStore((state) => state === APP_THEME.LED) + const { left: leftSafeArea, right: rightSafeArea } = useSafeAreaInsets() + + return ( + + + + + + {translate('routeSearchTitle')} + + {routes.length ? ( + + ) : null} + + + + + + ) +} diff --git a/src/components/TrainTypeInfoModal.tsx b/src/components/TrainTypeInfoModal.tsx index f8dc4ca5d..9a95a62c8 100644 --- a/src/components/TrainTypeInfoModal.tsx +++ b/src/components/TrainTypeInfoModal.tsx @@ -55,6 +55,7 @@ export const TrainTypeInfoModal: React.FC = ({ visible, trainType, stations, + loading, onClose, onConfirmed, }: Props) => { @@ -136,7 +137,7 @@ export const TrainTypeInfoModal: React.FC = ({ marginTop: 8, }} > - 停車駅: + {translate('allStops')}: = ({ > {stopStations.length ? stopStations.map((s) => s.name).join('、') - : `${translate('loadingAPI')}...`} + : ''} + {loading ? `${translate('loadingAPI')}...` : ''} = ({ marginTop: 16, }} > + {/* FIXME: translate */} 各線の種別: @@ -222,10 +225,10 @@ export const TrainTypeInfoModal: React.FC = ({ onPress={() => onConfirmed(trainType)} disabled={!stopStations.length} > - 確定 + 確定{/* FIXME: translate */} diff --git a/src/components/TrainTypeList.tsx b/src/components/TrainTypeList.tsx index c7918d25f..dbfbb5655 100644 --- a/src/components/TrainTypeList.tsx +++ b/src/components/TrainTypeList.tsx @@ -70,9 +70,7 @@ const ItemCell = ({ return ( onSelect(item)}> - {isJapanese - ? item.name - : `${currentLine?.nameRoman} ${item.nameRoman}`} + {isJapanese ? item.name : item.nameRoman} {isJapanese ? '種別変更なし' : ''}{' '} diff --git a/src/hooks/useResetMainState.ts b/src/hooks/useResetMainState.ts index a52320520..72b1c51df 100644 --- a/src/hooks/useResetMainState.ts +++ b/src/hooks/useResetMainState.ts @@ -1,4 +1,5 @@ -import { useEffect } from 'react' +import { useFocusEffect } from '@react-navigation/native' +import { useCallback } from 'react' import { useSetRecoilState } from 'recoil' import navigationState from '../store/atoms/navigation' import stationState from '../store/atoms/station' @@ -8,24 +9,26 @@ export const useResetMainState = () => { const setNavigationState = useSetRecoilState(navigationState) const setStationState = useSetRecoilState(stationState) - useEffect(() => { - return () => { - setNavigationState((prev) => ({ - ...prev, - headerState: isJapanese ? 'CURRENT' : 'CURRENT_EN', - bottomState: 'LINE', - leftStations: [], - stationForHeader: null, - })) - setStationState((prev) => ({ - ...prev, - selectedDirection: null, - selectedBound: null, - arrived: true, - approaching: false, - averageDistance: null, - })) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + useFocusEffect( + useCallback(() => { + return () => { + setNavigationState((prev) => ({ + ...prev, + headerState: isJapanese ? 'CURRENT' : 'CURRENT_EN', + bottomState: 'LINE', + leftStations: [], + stationForHeader: null, + })) + setStationState((prev) => ({ + ...prev, + selectedDirection: null, + selectedBound: null, + arrived: true, + approaching: false, + averageDistance: null, + })) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + ) } diff --git a/src/hooks/useTransitionHeaderState.ts b/src/hooks/useTransitionHeaderState.ts index 720c1318c..30a63e93f 100644 --- a/src/hooks/useTransitionHeaderState.ts +++ b/src/hooks/useTransitionHeaderState.ts @@ -18,7 +18,7 @@ type HeaderState = 'CURRENT' | 'NEXT' | 'ARRIVING' type HeaderLangState = 'JA' | 'KANA' | 'EN' | 'ZH' | 'KO' const useTransitionHeaderState = (): void => { - const { arrived, approaching } = useRecoilValue(stationState) + const { arrived, approaching, selectedBound } = useRecoilValue(stationState) const isLEDTheme = useThemeStore((state) => state === APP_THEME.LED) const [ { @@ -93,6 +93,10 @@ const useTransitionHeaderState = (): void => { useIntervalEffect( useCallback(() => { + if (!selectedBound) { + return + } + const currentHeaderState = headerStateRef.current.split( '_' )[0] as HeaderState @@ -235,6 +239,7 @@ const useTransitionHeaderState = (): void => { isExtraLangAvailable, isPassing, nextStation, + selectedBound, setNavigation, showNextExpression, ]), diff --git a/src/index.tsx b/src/index.tsx index 6d40ab2f7..ce2f06d3e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,11 +8,12 @@ import { ErrorBoundary } from 'react-error-boundary' import { ActivityIndicator, StatusBar, StyleSheet, Text } from 'react-native' import { RecoilRoot } from 'recoil' import ErrorFallback from './components/ErrorBoundary' -import FakeStationSettings from './components/FakeStationSettings' import TuningSettings from './components/TuningSettings' import useAnonymousUser from './hooks/useAnonymousUser' import useReport from './hooks/useReport' +import FakeStationSettingsScreen from './screens/FakeStationSettingsScreen' import PrivacyScreen from './screens/Privacy' +import RouteSearchScreen from './screens/RouteSearchScreen' import SavedRoutesScreen from './screens/SavedRoutesScreen' import MainStack from './stacks/MainStack' import { setI18nConfig } from './translation' @@ -111,7 +112,7 @@ const App: React.FC = () => { { component={SavedRoutesScreen} /> + + { +const FakeStationSettingsScreen: React.FC = () => { const [query, setQuery] = useState('') const navigation = useNavigation() const [{ station: stationFromState }, setStationState] = @@ -77,6 +78,8 @@ const FakeStationSettings: React.FC = () => { const longitude = useLocationStore((state) => state?.coords.longitude) const isLEDTheme = useThemeStore((state) => state === APP_THEME.LED) + const currentStation = useCurrentStation() + const { data: byCoordsData, isMutating: isByCoordsLoading, @@ -148,9 +151,13 @@ const FakeStationSettings: React.FC = () => { [byCoordsData, byNameData] ) + // NOTE: 今いる駅は出なくていい const groupedStations = useMemo( - () => groupStations(foundStations), - [foundStations] + () => + groupStations(foundStations).filter( + (sta) => sta.groupId !== currentStation?.groupId + ), + [currentStation?.groupId, foundStations] ) const handleStationPress = useCallback( @@ -235,4 +242,4 @@ const FakeStationSettings: React.FC = () => { ) } -export default React.memo(FakeStationSettings) +export default React.memo(FakeStationSettingsScreen) diff --git a/src/screens/RouteSearchScreen.tsx b/src/screens/RouteSearchScreen.tsx new file mode 100644 index 000000000..866643831 --- /dev/null +++ b/src/screens/RouteSearchScreen.tsx @@ -0,0 +1,327 @@ +import { useNavigation } from '@react-navigation/native' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + NativeSyntheticEvent, + Platform, + StyleSheet, + TextInput, + TextInputChangeEventData, + TextInputKeyPressEventData, + View, +} from 'react-native' +import { RFValue } from 'react-native-responsive-fontsize' + +import { SEARCH_STATION_RESULT_LIMIT } from 'react-native-dotenv' +import { useSetRecoilState } from 'recoil' +import useSWR from 'swr' +import useSWRMutation from 'swr/mutation' +import { + GetRouteRequest, + GetStationsByNameRequest, + Route, + Station, + TrainDirection, + TrainType, + TrainTypeKind, +} from '../../gen/proto/stationapi_pb' +import FAB from '../components/FAB' +import Heading from '../components/Heading' +import { RouteListModal } from '../components/RouteListModal' +import { StationList } from '../components/StationList' +import { FONTS } from '../constants' +import { useCurrentStation } from '../hooks/useCurrentStation' +import { useThemeStore } from '../hooks/useThemeStore' +import { grpcClient } from '../lib/grpc' +import { APP_THEME } from '../models/Theme' +import lineState from '../store/atoms/line' +import navigationState from '../store/atoms/navigation' +import stationState from '../store/atoms/station' +import { translate } from '../translation' +import { groupStations } from '../utils/groupStations' + +const styles = StyleSheet.create({ + root: { + paddingHorizontal: 48, + paddingVertical: 12, + flex: 1, + alignItems: 'center', + }, + settingItem: { + width: '65%', + height: '100%', + alignItems: 'center', + }, + heading: { + marginBottom: 24, + }, + stationNameInput: { + borderWidth: 1, + padding: 12, + width: '100%', + fontSize: RFValue(14), + }, + emptyText: { + fontSize: RFValue(16), + textAlign: 'center', + marginTop: 12, + fontWeight: 'bold', + }, +}) + +const RouteSearchScreen = () => { + const [query, setQuery] = useState('') + const [selectedStation, setSelectedStation] = useState(null) + + const navigation = useNavigation() + const isLEDTheme = useThemeStore((state) => state === APP_THEME.LED) + + const [isRouteListModalVisible, setIsRouteListModalVisible] = useState(false) + const setStationState = useSetRecoilState(stationState) + const setLineState = useSetRecoilState(lineState) + const setNavigationState = useSetRecoilState(navigationState) + + const currentStation = useCurrentStation() + + const { + data: byNameData, + isMutating: isByNameLoading, + trigger: fetchByName, + error: byNameError, + } = useSWRMutation( + [ + '/app.trainlcd.grpc/getStationsByName', + query, + SEARCH_STATION_RESULT_LIMIT, + ], + async ([, query, limit]) => { + if (!query.length) { + return + } + + const trimmedQuery = query.trim() + const req = new GetStationsByNameRequest({ + stationName: trimmedQuery, + limit: Number(limit), + fromStationGroupId: currentStation?.groupId, + }) + const res = await grpcClient.getStationsByName(req) + return res.stations + } + ) + + const { + data: routesData, + isLoading: isRoutesLoading, + error: fetchRoutesError, + } = useSWR( + ['/app.trainlcd.grpc/getRoutes', selectedStation?.groupId], + async ([, toStationGroupId]) => { + if (!currentStation) { + return [] + } + + const req = new GetRouteRequest({ + fromStationGroupId: currentStation?.groupId, + toStationGroupId, + }) + const res = await grpcClient.getRoutes(req) + + return res.routes + } + ) + + const onPressBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + return + } + navigation.navigate('MainStack') + }, [navigation]) + + const handleSubmit = useCallback(() => fetchByName(), [fetchByName]) + + useEffect(() => { + if (byNameError) { + Alert.alert(translate('errorTitle'), translate('apiErrorText')) + } + }, [byNameError]) + + const foundStations = useMemo(() => byNameData ?? [], [byNameData]) + + // NOTE: 今いる駅は出なくていい + const groupedStations = useMemo( + () => + groupStations(foundStations).filter( + (sta) => sta.groupId != currentStation?.groupId + ), + [currentStation?.groupId, foundStations] + ) + + const handleStationPress = useCallback( + (stationFromSearch: Station) => { + const station = foundStations.find((s) => s.id === stationFromSearch.id) + if (!station) { + return + } + setSelectedStation(station) + setIsRouteListModalVisible(true) + }, + [foundStations] + ) + + const onKeyPress = useCallback( + (e: NativeSyntheticEvent) => { + if (e.nativeEvent.key === 'Enter') { + handleSubmit() + } + }, + [handleSubmit] + ) + + const onChange = useCallback( + (e: NativeSyntheticEvent) => { + setQuery(e.nativeEvent.text) + }, + [] + ) + + const handleSelect = useCallback( + (route: Route) => { + const matchedStation = route.stops.find( + (s) => s.groupId === currentStation?.groupId + ) + const line = matchedStation?.line + const matchedStationIndex = route.stops.findIndex( + (s) => s.groupId === matchedStation?.groupId + ) + const boundStationIndex = route.stops.findIndex( + (s) => s?.groupId === selectedStation?.groupId + ) + const direction = + matchedStationIndex < boundStationIndex ? 'INBOUND' : 'OUTBOUND' + + const stops = + direction === 'INBOUND' ? route.stops : route.stops.slice().reverse() + const currentStationIndex = stops.findIndex( + (stop) => stop.groupId === currentStation?.groupId + ) + const stopsAfterCurrentStation = stops.slice(currentStationIndex) + + if (line) { + setStationState((prev) => ({ + ...prev, + stations: stopsAfterCurrentStation, + })) + + const trainTypes = + routesData + ?.flatMap((route) => + route.stops.find( + (stop) => stop.groupId === matchedStation.groupId + ) + ) + .map((stop) => { + if (stop?.trainType) { + return stop?.trainType + } + + return new TrainType({ + id: 0, + typeId: 0, + groupId: 0, + name: '普通/各駅停車', + nameKatakana: '', + nameRoman: 'Local', + nameChinese: '慢车/每站停车', + nameKorean: '보통/각역정차', + color: '', + lines: stop?.lines, + direction: TrainDirection.Both, + kind: TrainTypeKind.Default, + }) + }) ?? [] + + setNavigationState((prev) => ({ + ...prev, + trainType: matchedStation.trainType ?? null, + fetchedTrainTypes: trainTypes, + leftStations: [], + fromBuilder: true, + })) + setLineState((prev) => ({ + ...prev, + selectedLine: line, + })) + navigation.navigate('SelectBound') + } + }, + [ + currentStation?.groupId, + navigation, + routesData, + selectedStation?.groupId, + setLineState, + setNavigationState, + setStationState, + ] + ) + + return ( + <> + + + + {translate('routeSearchTitle')} + + + {isByNameLoading ? ( + + ) : ( + + )} + + + + {routesData?.length ? ( + setIsRouteListModalVisible(false)} + onSelect={handleSelect} + /> + ) : null} + + ) +} + +export default React.memo(RouteSearchScreen) diff --git a/src/screens/SelectLine.tsx b/src/screens/SelectLine.tsx index a215edb15..bc337d1a4 100644 --- a/src/screens/SelectLine.tsx +++ b/src/screens/SelectLine.tsx @@ -224,6 +224,10 @@ const SelectLineScreen: React.FC = () => { navigation.navigate('SavedRoutes') }, [navigation]) + const navigateToRouteSearchScreen = useCallback(() => { + navigation.navigate('RouteSearch') + }, [navigation]) + if (nearbyStationFetchError) { return ( { {translate('settings')} {isInternetAvailable ? ( - + <> + + + ) : null} {isInternetAvailable && isDevApp && (