diff --git a/App.js b/App.js index 5bbc6ef2e..91683f126 100644 --- a/App.js +++ b/App.js @@ -1,5 +1,6 @@ -import * as Sentry from '@sentry/react-native'; +import MapLibreGL from '@maplibre/maplibre-react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Sentry from '@sentry/react-native'; import * as Font from 'expo-font'; import * as SplashScreen from 'expo-splash-screen'; import React, { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,6 +12,7 @@ import { auth } from './src/auth'; import { fontConfig, namespace, secrets } from './src/config'; import { SUE_REPORT_VALUES } from './src/screens'; +MapLibreGL.setAccessToken(null); const sentryApi = secrets[namespace].sentryApi; if (sentryApi?.dsn) { diff --git a/app.json b/app.json index 13f7ff7a0..9877bffe0 100644 --- a/app.json +++ b/app.json @@ -93,6 +93,7 @@ "expo-font", "expo-localization", "expo-secure-store", + "@maplibre/maplibre-react-native", "./config-plugins/withAndroidMailQueriesAndWhatsappPackage" ] } diff --git a/package.json b/package.json index 2ced64513..23142ffda 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ "lint": "./node_modules/.bin/eslint __tests__ src --ext .js,.jsx,.ts,.tsx", "test": "./node_modules/.bin/jest", "test:debug": "node --inspect-brk node_modules/jest/bin/jest.js --runInBand", - "test:coverage": "node_modules/.bin/jest --coverage", - "postinstall": "patch-package" + "test:coverage": "node_modules/.bin/jest --coverage" }, "jest": { "preset": "jest-expo", @@ -37,6 +36,7 @@ "dependencies": { "@apollo/react-hooks": "~3.1.5", "@expo/vector-icons": "14.0.0", + "@maplibre/maplibre-react-native": "^10.0.0-alpha.10", "@native-html/iframe-plugin": "^2.6.1", "@native-html/table-plugin": "^5.3.1", "@react-native-async-storage/async-storage": "1.23.1", @@ -93,7 +93,6 @@ "matomo-tracker-react-native": "^0.3.0", "moment": "~2.29.4", "moment-range": "^4.0.2", - "patch-package": "^8.0.0", "postinstall-postinstall": "^2.1.0", "qs": "^6.10.3", "react": "18.2.0", @@ -111,8 +110,6 @@ "react-native-gesture-handler": "~2.16.1", "react-native-get-random-values": "~1.11.0", "react-native-gifted-chat": "^2.0.1", - "react-native-map-clustering": "^3.4.2", - "react-native-maps": "1.14.0", "react-native-markdown-display": "^7.0.0-alpha.2", "react-native-modal-dropdown": "https://github.com/donni106/react-native-modal-dropdown#feature/search-options", "react-native-progress": "^5.0.0", @@ -129,7 +126,8 @@ "react-native-web": "~0.19.10", "react-native-webview": "13.8.6", "react-query": "^3.34.14", - "styled-components": "~4.4.1" + "styled-components": "~4.4.1", + "supercluster": "^8.0.1" }, "devDependencies": { "@babel/core": "^7.24.0", @@ -165,4 +163,4 @@ "@expo/config-plugins": "^8.0.4", "markdown-it": "^12.3.2" } -} \ No newline at end of file +} diff --git a/patches/react-native-maps+1.14.0.patch b/patches/react-native-maps+1.14.0.patch deleted file mode 100644 index 67fcc8a99..000000000 --- a/patches/react-native-maps+1.14.0.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/node_modules/react-native-maps/ios/AirMaps/AIRMap.m b/node_modules/react-native-maps/ios/AirMaps/AIRMap.m -index fe1ae13..af863d9 100644 ---- a/node_modules/react-native-maps/ios/AirMaps/AIRMap.m -+++ b/node_modules/react-native-maps/ios/AirMaps/AIRMap.m -@@ -116,13 +116,13 @@ - (void)insertReactSubview:(id)subview atIndex:(NSInteger)atIndex - [self addOverlay:(id)subview]; - } else if ([subview isKindOfClass:[AIRMapUrlTile class]]) { - ((AIRMapUrlTile *)subview).map = self; -- [self addOverlay:(id)subview]; -+ [self addOverlay:(id)subview level:MKOverlayLevelAboveLabels]; - }else if ([subview isKindOfClass:[AIRMapWMSTile class]]) { - ((AIRMapWMSTile *)subview).map = self; - [self addOverlay:(id)subview]; - } else if ([subview isKindOfClass:[AIRMapLocalTile class]]) { - ((AIRMapLocalTile *)subview).map = self; -- [self addOverlay:(id)subview]; -+ [self addOverlay:(id)subview level:MKOverlayLevelAboveLabels]; - } else if ([subview isKindOfClass:[AIRMapOverlay class]]) { - ((AIRMapOverlay *)subview).map = self; - [self addOverlay:(id)subview]; diff --git a/src/components/LoadingContainer.js b/src/components/LoadingContainer.js index 6b19bb7e0..54dea4a67 100644 --- a/src/components/LoadingContainer.js +++ b/src/components/LoadingContainer.js @@ -4,8 +4,10 @@ import { StyleSheet, View } from 'react-native'; import { normalize } from '../config'; -export const LoadingContainer = ({ children, web }) => ( - {children} +export const LoadingContainer = ({ children, containerStyle, web }) => ( + + {children} + ); const styles = StyleSheet.create({ @@ -26,5 +28,6 @@ const styles = StyleSheet.create({ LoadingContainer.propTypes = { children: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), + containerStyle: PropTypes.object, web: PropTypes.bool }; diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx index 8694564a0..41e0a4085 100644 --- a/src/components/LoadingSpinner.tsx +++ b/src/components/LoadingSpinner.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ActivityIndicator } from 'react-native'; +import { ActivityIndicator, ViewStyle } from 'react-native'; import { colors } from '../config'; @@ -7,11 +7,12 @@ import { LoadingContainer } from './LoadingContainer'; type Props = { loading?: boolean; + containerStyle?: ViewStyle; }; -export const LoadingSpinner = ({ loading }: Props) => { +export const LoadingSpinner = ({ loading, containerStyle }: Props) => { return loading ? ( - + ) : null; diff --git a/src/components/SUE/report/SueReportLocation.tsx b/src/components/SUE/report/SueReportLocation.tsx index a1af5f485..0d7df9af1 100644 --- a/src/components/SUE/report/SueReportLocation.tsx +++ b/src/components/SUE/report/SueReportLocation.tsx @@ -80,6 +80,7 @@ export const SueReportLocation = ({ const currentPosition = position || lastKnownPosition; const [updatedRegion, setUpdatedRegion] = useState(false); + const [selectedMarker, setSelectedMarker] = useState(); useEffect(() => { if (updateRegionFromImage) { @@ -183,6 +184,7 @@ export const SueReportLocation = ({ nativeEvent: { action: string; coordinate: Location.LocationObjectCoords }; }) => { if (nativeEvent.action !== 'marker-press' && nativeEvent.action !== 'callout-inside-press') { + setSelectedMarker(undefined); setSelectedPosition(nativeEvent.coordinate); setUpdatedRegion(false); setUpdateRegionFromImage(false); @@ -248,8 +250,9 @@ export const SueReportLocation = ({ locations={locations} mapCenterPosition={mapCenterPosition} mapStyle={styles.map} - onMyLocationButtonPress={onMyLocationButtonPress} onMapPress={onMapPress} + onMarkerPress={setSelectedMarker} + onMyLocationButtonPress={onMyLocationButtonPress} onMaximizeButtonPress={() => navigation.navigate(ScreenName.SueReportMapView, { calloutTextEnabled: true, @@ -263,6 +266,7 @@ export const SueReportLocation = ({ showsUserLocation: true }) } + selectedMarker={selectedMarker} updatedRegion={ !!selectedPosition && (updatedRegion || updateRegionFromImage) ? { ...selectedPosition, latitudeDelta: 0.01, longitudeDelta: 0.01 } diff --git a/src/components/map/LocationOverview.tsx b/src/components/map/LocationOverview.tsx index 0e483fa30..286ce489e 100644 --- a/src/components/map/LocationOverview.tsx +++ b/src/components/map/LocationOverview.tsx @@ -125,7 +125,6 @@ export const LocationOverview = ({ filterByOpeningTimes, navigation, queryVariab return ( <> ; - updatedRegion?: Region; + updatedRegion?: { latitude: number; longitude: number }; }; +type TCluster = { + cluster?: boolean; + clusterColor: string; + geometry: { coordinates: [number, number] }; + id: string | number; + onPress: () => void; + properties: { point_count?: string }; +}; + +const CIRCLE_SIZES = [normalize(60), normalize(50), normalize(40), normalize(30)]; +const DEFAULT_ZOOM_LEVEL = 14; +const HIT_BOX_SIZE = normalize(80); const MARKER_ICON_SIZE = normalize(40); -const CIRCLE_SIZES = [60, 50, 40, 30]; +const MAX_ZOOM_LEVEL = 20; +const ONE_MARKER_ZOOM_LEVEL = 15; const MapIcon = ({ iconColor, @@ -50,61 +74,51 @@ const MapIcon = ({ return ; }; -type TCluster = { - clusterColor: string; - geometry: { coordinates: [number, number] }; - id: string; - onPress: () => void; - properties: { point_count: number }; -}; - const renderCluster = (cluster: TCluster) => { const { clusterColor: backgroundColor, geometry, id, onPress, properties = {} } = cluster; - const { point_count: points } = properties; + const { point_count } = properties; return ( - - {CIRCLE_SIZES.map((size, index) => ( - - - {points} - - - ))} - + + {CIRCLE_SIZES.map((size, index) => ( + + + {point_count} + + + ))} + + ); }; /* eslint-disable complexity */ export const Map = ({ calloutTextEnabled = false, - clusterDistance, - clusteringEnabled = false, + clusterDistance = 50, + clusteringEnabled = true, geometryTourData, isMaximizeButtonVisible = false, - isMultipleMarkersMap = false, isMyLocationButtonVisible = false, - locations, + locations = [], mapCenterPosition, mapStyle, minZoom, @@ -113,33 +127,28 @@ export const Map = ({ onMaximizeButtonPress, onMyLocationButtonPress, selectedMarker, + showsUserLocation = false, style, updatedRegion, ...otherProps }: Props) => { - const { globalSettings } = useContext(SettingsContext); - const { settings = {} } = globalSettings; - const { zoomLevelForMaps = {}, locationService = {} } = settings; const { locationSettings } = useLocationSettings(); + const cameraRef = useRef(null); + const superClusterRef = useRef(null); - const showsUserLocation = - locationSettings?.locationService ?? otherProps.showsUserLocation ?? !!locationService; - const zoom = isMultipleMarkersMap - ? zoomLevelForMaps.multipleMarkers - : zoomLevelForMaps.singleMarker; + const [clusters, setClusters] = useState([]); + const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL); + const [isInitialFit, setIsInitialFit] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [isAnimating, setIsAnimating] = useState(false); - const refForMapView = useRef(null); - // LATITUDE_DELTA handles the zoom, see: https://github.com/react-native-maps/react-native-maps/issues/2129#issuecomment-457056572 - const LATITUDE_DELTA = zoom || 0.0922; - // example for longitude delta: https://github.com/react-native-maps/react-native-maps/blob/0.30.x/example/examples/DisplayLatLng.js#L18 - const LONGITUDE_DELTA = LATITUDE_DELTA * (device.width / (device.height / 2)); + const showsUserLocationSetting = + locationSettings?.locationService ?? otherProps.showsUserLocation ?? showsUserLocation; // center of Germany - let initialRegion: Region = { + let initialRegion: { latitude: number; longitude: number } = { latitude: 51.1657, - longitude: 10.4515, - latitudeDelta: LATITUDE_DELTA, - longitudeDelta: LONGITUDE_DELTA + longitude: 10.4515 }; if (locations?.[0]?.position?.latitude && locations[0]?.position?.longitude) { @@ -157,146 +166,260 @@ export const Map = ({ }; } + const mapLocations = locations.map((location, index) => ({ + ...location, + type: 'Feature', + properties: { cluster: false, id: location.id, index }, + geometry: { + type: 'Point', + coordinates: [location.position.longitude, location.position.latitude] + } + })); + + useEffect(() => { + if (clusteringEnabled) { + const index = new Supercluster({ + radius: clusterDistance, + maxZoom: MAX_ZOOM_LEVEL + }); + /** + * the values represent the maximum possible latitude and longitude values for the earth's + * coordinate system, defining a rectangular area that covers the entire world. + * The values are based on the following assumptions: + * Westernmost longitude: 2° E (covering parts of France and the Netherlands) + * Easternmost longitude: 18° E (covering parts of Poland and the Czech Republic) + * Northernmost latitude: 56° N (covering parts of Denmark) + * Southernmost latitude: 46° N (covering parts of Switzerland and Austria) + */ + const bounds = [2, 46, 18, 56]; + const points = mapLocations; + + index.load(points); + superClusterRef.current = index; // Store the Supercluster instance in the ref + + setClusters(index.getClusters(bounds, zoomLevel)); + } else { + setClusters(mapLocations); + } + + if (locations.length > 0 && isInitialFit) { + const coordinates = locations.map((loc) => [loc.position.longitude, loc.position.latitude]); + if (locations.length === 1) { + if (cameraRef.current) { + cameraRef.current.setCamera({ + animationDuration: 1000, + centerCoordinate: coordinates[0], + zoomLevel: ONE_MARKER_ZOOM_LEVEL + }); + setIsLoading(false); + setIsInitialFit(false); + } + } else if (coordinates && coordinates.length > 0) { + const { minLng, minLat, maxLng, maxLat, deltaLng, deltaLat } = + calculateBoundsToFitAllMarkers(coordinates); + + if (cameraRef.current) { + cameraRef.current.fitBounds( + [minLng - deltaLng, minLat - deltaLat], + [maxLng + deltaLng, maxLat + deltaLat], + 0 + ); + + setIsLoading(false); + setIsInitialFit(false); + } + } + } + }, [locations, clusterDistance, zoomLevel, isInitialFit, clusteringEnabled]); + + useEffect(() => { + if (updatedRegion && cameraRef.current) { + cameraRef.current.setCamera({ + animationDuration: 1000, + centerCoordinate: [updatedRegion.longitude, updatedRegion.latitude], + zoomLevel: DEFAULT_ZOOM_LEVEL + }); + } + }, [updatedRegion]); + + const handleMapPress = (event) => { + if (onMapPress) { + const { geometry } = event; + const nativeEvent = { + coordinate: { + latitude: geometry.coordinates[1], + longitude: geometry.coordinates[0] + } + }; + onMapPress({ nativeEvent }); + } + }; + + const handleClusterPress = useCallback( + (cluster: TCluster) => { + if (isAnimating) return; + + setIsAnimating(true); + const [longitude, latitude] = cluster.geometry.coordinates; + + if (superClusterRef.current) { + const expansionZoom = superClusterRef.current.getClusterExpansionZoom(cluster.id); + + if (cameraRef.current) { + cameraRef.current.setCamera({ + animationDuration: 1000, + centerCoordinate: [longitude, latitude], + zoomLevel: expansionZoom + }); + } + } + + setIsAnimating(false); + }, + [isAnimating] + ); + return ( + { + const newZoomLevel = Math.round(region.properties.zoomLevel); + if (typeof newZoomLevel === 'number') { + setZoomLevel(newZoomLevel); + } }} + showUserLocation={showsUserLocationSetting} + style={[stylesForMap().map, mapStyle]} + styleJSON="https://tileserver-gl.smart-village.app/styles/osm-liberty/style.json" > - - {!!geometryTourData?.length && ( - - )} - {locations?.map((marker, index) => { + + + {clusters.map((marker, index) => { + const [longitude, latitude] = marker.geometry.coordinates; + const { cluster: isCluster } = marker.properties; + + if (clusteringEnabled && isCluster) { + return renderCluster({ + clusterColor: colors.primary, + geometry: { coordinates: [longitude, latitude] }, + id: index, + onPress: () => handleClusterPress(marker), + properties: marker.properties + }); + } + const isActiveMarker = selectedMarker && marker.id === selectedMarker; - const serviceName = truncateText(marker.serviceName); - const title = truncateText(marker.title); + const serviceName = truncateText(marker?.serviceName); + const title = truncateText(marker?.title); return ( - onMarkerPress?.(marker.id)} - zIndex={isActiveMarker ? 1010 : 1} + onSelected={() => onMarkerPress?.(marker.id)} > - {!!marker.iconName && - marker.iconName != 'ownLocation' && - marker.iconName != 'location' ? ( - <> - - + <> + {calloutTextEnabled && isActiveMarker && ( + + + {!!serviceName && ( + + {serviceName} + + )} + + {!!title && ( + + {title} + + )} + + )} + + + {!!marker.iconName && + marker.iconName != 'ownLocation' && + marker.iconName != 'location' ? ( + <> + + + + + + ) : ( - - - ) : ( - - )} - - {calloutTextEnabled && ( - - {!!serviceName && ( - - {serviceName} - - )} - {!!title && ( - - {title} - )} - - )} - + + + ); })} + + {!!geometryTourData?.length && ( + [point.longitude, point.latitude]) + } + }} + > + + + )} + + {showsUserLocationSetting && } + {isMaximizeButtonVisible && ( )} + {isMyLocationButtonVisible && ( )} - {device.platform === 'android' && ( - - © OpenStreetMap - - )} ); }; @@ -304,18 +427,33 @@ export const Map = ({ const styles = StyleSheet.create({ callout: { + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: normalize(10), + bottom: normalize(55), + padding: normalize(5), + position: 'absolute', width: normalize(120) }, + calloutTriangle: { + backgroundColor: colors.surface, + borderRadius: normalize(5), + bottom: normalize(-10), + height: normalize(30), + position: 'absolute', + transform: [{ rotate: '45deg' }], + width: normalize(30) + }, clusterCircle: { alignItems: 'center', justifyContent: 'center', position: 'absolute' }, - clusterMarker: { + hitBox: { alignItems: 'center', - height: normalize(60), + height: normalize(HIT_BOX_SIZE), justifyContent: 'center', - width: normalize(60) + width: normalize(HIT_BOX_SIZE) }, container: { alignItems: 'center', @@ -323,6 +461,13 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: 'center' }, + loadingContainer: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.8)', + justifyContent: 'center', + zIndex: 1 + }, logoContainer: { alignItems: 'center', backgroundColor: colors.surface, @@ -345,7 +490,7 @@ const styles = StyleSheet.create({ maximizeMapButton: { alignItems: 'center', backgroundColor: colors.surface, - borderRadius: 50, + borderRadius: normalize(50), bottom: normalize(15), height: normalize(48), justifyContent: 'center', @@ -357,14 +502,18 @@ const styles = StyleSheet.create({ myLocationButton: { alignItems: 'center', backgroundColor: colors.surface, - borderRadius: 50, - top: normalize(15), + borderRadius: normalize(50), height: normalize(48), justifyContent: 'center', position: 'absolute', right: normalize(15), + top: normalize(15), width: normalize(48), zIndex: 1 + }, + markerContainer: { + alignItems: 'center', + justifyContent: 'center' } }); diff --git a/src/components/settings/LocationSettings.js b/src/components/settings/LocationSettings.js index c0248b212..b35ae0bfe 100644 --- a/src/components/settings/LocationSettings.js +++ b/src/components/settings/LocationSettings.js @@ -1,5 +1,5 @@ import * as Location from 'expo-location'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Alert, ScrollView, StyleSheet } from 'react-native'; import Collapsible from 'react-native-collapsible'; @@ -34,54 +34,50 @@ export const LocationSettings = () => { const [showMap, setShowMap] = useState(false); const [selectedPosition, setSelectedPosition] = useState(); - if (!systemPermission) { - return ; - } - const { locationService = systemPermission.status !== Location.PermissionStatus.DENIED, alternativePosition, defaultAlternativePosition } = locationSettings || {}; - const locationServiceSwitchData = { - title: texts.settingsTitles.locationService, - bottomDivider: true, - topDivider: true, - value: locationService, - onActivate: (revert) => { - Location.getForegroundPermissionsAsync().then((response) => { - // if the system permission is granted, we can simply enable the sorting - if (response.status === Location.PermissionStatus.GRANTED) { - setAndSyncLocationSettings({ locationService: true }); - return; - } - - // if we can ask for the system permission, do so and update the settings or revert depending on the outcome - if (response.status === Location.PermissionStatus.UNDETERMINED || response.canAskAgain) { - Location.requestForegroundPermissionsAsync() - .then((response) => { - if (response.status !== Location.PermissionStatus.GRANTED) { - revert(); - } else { - setAndSyncLocationSettings({ locationService: true }); - return; - } - }) - .catch(() => revert()); - return; - } - - // if we neither have the permission, nor can we ask for it, then show an alert that the permission is missing - revert(); - Alert.alert( - texts.settingsTitles.locationService, - texts.settingsContents.locationService.onSystemPermissionMissing - ); - }); - }, - onDeactivate: () => setAndSyncLocationSettings({ locationService: false }) - }; + const locationServiceSwitchData = useMemo( + () => ({ + title: texts.settingsTitles.locationService, + bottomDivider: true, + topDivider: true, + value: locationService, + onActivate: (revert) => { + Location.getForegroundPermissionsAsync().then((response) => { + if (response.status === Location.PermissionStatus.GRANTED) { + setAndSyncLocationSettings({ locationService: true }); + return; + } + + if (response.status === Location.PermissionStatus.UNDETERMINED || response.canAskAgain) { + Location.requestForegroundPermissionsAsync() + .then((response) => { + if (response.status !== Location.PermissionStatus.GRANTED) { + revert(); + } else { + setAndSyncLocationSettings({ locationService: true }); + return; + } + }) + .catch(() => revert()); + return; + } + + revert(); + Alert.alert( + texts.settingsTitles.locationService, + texts.settingsContents.locationService.onSystemPermissionMissing + ); + }); + }, + onDeactivate: () => setAndSyncLocationSettings({ locationService: false }) + }), + [locationService, setAndSyncLocationSettings] + ); let locations = []; @@ -93,6 +89,31 @@ export const LocationSettings = () => { locations = [getLocationMarker(defaultAlternativePosition)]; } + const handleMapPress = ({ nativeEvent }) => { + setSelectedPosition({ + ...nativeEvent.coordinate + }); + }; + + const handleSave = () => { + if (selectedPosition) { + setAndSyncLocationSettings({ + alternativePosition: geoLocationToLocationObject(selectedPosition) + }); + } + setSelectedPosition(undefined); + setShowMap(false); + }; + + const handleAbort = () => { + setSelectedPosition(undefined); + setShowMap(false); + }; + + if (!systemPermission) { + return ; + } + return ( @@ -100,34 +121,11 @@ export const LocationSettings = () => { {texts.settingsContents.locationService.alternativePositionHint} - { - setSelectedPosition({ - ...nativeEvent.coordinate - }); - }} - /> - -