searchResultComponent.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import {
  2. View,
  3. Text,
  4. StyleSheet,
  5. Pressable,
  6. Image,
  7. ImageSourcePropType,
  8. TouchableWithoutFeedback,
  9. Keyboard,
  10. ActivityIndicator
  11. } from 'react-native';
  12. import React, { useState, useEffect, useRef, useMemo } from 'react';
  13. import { SafeAreaView } from 'react-native-safe-area-context';
  14. import MapView, { Marker, Region } from 'react-native-maps';
  15. import * as Location from 'expo-location';
  16. import { router, useLocalSearchParams } from 'expo-router';
  17. import { ArrowIconSvg, CheckMarkLogoSvg } from '../global/SVG';
  18. import NormalInput from '../global/normal_input';
  19. import BottomSheet, { BottomSheetScrollView } from '@gorhom/bottom-sheet';
  20. import { chargeStationService } from '../../service/chargeStationService';
  21. import { calculateDistance } from '../global/distanceCalculator';
  22. interface TabItem {
  23. imgURL: ImageSourcePropType;
  24. date: string;
  25. time: string;
  26. chargeStationName: string;
  27. chargeStationAddress: string;
  28. distance: string;
  29. stationID: string;
  30. lat: number;
  31. lng: number;
  32. }
  33. // **************************************
  34. // TODO: PUT THE GOOGLE MAP API KEY in ENV
  35. const GOOGLE_API_KEY = 'AIzaSyDYVSNuXFDNOhZAKfqeSwBTc8Pa7hKma1A';
  36. // ***************************************
  37. const SearchResultComponent = () => {
  38. const [region, setRegion] = useState<Region | undefined>(undefined);
  39. const [errorMsg, setErrorMsg] = useState<string | null>(null);
  40. const [searchInput, setSearchInput] = useState<string>('');
  41. const sheetRef = useRef<BottomSheet>(null);
  42. const snapPoints = useMemo(() => ['25%', '65%'], []);
  43. const mapRef = useRef<MapView>(null);
  44. const params = useLocalSearchParams();
  45. const [filteredItems, setFilteredItems] = useState<TabItem[]>([]);
  46. const [isLoading, setIsLoading] = useState(true);
  47. useEffect(() => {
  48. if (params.latitude && params.longitude) {
  49. setRegion({
  50. latitude: parseFloat(params.latitude as string),
  51. longitude: parseFloat(params.longitude as string),
  52. latitudeDelta: 0.01,
  53. longitudeDelta: 0.01
  54. });
  55. } else {
  56. (async () => {
  57. let { status } = await Location.requestForegroundPermissionsAsync();
  58. if (status !== 'granted') {
  59. setErrorMsg('Permission to access location was denied');
  60. return;
  61. }
  62. let myLocation = await Location.getLastKnownPositionAsync({});
  63. if (myLocation) {
  64. setRegion({
  65. latitude: myLocation.coords.latitude,
  66. longitude: myLocation.coords.longitude,
  67. latitudeDelta: 0.01,
  68. longitudeDelta: 0.01
  69. });
  70. }
  71. })();
  72. }
  73. }, []);
  74. useEffect(() => {
  75. if (mapRef.current && region) {
  76. mapRef.current.animateToRegion(region, 1000);
  77. }
  78. }, [region]);
  79. if (errorMsg) {
  80. return (
  81. <View className="flex-1 justify-center items-center ">
  82. <Text className="text-red-500">{errorMsg}</Text>
  83. </View>
  84. );
  85. }
  86. const handleRegionChange = (newRegion: Region) => {
  87. if (mapRef.current) {
  88. mapRef.current.animateToRegion(newRegion, 1000);
  89. }
  90. setRegion(newRegion);
  91. sheetRef.current?.snapToIndex(0);
  92. };
  93. // ************************************************************************************************
  94. const [currentLocation, setCurrentLocation] = useState<Location.LocationObject | null>(null);
  95. const [stations, setStations] = useState([]);
  96. const [tabItems, setTabItems] = useState<TabItem[]>([]);
  97. const getCurrentLocation = async () => {
  98. let { status } = await Location.requestForegroundPermissionsAsync();
  99. if (status !== 'granted') {
  100. console.error('Permission to access location was denied');
  101. return;
  102. }
  103. let location = await Location.getLastKnownPositionAsync({});
  104. setCurrentLocation(location);
  105. };
  106. useEffect(() => {
  107. getCurrentLocation();
  108. }, []);
  109. const fetchStations = async () => {
  110. setIsLoading(true);
  111. const fetchedStations = await chargeStationService.fetchChargeStations();
  112. setStations(fetchedStations);
  113. if (currentLocation) {
  114. const TabItems = await Promise.all(
  115. fetchedStations.map(async (station) => {
  116. const distance = await calculateDistance(
  117. Number(station.StationLat),
  118. Number(station.StationLng),
  119. currentLocation
  120. );
  121. return {
  122. chargeStationAddress: station.Address,
  123. chargeStationName: station.StationName,
  124. lng: station.StationLng,
  125. lat: station.StationLat,
  126. date: '今天',
  127. stationID: station.StationID,
  128. imgURL: require('../../assets/dummyStationPicture.png'),
  129. distance: distance !== null ? formatDistance(distance) : 'N/A'
  130. };
  131. })
  132. );
  133. setTabItems(TabItems);
  134. setIsLoading(false);
  135. }
  136. };
  137. useEffect(() => {
  138. if (currentLocation) {
  139. fetchStations();
  140. }
  141. }, [currentLocation]);
  142. const formatDistance = (distanceInMeters: number): string => {
  143. if (distanceInMeters < 1000) {
  144. return `${Math.round(distanceInMeters)}米`;
  145. } else {
  146. const distanceInKm = distanceInMeters / 1000;
  147. return `${distanceInKm.toFixed(1)}公里`;
  148. }
  149. };
  150. // ************************************************************************************************************************************************
  151. return (
  152. <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
  153. <SafeAreaView className="flex-1" edges={['top', 'left', 'right']}>
  154. <View className="flex-1 relative">
  155. <View
  156. style={{
  157. position: 'absolute',
  158. top: 10,
  159. left: 10,
  160. right: 10,
  161. zIndex: 1,
  162. backgroundColor: 'transparent',
  163. alignItems: 'center'
  164. }}
  165. >
  166. <View className=" flex-1 flex-row bg-white rounded-xl">
  167. <Pressable
  168. style={styles.leftArrowBackButton}
  169. onPress={() => {
  170. if (router.canGoBack()) {
  171. router.back();
  172. } else {
  173. router.replace('/(auth)/(tabs)/(home)');
  174. }
  175. }}
  176. >
  177. <ArrowIconSvg />
  178. </Pressable>
  179. <NormalInput
  180. placeholder="搜尋這裡"
  181. onChangeText={(text) => {
  182. setSearchInput(text);
  183. console.log(text);
  184. }}
  185. extendedStyle={styles.textInput}
  186. />
  187. </View>
  188. {filteredItems.length > 0 && (
  189. <View style={styles.dropdown}>
  190. <View>
  191. {filteredItems.map((item, index) => (
  192. <Pressable
  193. key={index}
  194. onPress={() => {
  195. setSearchInput(item.chargeStationName);
  196. setFilteredItems([]);
  197. handleRegionChange({
  198. latitude: item.lat,
  199. longitude: item.lng,
  200. latitudeDelta: 0.01,
  201. longitudeDelta: 0.01
  202. });
  203. }}
  204. style={({ pressed }) => [
  205. styles.dropdownItem,
  206. pressed && styles.dropdownItemPress
  207. ]}
  208. >
  209. <Text>{item.chargeStationName}</Text>
  210. </Pressable>
  211. ))}
  212. </View>
  213. </View>
  214. )}
  215. </View>
  216. <MapView
  217. ref={mapRef}
  218. style={styles.map}
  219. region={region}
  220. cameraZoomRange={{
  221. minCenterCoordinateDistance: 500,
  222. maxCenterCoordinateDistance: 90000,
  223. animated: true
  224. }}
  225. showsUserLocation={true}
  226. showsMyLocationButton={false}
  227. >
  228. {tabItems.map((item, index) => (
  229. <Marker
  230. key={index}
  231. coordinate={{
  232. latitude: item.lat,
  233. longitude: item.lng
  234. }}
  235. title={item.chargeStationName}
  236. description={item.chargeStationAddress}
  237. />
  238. ))}
  239. </MapView>
  240. <BottomSheet ref={sheetRef} index={0} snapPoints={snapPoints}>
  241. <BottomSheetScrollView contentContainerStyle={styles.contentContainer}>
  242. <View className="flex-1 mx-[5%]">
  243. {isLoading ? (
  244. <View className="pt-14">
  245. <ActivityIndicator color="#34657b" />
  246. </View>
  247. ) : (
  248. tabItems
  249. .filter((item) => item.chargeStationName.includes(searchInput.toUpperCase()))
  250. .map((item, index) => {
  251. return (
  252. <Pressable
  253. key={index}
  254. onPress={() => {
  255. handleRegionChange({
  256. latitude: item.latitude,
  257. longitude: item.longitude,
  258. latitudeDelta: 0.01,
  259. longitudeDelta: 0.01
  260. });
  261. router.push({
  262. pathname: '/resultDetailPage',
  263. params: {
  264. chargeStationAddress: item.chargeStationAddress,
  265. chargeStationID: item.stationID,
  266. chargeStationName: item.chargeStationName,
  267. chargeStationLat: item.lat,
  268. chargeStationLng: item.lng
  269. }
  270. });
  271. }}
  272. style={({ pressed }) => [
  273. styles.container,
  274. {
  275. backgroundColor: pressed ? '#e7f2f8' : '#ffffff'
  276. }
  277. ]}
  278. >
  279. <View style={styles.rowContainer}>
  280. <Image style={styles.image} source={item.imgURL} />
  281. <View style={styles.textContainer}>
  282. <Text
  283. style={{
  284. fontWeight: '400',
  285. fontSize: 18,
  286. color: '#222222'
  287. }}
  288. >
  289. {item.chargeStationName}
  290. </Text>
  291. <Text
  292. style={{
  293. fontWeight: '400',
  294. fontSize: 14,
  295. color: '#888888'
  296. }}
  297. >
  298. {item.chargeStationAddress}
  299. </Text>
  300. <View className="flex-row space-x-2 items-center">
  301. <CheckMarkLogoSvg />
  302. <Text
  303. style={{
  304. fontWeight: '400',
  305. fontSize: 14,
  306. color: '#222222'
  307. }}
  308. >
  309. Walk-in
  310. </Text>
  311. </View>
  312. </View>
  313. <Text
  314. style={{
  315. fontWeight: '400',
  316. fontSize: 16,
  317. color: '#888888',
  318. marginTop: 22
  319. }}
  320. className="flex-1 text-right"
  321. >
  322. {item.distance}
  323. </Text>
  324. </View>
  325. </Pressable>
  326. );
  327. })
  328. )}
  329. </View>
  330. </BottomSheetScrollView>
  331. </BottomSheet>
  332. </View>
  333. </SafeAreaView>
  334. </TouchableWithoutFeedback>
  335. );
  336. };
  337. export default SearchResultComponent;
  338. const styles = StyleSheet.create({
  339. container: {
  340. flex: 1
  341. },
  342. map: {
  343. flex: 1,
  344. width: '100%',
  345. height: '100%'
  346. },
  347. contentContainer: {
  348. backgroundColor: 'white'
  349. },
  350. itemContainer: {
  351. padding: 6,
  352. margin: 6,
  353. backgroundColor: '#eee'
  354. },
  355. image: {
  356. width: 100,
  357. height: 100,
  358. marginTop: 15,
  359. marginRight: 15,
  360. borderRadius: 10
  361. },
  362. textContainer: { flexDirection: 'column', gap: 8, marginTop: 22 },
  363. rowContainer: { flexDirection: 'row' },
  364. textInput: {
  365. width: '85%',
  366. maxWidth: '100%',
  367. fontSize: 16,
  368. padding: 20,
  369. paddingLeft: 0,
  370. borderLeftWidth: 0,
  371. borderTopWidth: 1,
  372. borderBottomWidth: 1,
  373. borderRightWidth: 1,
  374. borderBottomRightRadius: 12,
  375. borderTopRightRadius: 12,
  376. borderRadius: 0,
  377. borderColor: '#bbbbbb'
  378. },
  379. leftArrowBackButton: {
  380. width: '15%',
  381. maxWidth: '100%',
  382. fontSize: 16,
  383. padding: 20,
  384. paddingLeft: 30,
  385. borderBottomLeftRadius: 12,
  386. borderTopLeftRadius: 12,
  387. borderColor: '#bbbbbb',
  388. borderTopWidth: 1,
  389. borderBottomWidth: 1,
  390. borderLeftWidth: 1,
  391. alignItems: 'center',
  392. justifyContent: 'center'
  393. },
  394. dropdown: {
  395. backgroundColor: 'white',
  396. borderBottomLeftRadius: 12,
  397. borderBottomRightRadius: 12,
  398. borderLeftWidth: 1,
  399. borderRightWidth: 1,
  400. borderBottomWidth: 1,
  401. marginTop: 10,
  402. maxHeight: 200,
  403. width: '100%',
  404. position: 'absolute',
  405. top: 50,
  406. zIndex: 2,
  407. borderColor: '#bbbbbb'
  408. },
  409. dropdownItem: {
  410. padding: 10,
  411. borderBottomWidth: 1,
  412. borderBottomColor: '#ddd'
  413. },
  414. dropdownItemPress: {
  415. backgroundColor: '#e8f8fc'
  416. }
  417. });