index.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import React, { useCallback, useEffect, useRef, useState } from 'react';
  2. import { View, Text, TouchableOpacity, ActivityIndicator, Platform, Linking } from 'react-native';
  3. import { SafeAreaView } from 'react-native-safe-area-context';
  4. import { useNavigation } from '@react-navigation/native';
  5. import * as turf from '@turf/turf';
  6. import * as MapLibreRN from '@maplibre/maplibre-react-native';
  7. import * as Location from 'expo-location';
  8. import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
  9. import { Header, Modal, FlatList as List, WarningModal } from 'src/components';
  10. import { VECTOR_MAP_HOST } from 'src/constants';
  11. import { Colors } from 'src/theme';
  12. import { NAVIGATION_PAGES } from 'src/types';
  13. import { RegionAddData } from '../utils/types';
  14. import { useGetRegionsForTripsQuery } from '@api/trips';
  15. import { useGetListRegionsQuery } from '@api/regions';
  16. import { styles } from './styles';
  17. import SearchSvg from '../../../../../assets/icons/search.svg';
  18. import SaveSvg from '../../../../../assets/icons/travels-screens/save.svg';
  19. import LocationIcon from 'assets/icons/location.svg';
  20. const generateFilter = (ids: number[]) => {
  21. return ids?.length ? ['any', ...ids.map((id) => ['==', 'id', id])] : ['==', 'id', -1];
  22. };
  23. let nm_regions = {
  24. id: 'regions',
  25. type: 'fill',
  26. source: 'regions',
  27. 'source-layer': 'regions',
  28. style: {
  29. fillColor: 'rgba(15, 63, 79, 0)'
  30. },
  31. filter: ['all'],
  32. maxzoom: 16
  33. };
  34. let selected_region = {
  35. id: 'selected_region',
  36. type: 'fill',
  37. source: 'regions',
  38. 'source-layer': 'regions',
  39. style: {
  40. fillColor: 'rgba(237, 147, 52, 0.7)'
  41. },
  42. maxzoom: 12
  43. };
  44. const AddRegionsScreen = ({ route }: { route: any }) => {
  45. const { regionsParams }: { regionsParams: RegionAddData[] } = route.params;
  46. const { data } = useGetRegionsForTripsQuery(true);
  47. const { data: regionsList } = useGetListRegionsQuery(true);
  48. const navigation = useNavigation();
  49. const tabBarHeight = useBottomTabBarHeight();
  50. const [regions, setRegions] = useState<RegionAddData[] | null>(null);
  51. const [isModalVisible, setIsModalVisible] = useState(false);
  52. const [selectedRegions, setSelectedRegions] = useState<any[]>([]);
  53. const [regionsToSave, setRegionsToSave] = useState<RegionAddData[]>([]);
  54. const [regionData, setRegionData] = useState<RegionAddData | null>(null);
  55. const [regionPopupVisible, setRegionPopupVisible] = useState(false);
  56. const mapRef = useRef<MapLibreRN.MapViewRef>(null);
  57. const cameraRef = useRef<MapLibreRN.CameraRef>(null);
  58. const [renderCamera, setRenderCamera] = useState(Platform.OS === 'ios');
  59. const isAnimatingRef = useRef(false);
  60. const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  61. const [filterSelectedRegions, setFilterSelectedRegions] = useState<any[]>(generateFilter([]));
  62. const [isLocationLoading, setIsLocationLoading] = useState(false);
  63. const [location, setLocation] = useState<any | null>(null);
  64. const [askLocationVisible, setAskLocationVisible] = useState<boolean>(false);
  65. const [openSettingsVisible, setOpenSettingsVisible] = useState<boolean>(false);
  66. const cameraController = {
  67. flyTo: useCallback((coordinates: number[], duration: number = 1000) => {
  68. isAnimatingRef.current = true;
  69. if (animationTimeoutRef.current) {
  70. clearTimeout(animationTimeoutRef.current);
  71. }
  72. if (Platform.OS === 'android') {
  73. setRenderCamera(true);
  74. requestAnimationFrame(() => {
  75. cameraRef.current?.flyTo(coordinates, duration);
  76. });
  77. animationTimeoutRef.current = setTimeout(() => {
  78. isAnimatingRef.current = false;
  79. setRenderCamera(false);
  80. }, duration + 200);
  81. } else {
  82. cameraRef.current?.flyTo(coordinates, duration);
  83. animationTimeoutRef.current = setTimeout(() => {
  84. isAnimatingRef.current = false;
  85. }, duration + 100);
  86. }
  87. }, []),
  88. setCamera: useCallback((config: any) => {
  89. isAnimatingRef.current = true;
  90. if (animationTimeoutRef.current) {
  91. clearTimeout(animationTimeoutRef.current);
  92. }
  93. if (Platform.OS === 'android') {
  94. setRenderCamera(true);
  95. requestAnimationFrame(() => {
  96. cameraRef.current?.setCamera(config);
  97. });
  98. animationTimeoutRef.current = setTimeout(
  99. () => {
  100. isAnimatingRef.current = false;
  101. setRenderCamera(false);
  102. },
  103. (config.animationDuration ?? 1000) + 200
  104. );
  105. } else {
  106. cameraRef.current?.setCamera(config);
  107. animationTimeoutRef.current = setTimeout(
  108. () => {
  109. isAnimatingRef.current = false;
  110. },
  111. (config.animationDuration ?? 1000) + 100
  112. );
  113. }
  114. }, []),
  115. fitBounds: useCallback((ne: number[], sw: number[], padding: number[], duration: number) => {
  116. isAnimatingRef.current = true;
  117. if (animationTimeoutRef.current) {
  118. clearTimeout(animationTimeoutRef.current);
  119. }
  120. if (Platform.OS === 'android') {
  121. setRenderCamera(true);
  122. requestAnimationFrame(() => {
  123. cameraRef.current?.fitBounds(ne, sw, padding, duration);
  124. });
  125. animationTimeoutRef.current = setTimeout(() => {
  126. isAnimatingRef.current = false;
  127. setRenderCamera(false);
  128. }, duration + 200);
  129. } else {
  130. cameraRef.current?.fitBounds(ne, sw, padding, duration);
  131. animationTimeoutRef.current = setTimeout(() => {
  132. isAnimatingRef.current = false;
  133. }, duration + 100);
  134. }
  135. }, [])
  136. };
  137. useEffect(() => {
  138. if (data && data.regions) {
  139. setRegions(data.regions);
  140. }
  141. }, [data]);
  142. useEffect(() => {
  143. const ids = selectedRegions.map((region) => region.id);
  144. setFilterSelectedRegions(generateFilter(ids));
  145. }, [selectedRegions]);
  146. useEffect(() => {
  147. const addRegionsAsync = async () => {
  148. if (regionsParams) {
  149. setRegionsToSave((prevRegions) => [...prevRegions, ...regionsParams]);
  150. setSelectedRegions(
  151. (prevSelectedRegions) => [...prevSelectedRegions, ...regionsParams] as any
  152. );
  153. }
  154. };
  155. addRegionsAsync();
  156. }, [regionsParams]);
  157. useEffect(() => {
  158. return () => {
  159. if (animationTimeoutRef.current) {
  160. clearTimeout(animationTimeoutRef.current);
  161. }
  162. };
  163. }, []);
  164. const addRegionFromSearch = async (searchRegion: RegionAddData) => {
  165. const regionIndex = selectedRegions.findIndex((region) => region.id === searchRegion.id);
  166. const regionFromApi = regions?.find((region) => region.id === searchRegion.id);
  167. if (regionIndex < 0 && regionFromApi) {
  168. const newRegion = {
  169. id: searchRegion.id,
  170. name: searchRegion.name
  171. };
  172. setSelectedRegions([...selectedRegions, newRegion] as any);
  173. setRegionsToSave((prevRegions) => [...prevRegions, regionFromApi]);
  174. setRegionPopupVisible(true);
  175. if (regionsList) {
  176. const region = regionsList.data.find((region) => region.id === searchRegion.id);
  177. if (region) {
  178. const bounds = turf.bbox(region.bbox);
  179. cameraController.fitBounds(
  180. [bounds[2], bounds[3]],
  181. [bounds[0], bounds[1]],
  182. [50, 50, 50, 50],
  183. 600
  184. );
  185. }
  186. }
  187. }
  188. };
  189. const handleSavePress = () => {
  190. if (route.params?.isSharedTrip) {
  191. navigation.popTo(
  192. ...([
  193. NAVIGATION_PAGES.CREATE_SHARED_TRIP,
  194. { regionsToSave: regionsToSave, eventId: route.params?.editId }
  195. ] as never)
  196. );
  197. } else {
  198. navigation.popTo(
  199. ...([
  200. NAVIGATION_PAGES.ADD_TRIP,
  201. { regionsToSave: regionsToSave, editTripId: route.params?.editId }
  202. ] as never)
  203. );
  204. }
  205. };
  206. const handleSetRegionData = (regionId: number) => {
  207. const foundRegion = regions?.find((region) => region.id === regionId);
  208. if (foundRegion) {
  209. setRegionData(foundRegion);
  210. setRegionsToSave((prevRegions) => [...prevRegions, foundRegion]);
  211. }
  212. };
  213. const handleMapPress = useCallback(
  214. async (event: any) => {
  215. if (!mapRef.current) return;
  216. try {
  217. const { screenPointX, screenPointY } = event.properties;
  218. const { features } = await mapRef.current.queryRenderedFeaturesAtPoint(
  219. [screenPointX, screenPointY],
  220. undefined,
  221. ['regions']
  222. );
  223. if (features?.length) {
  224. const selectedRegion = features[0];
  225. if (selectedRegion.properties) {
  226. const id = selectedRegion.properties.id;
  227. const regionIndex = selectedRegions.findIndex((region) => region.id === id);
  228. if (regionIndex >= 0) {
  229. let newSelectedRegions = [...selectedRegions];
  230. newSelectedRegions = newSelectedRegions.filter((region) => region.id !== id);
  231. setSelectedRegions(newSelectedRegions);
  232. setRegionsToSave(regionsToSave.filter((region) => region.id !== id));
  233. setRegionPopupVisible(false);
  234. return;
  235. } else {
  236. setSelectedRegions([...selectedRegions, selectedRegion.properties] as any);
  237. }
  238. handleSetRegionData(id);
  239. setRegionPopupVisible(true);
  240. if (regionsList) {
  241. const region = regionsList.data.find((region) => region.id === id);
  242. if (region) {
  243. const bounds = turf.bbox(region.bbox);
  244. cameraController.fitBounds(
  245. [bounds[2], bounds[3]],
  246. [bounds[0], bounds[1]],
  247. [50, 50, 50, 50],
  248. 600
  249. );
  250. }
  251. }
  252. }
  253. }
  254. } catch (error) {
  255. console.error('Failed to get coordinates on AddRegionsScreen', error);
  256. }
  257. },
  258. [selectedRegions, regions]
  259. );
  260. const handleGetLocation = async () => {
  261. setIsLocationLoading(true);
  262. try {
  263. let { status, canAskAgain } = await Location.getForegroundPermissionsAsync();
  264. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  265. if (status === 'granted' && isServicesEnabled) {
  266. await getLocation();
  267. } else if (!canAskAgain || !isServicesEnabled) {
  268. setOpenSettingsVisible(true);
  269. } else {
  270. setAskLocationVisible(true);
  271. }
  272. } finally {
  273. setIsLocationLoading(false);
  274. }
  275. };
  276. const getLocation = async () => {
  277. try {
  278. let currentLocation = await Location.getCurrentPositionAsync({
  279. accuracy: Location.Accuracy.Balanced
  280. });
  281. setLocation(currentLocation.coords);
  282. if (currentLocation.coords) {
  283. cameraController.flyTo(
  284. [currentLocation.coords.longitude, currentLocation.coords.latitude],
  285. 1000
  286. );
  287. }
  288. } catch (error) {
  289. console.error('Error fetching user location:', error);
  290. }
  291. };
  292. const handleAcceptPermission = async () => {
  293. setAskLocationVisible(false);
  294. let { status, canAskAgain } = await Location.requestForegroundPermissionsAsync();
  295. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  296. if (status === 'granted' && isServicesEnabled) {
  297. getLocation();
  298. } else if (!canAskAgain || !isServicesEnabled) {
  299. setOpenSettingsVisible(true);
  300. }
  301. };
  302. return (
  303. <SafeAreaView style={{ height: '100%' }} edges={['top']}>
  304. <View style={styles.wrapper}>
  305. <Header label={'Add Regions'} />
  306. <View style={styles.searchContainer}>
  307. <TouchableOpacity style={[styles.regionSelector]} onPress={() => setIsModalVisible(true)}>
  308. <SearchSvg fill={Colors.LIGHT_GRAY} />
  309. <Text style={styles.regionText}>Search</Text>
  310. </TouchableOpacity>
  311. <TouchableOpacity
  312. style={[
  313. styles.saveBtn,
  314. selectedRegions.length ? styles.saveBtnActive : styles.saveBtnDisabled
  315. ]}
  316. onPress={handleSavePress}
  317. disabled={!selectedRegions.length}
  318. >
  319. <Text
  320. style={{
  321. fontSize: 12,
  322. fontWeight: '600',
  323. color: !selectedRegions.length ? Colors.LIGHT_GRAY : Colors.WHITE
  324. }}
  325. >
  326. Save
  327. </Text>
  328. </TouchableOpacity>
  329. </View>
  330. </View>
  331. <View style={styles.container}>
  332. <MapLibreRN.MapView
  333. ref={mapRef}
  334. style={styles.map}
  335. mapStyle={VECTOR_MAP_HOST + '/nomadmania-maps2025.json'}
  336. rotateEnabled={false}
  337. attributionEnabled={false}
  338. onPress={handleMapPress}
  339. >
  340. {(Platform.OS === 'ios' || renderCamera) && <MapLibreRN.Camera ref={cameraRef} />}
  341. <MapLibreRN.LineLayer
  342. id="nm-regions-line-layer"
  343. sourceID={nm_regions.source}
  344. sourceLayerID={nm_regions['source-layer']}
  345. filter={nm_regions.filter as any}
  346. maxZoomLevel={nm_regions.maxzoom}
  347. style={{
  348. lineColor: 'rgba(14, 80, 109, 1)',
  349. lineWidth: ['interpolate', ['linear'], ['zoom'], 0, 0.2, 4, 1, 5, 1.5, 12, 3],
  350. lineWidthTransition: { duration: 300, delay: 0 }
  351. }}
  352. belowLayerID="waterway-name"
  353. />
  354. <MapLibreRN.FillLayer
  355. id={nm_regions.id}
  356. sourceID={nm_regions.source}
  357. sourceLayerID={nm_regions['source-layer']}
  358. filter={nm_regions.filter as any}
  359. style={nm_regions.style}
  360. maxZoomLevel={nm_regions.maxzoom}
  361. belowLayerID="nm-regions-line-layer"
  362. />
  363. {selectedRegions && selectedRegions.length > 0 ? (
  364. <MapLibreRN.FillLayer
  365. id={selected_region.id}
  366. sourceID={nm_regions.source}
  367. sourceLayerID={nm_regions['source-layer']}
  368. filter={filterSelectedRegions as any}
  369. style={selected_region.style}
  370. maxZoomLevel={selected_region.maxzoom}
  371. belowLayerID="nm-regions-line-layer"
  372. />
  373. ) : null}
  374. {location && (
  375. <MapLibreRN.UserLocation
  376. animated={true}
  377. showsUserHeadingIndicator={true}
  378. onPress={async () => {
  379. const currentZoom = await mapRef.current?.getZoom();
  380. const newZoom = (currentZoom || 0) + 2;
  381. cameraController.setCamera({
  382. centerCoordinate: [location.longitude, location.latitude],
  383. zoomLevel: newZoom,
  384. animationDuration: 500,
  385. animationMode: 'flyTo'
  386. });
  387. }}
  388. ></MapLibreRN.UserLocation>
  389. )}
  390. </MapLibreRN.MapView>
  391. <TouchableOpacity
  392. onPress={handleGetLocation}
  393. style={[
  394. styles.cornerButton,
  395. styles.bottomButton,
  396. styles.bottomRightButton,
  397. { bottom: 20 }
  398. ]}
  399. >
  400. {isLocationLoading ? (
  401. <ActivityIndicator size="small" color={Colors.DARK_BLUE} />
  402. ) : (
  403. <LocationIcon />
  404. )}
  405. </TouchableOpacity>
  406. </View>
  407. {regionPopupVisible && regionData && (
  408. <View style={styles.popupWrapper}>
  409. <View style={styles.popupContainer}>
  410. <Text style={styles.popupText}>{regionData.name ?? regionData.region_name}</Text>
  411. </View>
  412. </View>
  413. )}
  414. <Modal
  415. onRequestClose={() => setIsModalVisible(false)}
  416. headerTitle={'Select Regions'}
  417. visible={isModalVisible}
  418. >
  419. <List
  420. itemObject={(object) => {
  421. setIsModalVisible(false);
  422. setRegionData(object);
  423. addRegionFromSearch(object);
  424. }}
  425. />
  426. </Modal>
  427. <WarningModal
  428. type={'success'}
  429. isVisible={openSettingsVisible}
  430. onClose={() => setOpenSettingsVisible(false)}
  431. action={async () => {
  432. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  433. if (!isServicesEnabled) {
  434. Platform.OS === 'ios'
  435. ? Linking.openURL('app-settings:')
  436. : Linking.sendIntent('android.settings.LOCATION_SOURCE_SETTINGS');
  437. } else {
  438. Platform.OS === 'ios' ? Linking.openURL('app-settings:') : Linking.openSettings();
  439. }
  440. }}
  441. message="NomadMania app needs location permissions to function properly. Open settings?"
  442. />
  443. <WarningModal
  444. type={'success'}
  445. isVisible={askLocationVisible}
  446. onClose={() => setAskLocationVisible(false)}
  447. action={handleAcceptPermission}
  448. message="To use this feature we need your permission to access your location. If you press OK your system will ask you to approve location sharing with NomadMania app."
  449. />
  450. </SafeAreaView>
  451. );
  452. };
  453. export default AddRegionsScreen;