searchResultComponent.tsx 19 KB

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