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 && (