index.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. import {
  2. Platform,
  3. TouchableOpacity,
  4. Image,
  5. Linking,
  6. TextInput,
  7. Dimensions,
  8. StatusBar,
  9. ActivityIndicator,
  10. ScrollView,
  11. View
  12. } from 'react-native';
  13. import React, { FC, useEffect, useRef, useState } from 'react';
  14. import * as Location from 'expo-location';
  15. import Animated, {
  16. Easing,
  17. useSharedValue,
  18. useAnimatedStyle,
  19. withTiming
  20. } from 'react-native-reanimated';
  21. import { styles } from './styles';
  22. import { API_HOST, VECTOR_MAP_HOST } from 'src/constants';
  23. import { CommonActions, NavigationProp } from '@react-navigation/native';
  24. import { AvatarWithInitials, LocationPopup } from 'src/components';
  25. import { Colors } from 'src/theme';
  26. import CloseSvg from 'assets/icons/close.svg';
  27. import LocationIcon from 'assets/icons/location.svg';
  28. import SearchIcon from 'assets/icons/search.svg';
  29. import FilterModal from '../../MapScreen/FilterModal';
  30. import SearchModal from '../../MapScreen/UniversalSearch';
  31. import { useGetUniversalSearch } from '@api/search';
  32. import { storage, StoreType } from 'src/storage';
  33. import { NAVIGATION_PAGES } from 'src/types';
  34. import { SafeAreaView } from 'react-native-safe-area-context';
  35. import * as MapLibreRN from '@maplibre/maplibre-react-native';
  36. import {
  37. usePostGetVisitedCountriesIdsQuery,
  38. usePostGetVisitedDareIdsQuery,
  39. usePostGetVisitedRegionsIdsQuery
  40. } from '@api/maps';
  41. import moment from 'moment';
  42. import TravelsIcon from 'assets/icons/bottom-navigation/globe-solid.svg';
  43. import MapButton from 'src/components/MapButton';
  44. import ScaleBar from 'src/components/ScaleBar';
  45. import _ from 'lodash';
  46. const defaultUserAvatar = require('assets/icon-user-share-location-solid.png');
  47. const generateFilter = (ids: number[]) => {
  48. return ids.length ? ['any', ...ids.map((id) => ['==', 'id', id])] : ['==', 'id', -1];
  49. };
  50. let regions_visited = {
  51. id: 'regions_visited',
  52. type: 'fill',
  53. source: 'regions',
  54. 'source-layer': 'regions',
  55. style: {
  56. fillColor: 'rgba(255, 126, 0, 1)',
  57. fillOpacity: 0.5,
  58. fillOutlineColor: 'rgba(14, 80, 109, 0)'
  59. },
  60. filter: generateFilter([]),
  61. maxzoom: 12
  62. };
  63. let countries_visited = {
  64. id: 'countries_visited',
  65. type: 'fill',
  66. source: 'countries',
  67. 'source-layer': 'countries',
  68. style: {
  69. fillColor: 'rgba(255, 126, 0, 1)',
  70. fillOpacity: 0.5,
  71. fillOutlineColor: 'rgba(14, 80, 109, 0)'
  72. },
  73. filter: generateFilter([]),
  74. maxzoom: 12
  75. };
  76. let dare_visited = {
  77. id: 'dare_visited',
  78. type: 'fill',
  79. source: 'dare',
  80. 'source-layer': 'dare',
  81. style: {
  82. fillColor: 'rgba(255, 126, 0, 1)',
  83. fillOpacity: 0.5,
  84. fillOutlineColor: 'rgba(255, 126, 0, 1)'
  85. },
  86. filter: generateFilter([]),
  87. maxzoom: 12
  88. };
  89. let regions = {
  90. id: 'regions',
  91. type: 'fill',
  92. source: 'regions',
  93. 'source-layer': 'regions',
  94. style: {
  95. fillColor: 'rgba(15, 63, 79, 0)',
  96. fillOutlineColor: 'rgba(14, 80, 109, 0)'
  97. },
  98. filter: ['all'],
  99. maxzoom: 16
  100. };
  101. let countries = {
  102. id: 'countries',
  103. type: 'fill',
  104. source: 'countries',
  105. 'source-layer': 'countries',
  106. style: {
  107. fillColor: 'rgba(15, 63, 79, 0)',
  108. fillOutlineColor: 'rgba(14, 80, 109, 0)'
  109. },
  110. filter: ['all'],
  111. maxzoom: 16
  112. };
  113. let dare = {
  114. id: 'dare',
  115. type: 'fill',
  116. source: 'dare',
  117. 'source-layer': 'dare',
  118. style: {
  119. fillColor: 'rgba(14, 80, 109, 0.6)',
  120. fillOutlineColor: 'rgba(14, 80, 109, 1)'
  121. },
  122. filter: ['all'],
  123. maxzoom: 16
  124. };
  125. type Props = {
  126. navigation: NavigationProp<any>;
  127. route: any;
  128. };
  129. const UsersMapScreen: FC<Props> = ({ navigation, route }) => {
  130. const token = storage.get('token', StoreType.STRING) as string;
  131. const userId = route.params?.userId;
  132. const data = route.params?.data;
  133. const [regionsVisitedFilter, setRegionsVisitedFilter] = useState(generateFilter([]));
  134. const [countriesVisitedFilter, setCountriesVisitedFilter] = useState(generateFilter([]));
  135. const [dareVisitedFilter, setDareVisitedFilter] = useState(generateFilter([]));
  136. const [regionsFilter, setRegionsFilter] = useState<any>({
  137. visitedLabel: 'by',
  138. year: moment().year()
  139. });
  140. const mapRef = useRef<MapLibreRN.MapViewRef>(null);
  141. const cameraRef = useRef<MapLibreRN.CameraRef>(null);
  142. const [isFilterVisible, setIsFilterVisible] = useState<string | null>(null);
  143. const [tilesType, setTilesType] = useState({ label: 'NM regions', value: 0 });
  144. const tilesTypes = [
  145. { label: 'NM regions', value: 0 },
  146. { label: 'UN countries', value: 1 },
  147. { label: 'DARE places', value: 2 }
  148. ];
  149. const [type, setType] = useState('regions');
  150. const [location, setLocation] = useState<Location.LocationObjectCoords | null>(null);
  151. const [askLocationVisible, setAskLocationVisible] = useState<boolean>(false);
  152. const [openSettingsVisible, setOpenSettingsVisible] = useState<boolean>(false);
  153. const [isExpanded, setIsExpanded] = useState(false);
  154. const [searchVisible, setSearchVisible] = useState(false);
  155. const [index, setIndex] = useState<number>(0);
  156. const width = useSharedValue(48);
  157. const usableWidth = Dimensions.get('window').width - 32;
  158. const [search, setSearch] = useState('');
  159. const [searchInput, setSearchInput] = useState('');
  160. const { data: searchData } = useGetUniversalSearch(search, search.length > 0);
  161. const [isLocationLoading, setIsLocationLoading] = useState(false);
  162. const [zoom, setZoom] = useState(0);
  163. const [center, setCenter] = useState<number[] | null>(null);
  164. const [isZooming, setIsZooming] = useState(true);
  165. const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  166. const { data: visitedRegionIds } = usePostGetVisitedRegionsIdsQuery(
  167. token,
  168. regionsFilter.visitedLabel,
  169. regionsFilter.year,
  170. +userId,
  171. type === 'regions' && !!userId
  172. );
  173. const { data: visitedCountryIds } = usePostGetVisitedCountriesIdsQuery(
  174. token,
  175. regionsFilter.visitedLabel,
  176. regionsFilter.year,
  177. +userId,
  178. type === 'countries' && !!userId
  179. );
  180. const { data: visitedDareIds } = usePostGetVisitedDareIdsQuery(
  181. token,
  182. +userId,
  183. type === 'dare' && !!userId
  184. );
  185. useEffect(() => {
  186. if (visitedRegionIds) {
  187. setRegionsVisitedFilter(generateFilter(visitedRegionIds.ids));
  188. } else {
  189. setRegionsVisitedFilter(['==', 'id', -1]);
  190. }
  191. }, [visitedRegionIds]);
  192. useEffect(() => {
  193. if (visitedCountryIds) {
  194. setCountriesVisitedFilter(generateFilter(visitedCountryIds.ids));
  195. } else {
  196. setCountriesVisitedFilter(['==', 'id', -1]);
  197. }
  198. }, [visitedCountryIds]);
  199. useEffect(() => {
  200. if (visitedDareIds) {
  201. setDareVisitedFilter(generateFilter(visitedDareIds.ids));
  202. } else {
  203. setDareVisitedFilter(['==', 'id', -1]);
  204. }
  205. }, [visitedDareIds]);
  206. useEffect(() => {
  207. if (
  208. data.location_sharing &&
  209. data.location_last_seen_location?.lng &&
  210. data.location_last_seen_location?.lat &&
  211. cameraRef.current
  212. ) {
  213. cameraRef.current.flyTo(
  214. [data.location_last_seen_location.lng, data.location_last_seen_location.lat],
  215. 1000
  216. );
  217. }
  218. }, [data, cameraRef.current]);
  219. const handleMapChange = async () => {
  220. if (!mapRef.current) return;
  221. if (hideTimer.current) clearTimeout(hideTimer.current);
  222. setIsZooming(true);
  223. const currentZoom = await mapRef.current.getZoom();
  224. const currentCenter = await mapRef.current.getCenter();
  225. setZoom(currentZoom);
  226. setCenter(currentCenter);
  227. };
  228. const handleGetLocation = async () => {
  229. setIsLocationLoading(true);
  230. try {
  231. let { status, canAskAgain } = await Location.getForegroundPermissionsAsync();
  232. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  233. if (status === 'granted' && isServicesEnabled) {
  234. await getLocation();
  235. } else if (!canAskAgain || !isServicesEnabled) {
  236. setOpenSettingsVisible(true);
  237. } else {
  238. setAskLocationVisible(true);
  239. }
  240. } finally {
  241. setIsLocationLoading(false);
  242. }
  243. };
  244. const getLocation = async () => {
  245. let currentLocation = await Location.getCurrentPositionAsync({
  246. accuracy: Location.Accuracy.Balanced
  247. });
  248. setLocation(currentLocation.coords);
  249. if (currentLocation.coords) {
  250. cameraRef.current?.flyTo(
  251. [currentLocation.coords.longitude, currentLocation.coords.latitude],
  252. 1000
  253. );
  254. }
  255. };
  256. const handleAcceptPermission = async () => {
  257. setAskLocationVisible(false);
  258. let { status, canAskAgain } = await Location.requestForegroundPermissionsAsync();
  259. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  260. if (status === 'granted' && isServicesEnabled) {
  261. getLocation();
  262. } else if (!canAskAgain || !isServicesEnabled) {
  263. setOpenSettingsVisible(true);
  264. }
  265. };
  266. const handlePress = () => {
  267. if (isExpanded) {
  268. setIndex(0);
  269. setSearchInput('');
  270. }
  271. setIsExpanded((prev) => !prev);
  272. width.value = withTiming(isExpanded ? 48 : usableWidth, {
  273. duration: 300,
  274. easing: Easing.inOut(Easing.ease)
  275. });
  276. };
  277. const animatedStyle = useAnimatedStyle(() => {
  278. return {
  279. width: width.value
  280. };
  281. });
  282. const handleSearch = async () => {
  283. setSearch(searchInput);
  284. setSearchVisible(true);
  285. };
  286. const handleGoBack = () => {
  287. navigation.goBack();
  288. };
  289. const handleCloseModal = () => {
  290. setSearchInput('');
  291. setSearchVisible(false);
  292. handlePress();
  293. };
  294. const handleFindRegion = (id: number, type: string) => {
  295. navigation.dispatch(
  296. CommonActions.reset({
  297. index: 1,
  298. routes: [
  299. {
  300. name: NAVIGATION_PAGES.IN_APP_MAP_TAB,
  301. state: {
  302. routes: [
  303. {
  304. name: NAVIGATION_PAGES.MAP_TAB,
  305. params: { id, type }
  306. }
  307. ]
  308. }
  309. }
  310. ]
  311. })
  312. );
  313. };
  314. const locationFeature: GeoJSON.Feature<GeoJSON.Point> = {
  315. type: 'Feature',
  316. geometry: {
  317. type: 'Point',
  318. coordinates: [data.location_last_seen_location?.lng, data.location_last_seen_location?.lat]
  319. },
  320. properties: {}
  321. };
  322. return (
  323. <SafeAreaView style={{ height: '100%' }}>
  324. <StatusBar translucent backgroundColor="transparent" />
  325. <MapLibreRN.MapView
  326. ref={mapRef}
  327. style={styles.map}
  328. mapStyle={VECTOR_MAP_HOST + '/nomadmania-maps.json'}
  329. rotateEnabled={false}
  330. attributionEnabled={false}
  331. onRegionDidChange={() => {
  332. hideTimer.current = setTimeout(() => {
  333. setIsZooming(false);
  334. }, 2000);
  335. }}
  336. onRegionIsChanging={handleMapChange}
  337. onRegionWillChange={_.debounce(handleMapChange, 200)}
  338. >
  339. {type === 'regions' && (
  340. <>
  341. <MapLibreRN.LineLayer
  342. id="nm-regions-line-layer"
  343. sourceID={regions.source}
  344. sourceLayerID={regions['source-layer']}
  345. filter={regions.filter as any}
  346. maxZoomLevel={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={regions.id}
  356. sourceID={regions.source}
  357. sourceLayerID={regions['source-layer']}
  358. filter={regions.filter as any}
  359. style={regions.style}
  360. maxZoomLevel={regions.maxzoom}
  361. belowLayerID={regions_visited.id}
  362. />
  363. <MapLibreRN.FillLayer
  364. id={regions_visited.id}
  365. sourceID={regions_visited.source}
  366. sourceLayerID={regions_visited['source-layer']}
  367. filter={regionsVisitedFilter as any}
  368. style={regions_visited.style}
  369. maxZoomLevel={regions_visited.maxzoom}
  370. belowLayerID="waterway-name"
  371. />
  372. </>
  373. )}
  374. {type === 'countries' && (
  375. <>
  376. <MapLibreRN.LineLayer
  377. id="countries-line-layer"
  378. sourceID={countries.source}
  379. sourceLayerID={countries['source-layer']}
  380. filter={countries.filter as any}
  381. maxZoomLevel={countries.maxzoom}
  382. style={{
  383. lineColor: 'rgba(14, 80, 109, 1)',
  384. lineWidth: ['interpolate', ['linear'], ['zoom'], 0, 0.2, 4, 1, 5, 1.5, 12, 3],
  385. lineWidthTransition: { duration: 300, delay: 0 }
  386. }}
  387. belowLayerID="waterway-name"
  388. />
  389. <MapLibreRN.FillLayer
  390. id={countries.id}
  391. sourceID={countries.source}
  392. sourceLayerID={countries['source-layer']}
  393. filter={countries.filter as any}
  394. style={countries.style}
  395. maxZoomLevel={countries.maxzoom}
  396. belowLayerID={countries_visited.id}
  397. />
  398. <MapLibreRN.FillLayer
  399. id={countries_visited.id}
  400. sourceID={countries_visited.source}
  401. sourceLayerID={countries_visited['source-layer']}
  402. filter={countriesVisitedFilter as any}
  403. style={countries_visited.style}
  404. maxZoomLevel={countries_visited.maxzoom}
  405. belowLayerID="waterway-name"
  406. />
  407. </>
  408. )}
  409. {type === 'dare' && (
  410. <>
  411. <MapLibreRN.FillLayer
  412. id={dare.id}
  413. sourceID={dare.source}
  414. sourceLayerID={dare['source-layer']}
  415. filter={dare.filter as any}
  416. style={dare.style}
  417. maxZoomLevel={dare.maxzoom}
  418. belowLayerID={dare_visited.id}
  419. />
  420. <MapLibreRN.FillLayer
  421. id={dare_visited.id}
  422. sourceID={dare_visited.source}
  423. sourceLayerID={dare_visited['source-layer']}
  424. filter={dareVisitedFilter as any}
  425. style={dare_visited.style}
  426. maxZoomLevel={dare_visited.maxzoom}
  427. belowLayerID="waterway-name"
  428. />
  429. </>
  430. )}
  431. {data.location_sharing && data.location_last_seen_location && data.own_profile !== 1 && (
  432. <MapLibreRN.ShapeSource id="user_location" shape={locationFeature}>
  433. <MapLibreRN.SymbolLayer
  434. id="user_symbol"
  435. filter={['!', ['has', 'point_count']]}
  436. aboveLayerID={Platform.OS === 'android' ? 'place-continent' : undefined}
  437. style={{
  438. iconImage: defaultUserAvatar,
  439. iconSize: [
  440. 'interpolate',
  441. ['linear'],
  442. ['zoom'],
  443. 0,
  444. 0.24,
  445. 5,
  446. 0.28,
  447. 10,
  448. 0.33,
  449. 15,
  450. 0.38,
  451. 20,
  452. 0.42
  453. ],
  454. iconAllowOverlap: true
  455. }}
  456. />
  457. </MapLibreRN.ShapeSource>
  458. )}
  459. <MapLibreRN.Camera ref={cameraRef} />
  460. {location && (
  461. <MapLibreRN.UserLocation
  462. animated={true}
  463. showsUserHeadingIndicator={true}
  464. ></MapLibreRN.UserLocation>
  465. )}
  466. </MapLibreRN.MapView>
  467. {center ? (
  468. <ScaleBar zoom={zoom} latitude={center[1]} isVisible={isZooming} bottom={80} />
  469. ) : null}
  470. {!isExpanded ? (
  471. <TouchableOpacity
  472. style={[styles.cornerButton, styles.topRightButton]}
  473. onPress={handleGoBack}
  474. >
  475. {data.user_data.avatar ? (
  476. <Image
  477. style={styles.avatar}
  478. source={{ uri: API_HOST + '/img/avatars/' + data.user_data.avatar }}
  479. />
  480. ) : (
  481. <AvatarWithInitials
  482. text={`${data.user_data.first_name[0] ?? ''}${data.user_data.last_name[0] ?? ''}`}
  483. flag={API_HOST + '/img/flags_new/' + data.user_data.flag1}
  484. size={48}
  485. borderColor={Colors.WHITE}
  486. />
  487. )}
  488. </TouchableOpacity>
  489. ) : null}
  490. <View style={styles.tabs}>
  491. <ScrollView
  492. horizontal
  493. showsHorizontalScrollIndicator={false}
  494. contentContainerStyle={{ marginLeft: 12, gap: 12, flexDirection: 'row' }}
  495. >
  496. <MapButton
  497. onPress={() => {
  498. setIsFilterVisible('regions');
  499. }}
  500. icon={TravelsIcon}
  501. text="Travels"
  502. />
  503. </ScrollView>
  504. </View>
  505. <TouchableOpacity
  506. onPress={handleGetLocation}
  507. style={[styles.cornerButton, styles.bottomButton, styles.bottomRightButton]}
  508. >
  509. {isLocationLoading ? (
  510. <ActivityIndicator size="small" color={Colors.DARK_BLUE} />
  511. ) : (
  512. <LocationIcon />
  513. )}
  514. </TouchableOpacity>
  515. <Animated.View
  516. style={[
  517. styles.searchContainer,
  518. styles.cornerButton,
  519. styles.topLeftButton,
  520. animatedStyle,
  521. { padding: 5 }
  522. ]}
  523. >
  524. {isExpanded ? (
  525. <>
  526. <TouchableOpacity onPress={handlePress} style={styles.iconButton}>
  527. <CloseSvg fill={'#0F3F4F'} />
  528. </TouchableOpacity>
  529. <TextInput
  530. style={styles.input}
  531. placeholder="Search regions, places, nomads"
  532. placeholderTextColor={Colors.LIGHT_GRAY}
  533. value={searchInput}
  534. onChangeText={(text) => setSearchInput(text)}
  535. onSubmitEditing={handleSearch}
  536. />
  537. <TouchableOpacity onPress={handleSearch} style={styles.iconButton}>
  538. <SearchIcon fill={'#0F3F4F'} />
  539. </TouchableOpacity>
  540. </>
  541. ) : (
  542. <TouchableOpacity onPress={handlePress} style={[styles.iconButton]}>
  543. <SearchIcon fill={'#0F3F4F'} />
  544. </TouchableOpacity>
  545. )}
  546. </Animated.View>
  547. <FilterModal
  548. isFilterVisible={isFilterVisible}
  549. setIsFilterVisible={setIsFilterVisible}
  550. tilesTypes={tilesTypes}
  551. tilesType={tilesType}
  552. setTilesType={setTilesType}
  553. setType={setType}
  554. userId={userId}
  555. setRegionsFilter={setRegionsFilter}
  556. isPublicView={true}
  557. isLogged={true}
  558. />
  559. <LocationPopup
  560. visible={askLocationVisible}
  561. onClose={() => setAskLocationVisible(false)}
  562. onAccept={handleAcceptPermission}
  563. modalText="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."
  564. />
  565. <LocationPopup
  566. visible={openSettingsVisible}
  567. onClose={() => setOpenSettingsVisible(false)}
  568. onAccept={async () => {
  569. const isServicesEnabled = await Location.hasServicesEnabledAsync();
  570. if (!isServicesEnabled) {
  571. Platform.OS === 'ios'
  572. ? Linking.openURL('app-settings:')
  573. : Linking.sendIntent('android.settings.LOCATION_SOURCE_SETTINGS');
  574. } else {
  575. Platform.OS === 'ios' ? Linking.openURL('app-settings:') : Linking.openSettings();
  576. }
  577. }}
  578. modalText="NomadMania app needs location permissions to function properly. Open settings?"
  579. />
  580. <SearchModal
  581. searchVisible={searchVisible}
  582. handleCloseModal={handleCloseModal}
  583. handleFindRegion={handleFindRegion}
  584. index={index}
  585. searchData={searchData}
  586. setIndex={setIndex}
  587. token={token}
  588. />
  589. </SafeAreaView>
  590. );
  591. };
  592. export default UsersMapScreen;