Source

src/components/Map.tsx

import { FontAwesome } from "@expo/vector-icons";
import * as Location from 'expo-location';
import React, { useEffect, useState } from "react";
import { Keyboard, KeyboardAvoidingView, TouchableOpacity, View } from "react-native";
import MapView, { LatLng, Region } from "react-native-maps";
import Modal from "react-native-modal";
import { colors } from "../globals";
import { Landmark, useLandmarks } from "../hooks/useLandmarks";
import AddLandmark from "./AddLandmark";
import LandmarkDetails from "./LandmarkDetails";
import LandmarkPin from "./LandmarkPin";

/**
 * 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;
}

const directionsUrl = "https://maps.googleapis.com/maps/api/directions/son?mode=walking&alternatives=true&key=AIzaSyD06YUMazlb4Fu0Q81y_YNyEBz8PmtZyeY";

/**
 * The screen component containing the [react-native-maps]{@link https://github.com/react-native-maps/react-native-maps} Map and all related functionality.
 * @category Map
 * @component
 */
export const Map: React.FC = () => {
    /**
     * State that contains the new {@link Landmark} object which is passed down to the {@link AddLandmark} modal.
     */
    const newLandmarkState: Landmark = {};
    const [newLandmark, setNewLandmark] = useState<Landmark>(newLandmarkState);
    /**
     * State that contains the selected {@link Landmark} object which is passed down to the {@link LandmarkDetails} modal.
     */
    const selectedLandmarkState: Landmark = {};
    const [selectedLandmark, setSelectedLandmark] = useState<Landmark>(selectedLandmarkState);
    /**
     * Holds the visibility state of the {@link AddLandmark} modal.
     */
    const lmAddVisibleState = false;
    const [lmAddVisible, toggleLmAdd] = useState<boolean>(false);
    /**
     * Holds the visibility state of the {@link LandmarkDetails} modal.
     */
    const lmDetailsVisibleState = false;
    const [lmDetailsVisible, toggleLmDetails] = useState<boolean>(false);
    /**
     * Flag that toggles whether or not editing is enabled in the {@link LandmarkDetails} modal. 
     * The parent Map component has access to it so that it can disable closing the modal on backdrop press when it is enabled.
     */
    const lmDetailsEditingState = false;
    const [lmDetailsEditing, toggleLmDetailsEditing] = useState<boolean>(false);
    /**
     * State that holds a {@link UserLocation} object retrieved from location services.
     */
    const userLocationState: UserLocation | undefined = undefined;
    const [userLocation, setUserLocation] = useState<UserLocation>(userLocationState);
    /**
     * Flag that determines whether the map should focus and follow the user's location.
     */
    const followUserState = false;
    const [followUser, toggleFollowUser] = useState<boolean>(followUserState);
    //const [prevFetchedBounds, setFetchedBounds] = useState<Region>();
    const { landmarks, getLandmarksStatus, refetchLandmarks } = useLandmarks(undefined);
    
    /**
     * Ref that holds the loaded [MapView]{@link https://github.com/react-native-maps/react-native-maps/blob/master/docs/mapview.md} instance.
     */
    const mapRef = React.createRef<MapView>();

    useEffect(() => {
        /**
         * Prompts user to give permission to track their location using [expo-location]{@link https://docs.expo.dev/versions/latest/sdk/location/}.
         * If permission is granted, user location will be retrieved and stored in {@linkcode userLocationState}.
         * @memberOf Map
         */
        const requestLocationPermissions = async () => {
            let { status } = await Location.requestForegroundPermissionsAsync();
            if (status !== 'granted') {
                return;
            }

            const location = await Location.getCurrentPositionAsync();
            setUserLocation({latitude: location.coords.latitude, longitude: location.coords.longitude})   
            // setFetchedBounds({
            //     latitude: location.coords.latitude, 
            //     longitude: location.coords.longitude, 
            //     latitudeDelta: 0.01, 
            //     longitudeDelta: 0.01
            // });
        };

        requestLocationPermissions();
    }, []);

    /**
     * Triggered by long pressing on the map. 
     * Sets {@linkcode newLandmarkState} to a skeleton {@link Landmark} object that only contains the pressed coordinates.
     * Triggers {@link openAddLandmark} via useEffect because the asyncronous nature of useState does not set the coordinates fast enough to toggle the modal directly through this method.
     */
     const promptAddLandmark = (coordinate: LatLng) => {
        setNewLandmark({latitude: coordinate.latitude, longitude: coordinate.longitude});
    }

    useEffect(() => {
        /**
         * Opens the {@link AddLandmark} modal when the user creates {@link newLandmarkState} by longpressing the map. 
         * Embedded in a [useEffect]{@link https://reactjs.org/docs/hooks-effect.html} hook that listens to {@linkcode newLandmarkState}.
         * @memberOf Map
         */
        function openAddLandmark() {
            if (newLandmark) {
                toggleLmAdd(true)
                toggleLmDetails(false)   
                console.log(newLandmark)
            }
        }
        openAddLandmark();
    }, [newLandmark]);

    // useEffect(() => {
    //     if (prevFetchedBounds) {
    //         refetchLandmarks();
    //     }
    // }, [prevFetchedBounds]);

    /**
     * Triggered by on a {@link Landmark} displayed on the map. 
     * Sets {@linkcode selectedLandmark} to the pressed {@link Landmark} object's value and toggles the {@link LandmarkDetails} modal.
     */
    const focusLandmark = (landmark: Landmark) => {
        setSelectedLandmark(landmark);
        toggleLmDetails(true)
    }

    /**
     * Animates the map to fly over to and focus on the user's location.
     */
    const flyToUser = () => {
        if (userLocation) {
            mapRef.current?.animateToRegion({latitude: userLocation.latitude, longitude: userLocation.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01})
        }
    }

    // const checkBounds = (bounds: Region) => {
    //     if (prevFetchedBounds) {
    //         if (
    //             bounds.latitude < prevFetchedBounds.latitude - prevFetchedBounds.latitudeDelta || // check if new lat exceeds old left bounds
    //             bounds.latitude > prevFetchedBounds.latitude + prevFetchedBounds.latitudeDelta || // check if new lat exceeds old right bounds
    //             bounds.longitude < prevFetchedBounds.longitude - prevFetchedBounds.longitudeDelta || // check if new lat exceeds bottom bounds
    //             bounds.longitude > prevFetchedBounds.longitude + prevFetchedBounds.longitudeDelta || 
    //             bounds.latitudeDelta < prevFetchedBounds.latitudeDelta / 2) // check if user zoomed in to atleast half scale of previous
    //         {
    //             console.log('new bounds')
    //             setFetchedBounds(bounds);
    //         }   
    //     }
    //     else if (bounds.latitudeDelta < 5) {
    //         console.log('initialize bounds')
    //         setFetchedBounds(bounds);
    //     }
    // }

    return (
        <View>
            <MapView 
                testID="mapView"
                ref={mapRef} 
                style={{width: "100%", height: "100%"}} 
                initialRegion={userLocation ? {latitude: userLocation.latitude, longitude: userLocation.longitude, latitudeDelta: 0.01, longitudeDelta: 0.01} : undefined} 
                onLongPress={e => promptAddLandmark(e.nativeEvent.coordinate)} 
                // onRegionChangeComplete={bounds => checkBounds(bounds)}
                showsUserLocation={true} 
                onUserLocationChange={e => setUserLocation(e.nativeEvent.coordinate)}
                followsUserLocation={followUser}>
            {landmarks?.map((landmark) => {
                return (
                <LandmarkPin key={landmark.id} landmark={landmark} focusLandmark={focusLandmark}/>)})}
            </MapView>
            <TouchableOpacity style={{position: 'absolute', bottom: 30, right: 30, backgroundColor: colors.red, height: 60, width: 60, borderRadius: 30, justifyContent: "center", alignItems: 'center'}} onPress={flyToUser}>
                <FontAwesome name="location-arrow" size={20} color="white"/>
            </TouchableOpacity>
            {/* <TouchableOpacity style={{position: 'absolute', bottom: 120, right: 30, backgroundColor: colors.red, height: 60, width: 60, borderRadius: 30, justifyContent: "center", alignItems: 'center'}}>
                <FontAwesome name="" size={20} color="white"/>
            </TouchableOpacity> */}
            <Modal
                testID="addLMModal"
                avoidKeyboard={true}
                onBackdropPress={() => toggleLmAdd(false)}
                style={{justifyContent: "flex-end", height: '100%', margin: 0}}
                isVisible={lmAddVisible} >
                <KeyboardAvoidingView>
                    <AddLandmark setVisible={toggleLmAdd} landmark={newLandmark} />
                </KeyboardAvoidingView>
            </Modal>
            <Modal 
                avoidKeyboard={true}
                onBackdropPress={() => {
                    if (lmDetailsEditing) {
                        Keyboard.dismiss();
                    } else {
                        toggleLmDetails(false)   
                    }
                }}
                style={{justifyContent: "flex-end", height: '100%', margin: 0}}
                isVisible={lmDetailsVisible}>
                <LandmarkDetails setVisible={toggleLmDetails} setEditing={toggleLmDetailsEditing} editingEnabled={lmDetailsEditing} landmark={selectedLandmark} />
            </Modal>
        </View> )
}