searchResultComponent.tsx 19 KB

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