|
@@ -0,0 +1,381 @@
|
|
|
|
+/* Copyright (C) Click & Push Accessibility, Inc - All Rights Reserved
|
|
|
|
+ * Unauthorized copying of this file, via any medium is strictly prohibited
|
|
|
|
+ * Proprietary and confidential
|
|
|
|
+ * Written and maintained by the Click & Push Development team
|
|
|
|
+ * <dev@clicknpush.ca>, January 2022
|
|
|
|
+ */
|
|
|
|
+
|
|
|
|
+import { FontAwesome } from "@expo/vector-icons";
|
|
|
|
+import { RouteProp, useNavigationState } from "@react-navigation/native";
|
|
|
|
+import { booleanPointInPolygon, circle } from '@turf/turf';
|
|
|
|
+import * as Notifications from 'expo-notifications';
|
|
|
|
+import { observer } from "mobx-react";
|
|
|
|
+import React, { useEffect, useState } from "react";
|
|
|
|
+import { ActivityIndicator, Alert, Image, Keyboard, Modal, Text, TouchableOpacity, TouchableWithoutFeedback, View } from "react-native";
|
|
|
|
+import MapView, { LatLng, Marker, Polygon, Polyline } from "react-native-maps";
|
|
|
|
+import { openSettings } from "react-native-permissions";
|
|
|
|
+import Spokestack from 'react-native-spokestack';
|
|
|
|
+import { useAuth } from "../../../state/external/auth-provider";
|
|
|
|
+import { Landmark } from '../../../state/external/landmarks';
|
|
|
|
+import { NotifType } from "../../../state/external/notifications";
|
|
|
|
+import { usePermissions } from "../../../state/external/PermissionsContext";
|
|
|
|
+import { MainTabsNavigationProp, MainTabsParamList } from "../../../navigation/main-tabs-navigator";
|
|
|
|
+import { colors, lmTypes } from "../../../utils/GlobalUtils";
|
|
|
|
+import Badge from "../../Badge";
|
|
|
|
+import { IconButton } from "../../Buttons";
|
|
|
|
+import mapStyles from "../Map.styles";
|
|
|
|
+import { MapStackNavigationProp, MapStackParamList } from "../MapNavigator";
|
|
|
|
+import { addLandmarkStore } from "../panels/add-landmark-panel/add-landmark-panel.store";
|
|
|
|
+import NearbyLandmarksPanel from "../panels/NearbyLandmarksPanel";
|
|
|
|
+import { VoicePanel } from "../panels/VoicePanel";
|
|
|
|
+import { useOutdoorMapState } from "../useMapState";
|
|
|
|
+
|
|
|
|
+/**
|
|
|
|
+ * An interface representing the user location retrieved from [expo-location]{@link https://docs.expo.dev/versions/latest/sdk/location/}.
|
|
|
|
+ * @category Map
|
|
|
|
+ */
|
|
|
|
+export interface UserLocation {
|
|
|
|
+ latitude: number;
|
|
|
|
+ longitude: number;
|
|
|
|
+ heading?: number;
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+export type MapStackRouteProp = RouteProp<MapStackParamList, 'Outdoor'>;
|
|
|
|
+
|
|
|
|
+export type AuthTabsMapRouteProp = RouteProp<MainTabsParamList, 'Map'>;
|
|
|
|
+
|
|
|
|
+/**
|
|
|
|
+ * The screen component containing the Map and all related functionality. Uses [react-native-maps]{@link https://github.com/react-native-maps/react-native-maps}
|
|
|
|
+ * @category Map
|
|
|
|
+ * @component
|
|
|
|
+ */
|
|
|
|
+
|
|
|
|
+interface OutdoorMapProps {
|
|
|
|
+ mapNavigation: MapStackNavigationProp,
|
|
|
|
+ authNavigation: MainTabsNavigationProp,
|
|
|
|
+ authNavIndex: number,
|
|
|
|
+ route: AuthTabsMapRouteProp,
|
|
|
|
+ focusLandmark: (landmark: Landmark) => void,
|
|
|
|
+ setSelectedLandmarkId: (id: string) => void,
|
|
|
|
+ selectedLandmarkId: string,
|
|
|
|
+ toggleLmDetails: (state: boolean) => void,
|
|
|
|
+ toggleLmAdd: (state: boolean) => void,
|
|
|
|
+ landmarks: Landmark[]
|
|
|
|
+ applyFilters: (landmarks: Landmark[]) => Landmark[]
|
|
|
|
+ promptAddLandmark: (longitude: number, latitude: number) => void
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+const OutdoorMap: React.FC<OutdoorMapProps> = (props) => {
|
|
|
|
+ const {locationPermissionsGranted, voicePermissionsGranted} = usePermissions();
|
|
|
|
+ const {setAlert} = useAuth()
|
|
|
|
+ const mapState = useOutdoorMapState()
|
|
|
|
+
|
|
|
|
+ // used to determine previous screens and decide if the map should be refreshed
|
|
|
|
+ const {index: mapNavigationIndex} = useNavigationState(state => state)
|
|
|
|
+
|
|
|
|
+ const [coordinates] = useState([
|
|
|
|
+ { latitude: 53.527086340019856, longitude: -113.52358410971608, }, // Cameron library
|
|
|
|
+ { latitude: 53.52516024715472, longitude: -113.52154139033108, }, // University station
|
|
|
|
+ ]);
|
|
|
|
+ const [size, setSize] = useState(0.052344);
|
|
|
|
+
|
|
|
|
+ // TODO: define this effect better and define an interface for the incoming route params
|
|
|
|
+ // when switching to this component, check if incoming navstate contains incoming selected landmarks, display them if there are. this will be triggered by incoming notifcations
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ console.log(props.route?.params?.selectedLandmark)
|
|
|
|
+ if (props.route?.params?.selectedLandmark) {
|
|
|
|
+ props.setSelectedLandmarkId(props.route?.params?.selectedLandmark)
|
|
|
|
+ }
|
|
|
|
+ if (props.route?.params?.nearbyLandmarks) {
|
|
|
|
+ mapState.toggleNearbyLmPanel(true)
|
|
|
|
+ }
|
|
|
|
+ }, [props.route])
|
|
|
|
+
|
|
|
|
+ // when either the map navigation state or main navigation state changes, check to see if we are coming from a different screen. if so, refresh the map to prevent map lag
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ const toMap = mapNavigationIndex == 0 && props.authNavIndex == 0
|
|
|
|
+
|
|
|
|
+ if (toMap) mapState.setLoading(true)
|
|
|
|
+
|
|
|
|
+ setTimeout(() => {
|
|
|
|
+ mapState.setLoading(false)
|
|
|
|
+ }, 500);
|
|
|
|
+
|
|
|
|
+ }, [mapNavigationIndex, props.authNavIndex])
|
|
|
|
+
|
|
|
|
+ // Toggle the lm details panel when a new selected landmark is detected (triggered by pressing on a map marker, or from the list of nearby landmarks)
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ console.log("[LandmarkDetails]: Landmark selected - " + props.selectedLandmarkId)
|
|
|
|
+ if (props.selectedLandmarkId) {
|
|
|
|
+ const landmark = props.landmarks.find(lm => lm.id == props.selectedLandmarkId)
|
|
|
|
+ mapState.mapRef.current.animateToRegion({ latitude: landmark.latitude, longitude: landmark.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 })
|
|
|
|
+ props.toggleLmDetails(true)
|
|
|
|
+ }
|
|
|
|
+ }, [props.selectedLandmarkId])
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ // Move to pressed location when newlandmark changes
|
|
|
|
+ useEffect(() => {
|
|
|
|
+ if (props.selectedLandmarkId) {
|
|
|
|
+ mapState.mapRef.current.animateToRegion({ latitude: addLandmarkStore.pendingLandmark?.latitude, longitude: addLandmarkStore.pendingLandmark?.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 })
|
|
|
|
+ }
|
|
|
|
+ }, [addLandmarkStore.pendingLandmark])
|
|
|
|
+ /**
|
|
|
|
+ * Animates the map to fly over to and focus on the user's location.
|
|
|
|
+ */
|
|
|
|
+ const flyToUser = () => {
|
|
|
|
+
|
|
|
|
+ console.log('[Map]: Centering on user')
|
|
|
|
+ if (mapState.userLocation) {
|
|
|
|
+ mapState.mapRef.current?.animateToRegion({ latitude: mapState.userLocation.latitude, longitude: mapState.userLocation.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 })
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Activates speech recognition and opens the voice panel
|
|
|
|
+ */
|
|
|
|
+ const startSpeech = async () => {
|
|
|
|
+ if (!(voicePermissionsGranted && locationPermissionsGranted)) {
|
|
|
|
+ setAlert({
|
|
|
|
+ title: "Permissions",
|
|
|
|
+ message: "Please enable location and voice permissions in the settings menu",
|
|
|
|
+ type: "warning",
|
|
|
|
+ callback: () => openSettings(),
|
|
|
|
+ callbackButtonText: 'Open settings'
|
|
|
|
+ })
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ props.toggleLmDetails(false);
|
|
|
|
+ props.toggleLmAdd(false);
|
|
|
|
+ const spokestackInitialized = await Spokestack.isInitialized()
|
|
|
|
+ const spokestackStarted = await Spokestack.isStarted()
|
|
|
|
+
|
|
|
|
+ if (spokestackInitialized && spokestackStarted) {
|
|
|
|
+ addLandmarkStore.stageNewLandmarkWithLocation({latitude: mapState.userLocation.latitude, longitude: mapState.userLocation.longitude})
|
|
|
|
+ mapState.toggleVoiceVisible(true)
|
|
|
|
+ Spokestack.activate()
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Gets initial region that map should zoom into from current user location
|
|
|
|
+ */
|
|
|
|
+ const getInitialRegion = () => {
|
|
|
|
+ if (mapState.userLocation) {
|
|
|
|
+ return { latitude: mapState.userLocation.latitude, longitude: mapState.userLocation.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01 }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ /**
|
|
|
|
+ * Method that runs every time user location changes, updates user location state in memory and checks if any landmarks are nearby
|
|
|
|
+ */
|
|
|
|
+ const updateLocation = async (coord: LatLng) => {
|
|
|
|
+
|
|
|
|
+ mapState.setUserLocation(coord)
|
|
|
|
+ // get 10m radius around user
|
|
|
|
+ const userAlertRadius = circle([coord.longitude, coord.latitude], 10, { units: 'meters' })
|
|
|
|
+
|
|
|
|
+ // check each landmark to see if its inside user radius. if it is, and it isn't already in the list of notified landmarks, add it
|
|
|
|
+ const newLandmarksNearUser = props.landmarks?.filter(lm => {
|
|
|
|
+ const landmarkNearUser = booleanPointInPolygon([lm.longitude, lm.latitude], userAlertRadius)
|
|
|
|
+ return landmarkNearUser
|
|
|
|
+ })
|
|
|
|
+
|
|
|
|
+ // to prevent duplicate notifications make a list of landmarks that weren't previously near the user.
|
|
|
|
+ // these are the only ones that the user will be notified of
|
|
|
|
+ const newLandmarksNotPreviouslyNearUser = newLandmarksNearUser?.filter(lm => mapState.landmarksNearUser.some(origLm => lm == origLm.id))
|
|
|
|
+
|
|
|
|
+ // update list
|
|
|
|
+ mapState.setLandmarksNearUser(newLandmarksNearUser)
|
|
|
|
+
|
|
|
|
+ // if there are any new landmarks near user, create a notification for them and send it
|
|
|
|
+ if (newLandmarksNotPreviouslyNearUser?.length > 0) {
|
|
|
|
+ const body = newLandmarksNotPreviouslyNearUser.length > 1 ? "There are new landmarks near by. Tap here to view" : "There is a new landmark close by. Tap here to view"
|
|
|
|
+ const notifType: NotifType = newLandmarksNotPreviouslyNearUser.length > 1 ? 'near-landmarks' : 'near-landmark'
|
|
|
|
+ const data = { notif_type: notifType, landmarks: newLandmarksNotPreviouslyNearUser.length == 1 ? newLandmarksNearUser : null }
|
|
|
|
+ await Notifications.scheduleNotificationAsync({
|
|
|
|
+ content: {
|
|
|
|
+ title: "⚠ Landmarks close by ⚠",
|
|
|
|
+ body: body,
|
|
|
|
+ data: data
|
|
|
|
+ },
|
|
|
|
+ trigger: { seconds: 2 },
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const focusNearbyLandmarks = () => {
|
|
|
|
+ if (mapState.landmarksNearUser?.length > 1) {
|
|
|
|
+ mapState.toggleNearbyLmPanel(true)
|
|
|
|
+ }
|
|
|
|
+ else if (mapState.landmarksNearUser?.length === 1) {
|
|
|
|
+ props.setSelectedLandmarkId(mapState.landmarksNearUser[0].id)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return (
|
|
|
|
+ <TouchableWithoutFeedback>
|
|
|
|
+ <>
|
|
|
|
+ {/*Main map component*/}
|
|
|
|
+ <Modal transparent={true} animationType="fade" visible={mapState.loading}>
|
|
|
|
+ <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0,0,0,0.3)' }}>
|
|
|
|
+ <View style={{ width: '60%', height: '30%', backgroundColor: colors.red, justifyContent: 'center', alignItems: 'center', borderRadius: 20 }}>
|
|
|
|
+ <ActivityIndicator size="large" color="white" style={{ marginBottom: 20 }} />
|
|
|
|
+ <Text style={{ fontSize: 15, color: 'white' }}>Refreshing</Text>
|
|
|
|
+ </View>
|
|
|
|
+ </View>
|
|
|
|
+ </Modal>
|
|
|
|
+ <MapView
|
|
|
|
+ key={mapState.refreshKey}
|
|
|
|
+ toolbarEnabled={false}
|
|
|
|
+ onPress={() => Keyboard.dismiss()}
|
|
|
|
+ testID="mapView"
|
|
|
|
+ ref={mapState.mapRef}
|
|
|
|
+ style={{ width: '100%', height: '100%' }}
|
|
|
|
+ initialRegion={getInitialRegion()}
|
|
|
|
+ onLongPress={async (e) => await props.promptAddLandmark(e.nativeEvent.coordinate.longitude, e.nativeEvent.coordinate.latitude)}
|
|
|
|
+ showsUserLocation={locationPermissionsGranted}
|
|
|
|
+ onUserLocationChange={e => updateLocation(e.nativeEvent.coordinate)}
|
|
|
|
+ followsUserLocation={mapState.followUser}
|
|
|
|
+ showsMyLocationButton={false}
|
|
|
|
+
|
|
|
|
+ onRegionChangeComplete={(Region) => {
|
|
|
|
+ console.log("size is " + size)
|
|
|
|
+ console.log(Region.latitudeDelta)
|
|
|
|
+ setSize(Region.latitudeDelta)
|
|
|
|
+ }}
|
|
|
|
+ >
|
|
|
|
+ <Polygon // polygon for cameron library
|
|
|
|
+ coordinates={[
|
|
|
|
+ { latitude: 53.527190, longitude: -113.524205 },
|
|
|
|
+ { latitude: 53.526510, longitude: -113.524205 },
|
|
|
|
+ { latitude: 53.526510, longitude: -113.523452 },
|
|
|
|
+ { latitude: 53.527190, longitude: -113.523452 },
|
|
|
|
+ ]}
|
|
|
|
+ fillColor={`rgba(100,100,200,0.3)`}
|
|
|
|
+ strokeWidth={2.5}
|
|
|
|
+ tappable={true}
|
|
|
|
+ onPress={() => props.mapNavigation.navigate("Indoor")}
|
|
|
|
+ />
|
|
|
|
+
|
|
|
|
+ {props.applyFilters(props.landmarks)?.map((landmark) => {
|
|
|
|
+ if (landmark.floor == null) {
|
|
|
|
+ let trackChanges = false;
|
|
|
|
+ if (landmark?.id == props.selectedLandmarkId) {
|
|
|
|
+ trackChanges = true;
|
|
|
|
+ }
|
|
|
|
+ return (
|
|
|
|
+ <Marker
|
|
|
|
+ tracksViewChanges={trackChanges}
|
|
|
|
+ onPress={() => props.focusLandmark(landmark)}
|
|
|
|
+ key={landmark.id}
|
|
|
|
+ coordinate={{ latitude: landmark.latitude as number, longitude: landmark.longitude as number }} >
|
|
|
|
+ {landmark.landmark_type ? <Image style={{ height: 35, width: 25 }} source={lmTypes[landmark.landmark_type]?.image} /> : null}
|
|
|
|
+ </Marker>)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ )}
|
|
|
|
+
|
|
|
|
+ <Polyline
|
|
|
|
+ coordinates={[
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.527192,
|
|
|
|
+ longitude: -113.523583, //Cameron
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.527189,
|
|
|
|
+ longitude: -113.5233285,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.526942,
|
|
|
|
+ longitude: -113.523338,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.526934,
|
|
|
|
+ longitude: -113.523263,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.5264874,
|
|
|
|
+ longitude: -113.5232654,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.526492,
|
|
|
|
+ longitude: -113.522827,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.52620959999999,
|
|
|
|
+ longitude: -113.5228282,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.5259882,
|
|
|
|
+ longitude: -113.5222835,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.5254566,
|
|
|
|
+ longitude: -113.5222824,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.525456,
|
|
|
|
+ longitude: -113.522155,
|
|
|
|
+ },
|
|
|
|
+ {
|
|
|
|
+ latitude: 53.5251288,
|
|
|
|
+ longitude: -113.5216083, // University station
|
|
|
|
+ },
|
|
|
|
+ ]}
|
|
|
|
+ strokeColor="black"
|
|
|
|
+ strokeWidth={3}
|
|
|
|
+ onPress={() => Alert.alert("This is a route from University Station to Cameron Library")}
|
|
|
|
+ tappable={true}
|
|
|
|
+ >
|
|
|
|
+ </Polyline>
|
|
|
|
+ {/* <MapViewDirections
|
|
|
|
+ origin={coordinates[0]}
|
|
|
|
+ destination={coordinates[1]}
|
|
|
|
+ apikey={"xxxxxxxxxxxxxxx"} // insert your API Key here
|
|
|
|
+ strokeWidth={4}
|
|
|
|
+ strokeColor="#111111"
|
|
|
|
+ mode="WALKING"
|
|
|
|
+ /> */}
|
|
|
|
+ <Marker coordinate={coordinates[1]} pinColor={colors.red}/>
|
|
|
|
+
|
|
|
|
+ <Marker coordinate={{ latitude: 53.527189,longitude: -113.5233285, }} pinColor={colors.red}>
|
|
|
|
+ {/* <Image source={require('../../../../assets/accessibleEntrance.png')} /> */}
|
|
|
|
+ <Text style={{ fontSize: size>0.00327 ? 0 : 0.05/size , maxWidth:200, }}>Route from University Station to Cameron Library</Text>
|
|
|
|
+ </Marker>
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ </MapView>
|
|
|
|
+ {/*Map buttons*/}
|
|
|
|
+ {mapState.landmarksNearUser?.length > 0 ?
|
|
|
|
+ <TouchableOpacity style={[mapStyles.lowerMapButton, mapStyles.alertButton]} onPress={focusNearbyLandmarks}>
|
|
|
|
+ <FontAwesome name='exclamation-triangle' size={20} color='white' />
|
|
|
|
+ <Badge positioning={{ bottom: 7, right: 4 }} value={mapState.landmarksNearUser.length} />
|
|
|
|
+ </TouchableOpacity> : null}
|
|
|
|
+ {locationPermissionsGranted && voicePermissionsGranted ?<IconButton size={20} color='white' style={[mapStyles.lowerMapButton, mapStyles.voiceButton]} icon="microphone" onPress={async () => await startSpeech()} /> : null}
|
|
|
|
+ <IconButton size={20} color='white' style={[mapStyles.lowerMapButton, mapStyles.addLandmarkButton]} icon="plus" onPress={async () => await props.promptAddLandmark(mapState.userLocation.longitude, mapState.userLocation.latitude)} />
|
|
|
|
+ <IconButton size={20} color='white' style={[mapStyles.lowerMapButton, mapStyles.userLocationButton]} icon="location-arrow" onPress={flyToUser} />
|
|
|
|
+ <NearbyLandmarksPanel
|
|
|
|
+ focusLandmark={props.focusLandmark}
|
|
|
|
+ nearbyLmPanelVisible={mapState.nearbyLmPanelVisible}
|
|
|
|
+ toggleAlertedLmPanel={mapState.toggleNearbyLmPanel}
|
|
|
|
+ nearbyLandmarks={mapState.landmarksNearUser} />
|
|
|
|
+ {/*Map Panels*/}
|
|
|
|
+ {locationPermissionsGranted && voicePermissionsGranted ?
|
|
|
|
+ <VoicePanel
|
|
|
|
+ landmarksNearby={mapState.landmarksNearUser?.length > 0}
|
|
|
|
+ toggleAlertedLandmarksVisible={mapState.toggleNearbyLmPanel}
|
|
|
|
+ navigation={props.authNavigation}
|
|
|
|
+ userCoords={{ longitude: mapState.userLocation?.longitude, latitude: mapState.userLocation?.latitude }}
|
|
|
|
+ toggleVoiceVisible={mapState.toggleVoiceVisible}
|
|
|
|
+ toggleLmDetails={props.toggleLmDetails}
|
|
|
|
+ voiceVisible={mapState.voiceVisible}
|
|
|
|
+ /> : null}
|
|
|
|
+ </>
|
|
|
|
+ </TouchableWithoutFeedback>)
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+export default observer(OutdoorMap);
|