searchResultComponent.tsx 21 KB

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