index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import React, { useEffect, useRef, useState } from 'react';
  2. import {
  3. View,
  4. StyleSheet,
  5. StatusBar,
  6. TouchableOpacity,
  7. ActivityIndicator,
  8. Platform,
  9. Linking
  10. } from 'react-native';
  11. import * as MapLibreRN from '@maplibre/maplibre-react-native';
  12. import * as Location from 'expo-location';
  13. import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
  14. import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
  15. import { useNavigation } from '@react-navigation/native';
  16. import _ from 'lodash';
  17. import { VECTOR_MAP_HOST } from 'src/constants';
  18. import { WarningModal } from 'src/components';
  19. import { Colors } from 'src/theme';
  20. import ScaleBar from 'src/components/ScaleBar';
  21. import ChevronLeft from 'assets/icons/chevron-left.svg';
  22. import LocationIcon from 'assets/icons/location.svg';
  23. import MapSvg from 'assets/icons/travels-screens/map-location.svg';
  24. const FullMapScreen = ({ route }: { route: any }) => {
  25. const { lat, lng } = route.params;
  26. const tabBarHeight = useBottomTabBarHeight();
  27. const insets = useSafeAreaInsets();
  28. const navigation = useNavigation();
  29. const mapRef = useRef<MapLibreRN.MapViewRef>(null);
  30. const cameraRef = useRef<MapLibreRN.CameraRef>(null);
  31. const [renderCamera, setRenderCamera] = useState(Platform.OS === 'ios');
  32. const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  33. const isAnimatingRef = useRef(false);
  34. const [isLocationLoading, setIsLocationLoading] = useState(false);
  35. const [location, setLocation] = useState<any | null>(null);
  36. const [zoom, setZoom] = useState(0);
  37. const [center, setCenter] = useState<number[] | null>(null);
  38. const [isZooming, setIsZooming] = useState(true);
  39. const [askLocationVisible, setAskLocationVisible] = useState<boolean>(false);
  40. const [openSettingsVisible, setOpenSettingsVisible] = useState<boolean>(false);
  41. const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  42. const cameraController = {
  43. setCamera: (config: any) => {
  44. isAnimatingRef.current = true;
  45. if (animationTimeoutRef.current) {
  46. clearTimeout(animationTimeoutRef.current);
  47. }
  48. if (Platform.OS === 'android') {
  49. setRenderCamera(true);
  50. requestAnimationFrame(() => {
  51. cameraRef.current?.setCamera(config);
  52. });
  53. animationTimeoutRef.current = setTimeout(
  54. () => {
  55. isAnimatingRef.current = false;
  56. setRenderCamera(false);
  57. },
  58. (config.animationDuration || 1000) + 200
  59. );
  60. } else {
  61. cameraRef.current?.setCamera(config);
  62. animationTimeoutRef.current = setTimeout(
  63. () => {
  64. isAnimatingRef.current = false;
  65. },
  66. (config.animationDuration || 1000) + 100
  67. );
  68. }
  69. },
  70. flyTo: (coordinates: number[], duration: number = 1000) => {
  71. isAnimatingRef.current = true;
  72. if (animationTimeoutRef.current) {
  73. clearTimeout(animationTimeoutRef.current);
  74. }
  75. if (Platform.OS === 'android') {
  76. setRenderCamera(true);
  77. requestAnimationFrame(() => {
  78. cameraRef.current?.flyTo(coordinates, duration);
  79. });
  80. animationTimeoutRef.current = setTimeout(() => {
  81. isAnimatingRef.current = false;
  82. setRenderCamera(false);
  83. }, duration + 200);
  84. } else {
  85. cameraRef.current?.flyTo(coordinates, duration);
  86. animationTimeoutRef.current = setTimeout(() => {
  87. isAnimatingRef.current = false;
  88. }, duration + 100);
  89. }
  90. }
  91. };
  92. useEffect(() => {
  93. (async () => {
  94. let { status } = await Location.getForegroundPermissionsAsync();
  95. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  96. if (status !== 'granted' || !isServicesEnabled) {
  97. return;
  98. }
  99. try {
  100. let currentLocation = await Location.getCurrentPositionAsync({
  101. accuracy: Location.Accuracy.Balanced
  102. });
  103. setLocation(currentLocation.coords);
  104. } catch (error) {
  105. console.error('Error fetching user location:', error);
  106. }
  107. })();
  108. }, []);
  109. useEffect(() => {
  110. return () => {
  111. if (animationTimeoutRef.current) {
  112. clearTimeout(animationTimeoutRef.current);
  113. }
  114. };
  115. }, []);
  116. const handleMapChange = async () => {
  117. if (!mapRef.current) return;
  118. if (hideTimer.current) clearTimeout(hideTimer.current);
  119. setIsZooming(true);
  120. const currentZoom = await mapRef.current.getZoom();
  121. setZoom(currentZoom);
  122. if (mapRef.current) {
  123. const currentCenter = await mapRef.current?.getCenter();
  124. setCenter(currentCenter);
  125. }
  126. };
  127. const handleGetLocation = async () => {
  128. setIsLocationLoading(true);
  129. try {
  130. let { status, canAskAgain } = await Location.getForegroundPermissionsAsync();
  131. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  132. if (status === 'granted' && isServicesEnabled) {
  133. await getLocation();
  134. } else if (!canAskAgain || !isServicesEnabled) {
  135. setOpenSettingsVisible(true);
  136. } else {
  137. setAskLocationVisible(true);
  138. }
  139. } finally {
  140. setIsLocationLoading(false);
  141. }
  142. };
  143. const getLocation = async () => {
  144. try {
  145. let currentLocation = await Location.getCurrentPositionAsync({
  146. accuracy: Location.Accuracy.Balanced
  147. });
  148. setLocation(currentLocation.coords);
  149. if (currentLocation.coords) {
  150. cameraController?.flyTo(
  151. [currentLocation.coords.longitude, currentLocation.coords.latitude],
  152. 1000
  153. );
  154. }
  155. } catch (error) {
  156. console.error('Error fetching user location:', error);
  157. }
  158. };
  159. const handleAcceptPermission = async () => {
  160. setAskLocationVisible(false);
  161. let { status, canAskAgain } = await Location.requestForegroundPermissionsAsync();
  162. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  163. if (status === 'granted' && isServicesEnabled) {
  164. getLocation();
  165. } else if (!canAskAgain || !isServicesEnabled) {
  166. setOpenSettingsVisible(true);
  167. }
  168. };
  169. const openInExternalMaps = async (latitude: number, longitude: number) => {
  170. const appleMapsURL = `http://maps.apple.com/?q=${latitude},${longitude}`;
  171. const defaultGeoURL = `geo:${latitude},${longitude}?q=${latitude},${longitude}`;
  172. if (Platform.OS === 'ios') {
  173. await Linking.openURL(appleMapsURL);
  174. } else {
  175. await Linking.openURL(defaultGeoURL);
  176. }
  177. };
  178. return (
  179. <SafeAreaView style={{ height: '100%' }}>
  180. <StatusBar translucent backgroundColor="transparent" />
  181. <MapLibreRN.MapView
  182. ref={mapRef}
  183. style={styles.map}
  184. mapStyle={VECTOR_MAP_HOST + '/nomadmania-maps2025.json'}
  185. rotateEnabled={false}
  186. attributionEnabled={false}
  187. onRegionDidChange={() => {
  188. hideTimer.current = setTimeout(() => {
  189. setIsZooming(false);
  190. }, 2000);
  191. }}
  192. // onRegionIsChanging={handleMapChange}
  193. onRegionWillChange={_.debounce(handleMapChange, 200)}
  194. >
  195. {(Platform.OS === 'ios' || renderCamera) && (
  196. <MapLibreRN.Camera
  197. ref={cameraRef}
  198. defaultSettings={{ centerCoordinate: [lng, lat], zoomLevel: 12 }}
  199. />
  200. )}
  201. <MapLibreRN.MarkerView coordinate={[lng, lat]}>
  202. <View style={styles.marker} />
  203. </MapLibreRN.MarkerView>
  204. {location && (
  205. <MapLibreRN.UserLocation
  206. animated={true}
  207. showsUserHeadingIndicator={true}
  208. onPress={async () => {
  209. const currentZoom = await mapRef.current?.getZoom();
  210. const newZoom = (currentZoom || 0) + 2;
  211. cameraController.setCamera({
  212. centerCoordinate: [location.longitude, location.latitude],
  213. zoomLevel: newZoom,
  214. animationDuration: 500,
  215. animationMode: 'flyTo'
  216. });
  217. }}
  218. ></MapLibreRN.UserLocation>
  219. )}
  220. </MapLibreRN.MapView>
  221. <TouchableOpacity
  222. onPress={() => {
  223. navigation.goBack();
  224. }}
  225. style={[
  226. styles.backButtonContainer,
  227. { top: Platform.OS === 'android' ? insets.top + 20 : insets.top }
  228. ]}
  229. >
  230. <View style={styles.backButton}>
  231. <ChevronLeft fill={Colors.WHITE} />
  232. </View>
  233. </TouchableOpacity>
  234. <TouchableOpacity
  235. onPress={handleGetLocation}
  236. style={[
  237. styles.cornerButton,
  238. styles.bottomButton,
  239. styles.bottomRightButton,
  240. { bottom: tabBarHeight + insets.bottom + 20 }
  241. ]}
  242. >
  243. {isLocationLoading ? (
  244. <ActivityIndicator size="small" color={Colors.DARK_BLUE} />
  245. ) : (
  246. <LocationIcon />
  247. )}
  248. </TouchableOpacity>
  249. <TouchableOpacity
  250. onPress={() => openInExternalMaps(lat, lng)}
  251. style={[
  252. styles.cornerButton,
  253. styles.bottomButton,
  254. styles.topRightButton,
  255. { top: Platform.OS === 'android' ? insets.top + 24 : insets.top + 4 }
  256. ]}
  257. >
  258. <MapSvg fill={Colors.DARK_BLUE} />
  259. </TouchableOpacity>
  260. {center ? (
  261. <ScaleBar
  262. zoom={zoom}
  263. latitude={center[1]}
  264. isVisible={isZooming}
  265. bottom={tabBarHeight + insets.bottom + 38}
  266. />
  267. ) : null}
  268. <WarningModal
  269. type={'success'}
  270. isVisible={askLocationVisible}
  271. onClose={() => setAskLocationVisible(false)}
  272. action={handleAcceptPermission}
  273. 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."
  274. />
  275. <WarningModal
  276. type={'success'}
  277. isVisible={openSettingsVisible}
  278. onClose={() => setOpenSettingsVisible(false)}
  279. action={async () => {
  280. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  281. if (!isServicesEnabled) {
  282. Platform.OS === 'ios'
  283. ? Linking.openURL('app-settings:')
  284. : Linking.sendIntent('android.settings.LOCATION_SOURCE_SETTINGS');
  285. } else {
  286. Platform.OS === 'ios' ? Linking.openURL('app-settings:') : Linking.openSettings();
  287. }
  288. }}
  289. message="NomadMania app needs location permissions to function properly. Open settings?"
  290. />
  291. </SafeAreaView>
  292. );
  293. };
  294. const styles = StyleSheet.create({
  295. map: {
  296. ...StyleSheet.absoluteFillObject
  297. },
  298. backButtonContainer: {
  299. position: 'absolute',
  300. width: 50,
  301. height: 50,
  302. top: 50,
  303. left: 12,
  304. justifyContent: 'center',
  305. alignItems: 'center',
  306. zIndex: 2
  307. },
  308. backButton: {
  309. width: 42,
  310. height: 42,
  311. borderRadius: 21,
  312. justifyContent: 'center',
  313. alignItems: 'center',
  314. backgroundColor: 'rgba(0, 0, 0, 0.3)'
  315. },
  316. marker: {
  317. width: 20,
  318. height: 20,
  319. borderRadius: 10,
  320. backgroundColor: Colors.ORANGE,
  321. borderWidth: 2,
  322. borderColor: Colors.WHITE
  323. },
  324. cornerButton: {
  325. position: 'absolute',
  326. backgroundColor: Colors.WHITE,
  327. padding: 12,
  328. width: 48,
  329. height: 48,
  330. borderRadius: 24,
  331. alignItems: 'center',
  332. justifyContent: 'center',
  333. shadowColor: '#000',
  334. shadowOffset: {
  335. width: 0,
  336. height: 1
  337. },
  338. shadowOpacity: 0.25,
  339. shadowRadius: 1.5,
  340. elevation: 2
  341. },
  342. bottomButton: {
  343. width: 42,
  344. height: 42,
  345. borderRadius: 21
  346. },
  347. bottomRightButton: {
  348. right: 16
  349. },
  350. topRightButton: {
  351. top: 54,
  352. right: 16
  353. }
  354. });
  355. export default FullMapScreen;