DEV Community

Wern Ancheta
Wern Ancheta

Posted on • Originally published at pusher.com

Create a carpooling app with React Native - Part 2: Creating the frontend

Create a carpooling app with React Native - Part 2: Creating the app

This is the second part of a two-part series on creating a carpooling app with React Native. This is the part where we will be creating the actual app. I’ll be showing you how to set up the app so you can run it on an Android emulator (Genymotion) or an iOS device.

Prerequisites

This tutorial has the same prerequisites as the first part. The following needs to be set up on your machine:

  • React Native development environment
  • Docker and Docker Compose
  • Git

Additionally, you should have already a running server instance which is exposed to the internet via ngrok. Be sure to check out the first part if you haven’t set up any of these yet.

To effectively follow this tutorial, you should have a good grasp of the following React concepts:

  • props
  • refs
  • state
  • component lifecycle

As for building the app with React Native, knowing how to do the following will be helpful:

  • How to use primitive React Native components such as the View or Text.
  • How to add styles to the components.
  • How to create your own components.

What we’ll be building

The complete details on what we’ll be building are available in the first part of the series. As a refresher, we’ll be building a carpooling app. This allows the user to share the vehicle they’re currently riding in so someone else can hop in the same vehicle. The app is responsible for:

  • Matching the users so that only the users who are going the same route can share a ride with each other.
  • After two users are matched, the app provides realtime tracking on where each other currently are.

For the rest of the tutorial, I’ll be referring to the user who is sharing the ride as the “rider”. While the user who is searching for a ride as the “hiker”.

Installing the dependencies

Start by generating a new React Native project:

react-native init Ridesharer
Enter fullscreen mode Exit fullscreen mode

This will create a Ridesharer directory. This will serve as the root directory that we’ll be using for the rest of the tutorial.

The app relies on the following libraries to implement specific features:

To ensure that we’re both using the same package versions, open the package.json file and update the dependencies with the following:

"dependencies": {
  "axios": "0.18.0",
  "prop-types": "15.6.1",
  "pusher-js": "4.2.2",
  "react": "16.3.1",
  "react-native": "0.55.4",
  "react-native-geocoding": "0.3.0",
  "react-native-google-places-autocomplete": "1.3.6",
  "react-native-maps": "0.20.1",
  "react-native-maps-directions": "1.6.0",
  "react-native-vector-icons": "4.6.0",
  "react-navigation": "2.0.1"
},
Enter fullscreen mode Exit fullscreen mode

Once that’s done, save the file and execute npm install.

Setting up the dependencies

Now that you’ve installed all the dependencies, there’s one more thing you have to do before you can start coding the app. Additional setup is required for the following dependencies:

Instructions on how to set up the dependencies are available on the GitHub repos for each library. Here are the links to the setup instructions to the specific version we’re using:

Note that if you’re reading this sometime in the future, you’ll probably have to install the latest package versions and follow their latest installation instructions.

Building the app

Now we’re ready to build the app. Navigate inside the Ridesharer directory as that’s going to be our working directory.

Note that anytime you feel confused on where to add a specific code, you can always visit the GitHub repo and view the file.

Index

Open the index.js file and make sure you’re registering the same name that you used when you generated the project. In this case, it should be Ridesharer:

// Ridesharer/index.js
import { AppRegistry } from 'react-native';
import App from './App';

AppRegistry.registerComponent('Ridesharer', () => App);
Enter fullscreen mode Exit fullscreen mode

Root component

Create a Root.js file. This will serve as the Root component of the app. This is where we set up the navigation so we include the two pages of the app: Home and Map. We will be creating these pages later:

// Ridesharer/Root.js
import React from 'react';
import { StackNavigator } from 'react-navigation';

import HomePage from './app/screens/Home';
import MapPage from './app/screens/Map';

const RootStack = StackNavigator(
  {
    Home: {
      screen: HomePage
    },
    Map: {
      screen: MapPage
    }
  },
  { 
    initialRouteName: 'Home', // set the home page as the default page 
  }
);

export default RootStack;
Enter fullscreen mode Exit fullscreen mode

In the above code, we’re using the StackNavigator, one of the navigators that comes with the React Navigation library. This allows us to push and pop pages to and from a stack. Navigating to a page means pushing it in front of the stack, going back means popping the page that’s currently in front of the stack.

App component

Open the App.js file and render the App component:

// Ridesharer/App.js
import React, { Component } from 'react';
import {
  StyleSheet,
  View
} from 'react-native';

import Root from './Root';

export default class App extends Component {

  render() {
    return (
      <View style={styles.container}>
        <Root />
      </View>
    );
  }

}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff'
  }
});
Enter fullscreen mode Exit fullscreen mode

Tapper component

The Tapper component is simply a button created for convenience. We can’t really apply a custom style to the built-in React Native Button component so we’re creating this one. This component wraps the Button component in a View in which the styles are applied:

// Ridesharer/app/components/Tapper/Tapper.js
import React from 'react';
import { View, Button } from 'react-native';

import styles from './styles';

const Tapper = (props) => {
  return (
    <View style={styles.button_container}>
      <Button
        onPress={props.onPress}
        title={props.title}
        color={props.color}
      />
    </View>
  );
}

export default Tapper;
Enter fullscreen mode Exit fullscreen mode

Here’s the style declaration:

// Ridesharer/app/components/Tapper/styles.js
import { StyleSheet } from 'react-native';

export default StyleSheet.create({
  button_container: {
    margin: 10
  },
});
Enter fullscreen mode Exit fullscreen mode

Lastly, we export it using an index.js file so that we can simply refer to the component as Tapper without including the Tapper.js file in the import statement later on:

// Ridesharer/app/components/Tapper/index.js
import Tapper from './Tapper';

export default Tapper;
Enter fullscreen mode Exit fullscreen mode

If you don’t want to create a separate component, you can always use the TouchableOpacity and TouchableHighlight components. Those two allow you to add a custom style.

Home page

The Home page is the default page the user sees when they open the app.

Start by including all the React Native packages that we need:

// Ridesharer/app/screens/Home.js
import React, { Component } from 'react';
import { 
  View, 
  Text, 
  StyleSheet, 
  TextInput, 
  Alert, 
  ActivityIndicator, 
  PermissionsAndroid, 
  KeyboardAvoidingView 
} from 'react-native';
Enter fullscreen mode Exit fullscreen mode

Among the packages above, only these three warrants an explanation:

  • PermissionsAndroid - for asking permissions to use the device’s Geolocation feature on Android.
  • KeyboardAvoidingView - for automatically adjusting the View when the on-screen keyboard pops out. This allows the user to see what they’re inputting while the keyboard is open. Most of the time, especially on devices with small screen, the input is hidden when the keyboard is open.

Next, include the third-party packages we installed earlier:

import axios from 'axios';
import Icon from 'react-native-vector-icons/FontAwesome';
import Tapper from '../components/Tapper';
Enter fullscreen mode Exit fullscreen mode

Add your ngrok URL (this was created in the first part of the series):

const base_url = 'YOUR NGROK URL';
Enter fullscreen mode Exit fullscreen mode

Declare the function that will ask for the Geolocation permission and then call it:

async function requestGeolocationPermission() {
  try{
    const granted = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
      {
        'title': 'Ridesharer Geolocation Permission',
        'message': 'Ridesharer needs access to your current location so you can share or search for a ride'
      }
    );

    if(granted === PermissionsAndroid.RESULTS.GRANTED){
      console.log("You can use the geolocation")
    }else{
      console.log("Geolocation permission denied")
    }
  }catch(err){
    console.warn(err)
  }
}

requestGeolocationPermission();
Enter fullscreen mode Exit fullscreen mode

Hide the header. The Home page doesn’t need it:

export default class Home extends Component {
  static navigationOptions = {
    header: null,
  };
}
Enter fullscreen mode Exit fullscreen mode

Set the default state for the loader (for controlling the visibility of the ActivityIndicator) and username:

state = {
  is_loading: false,
  username: ''
}
Enter fullscreen mode Exit fullscreen mode

Render the Home page. In this page we have:

  • An input that asks for the user’s name
  • A button for sharing a ride
  • A button for hitching a ride

Note that we’re using the KeyboardAvoidingView as a wrapper. This way, everything inside it will adjust accordingly when the on-screen keyboard becomes visible:

render() {

  return (
    <KeyboardAvoidingView style={styles.container} behavior="padding" enabled>
      <View style={styles.jumbo_container}>
        <Icon name="question-circle" size={35} color="#464646" />
        <Text style={styles.jumbo_text}>What do you want to do?</Text>
      </View>

      <View>
        <TextInput
          placeholder="Enter your username"
          style={styles.text_field}
          onChangeText={(username) => this.setState({username})}
          value={this.state.username}
          clearButtonMode={"always"}
          returnKeyType={"done"}
        />
        <ActivityIndicator size="small" color="#007ff5" style={{marginTop: 10}} animating={this.state.is_loading} />
      </View>

      <View style={styles.close_container}>
        <Tapper
          title="Share a Ride"
          color="#007ff5"
          onPress={() => {
            this.enterUser('share');
          }}
        />

        <Tapper 
          title="Hitch a Ride" 
          color="#00bcf5" 
          onPress={() => {
            this.enterUser('hike');
          }} 
        />
      </View>

    </KeyboardAvoidingView>
  );
}
Enter fullscreen mode Exit fullscreen mode

When either of the buttons is pressed, the function below gets executed. All it does is create the user if they don’t already exist:

enterUser = (action) => {
  if(this.state.username){ // user should enter a username before they can enter

    this.setState({
      is_loading: true
    });

    // make a POST request to the server for creating the user
    axios.post(`${base_url}/save-user.php`, {
      username: this.state.username // the username entered in the text field
    })
    .then((response) => {

      if(response.data == 'ok'){
        // hide the ActivityIndicator
        this.setState({
          is_loading: false
        });

        // navigate to the Map page, submitting the user's action (ride or hike) and their username as a navigation param (so it becomes available on the Map page)
        this.props.navigation.navigate('Map', {
          action: action,
          username: this.state.username
        });
      }

    });

  }else{
    Alert.alert(
      'Username required',
      'Please enter a username'
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Add the styles for the Home page:

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'space-around'
  },
  jumbo_container: {
    padding: 50,
    alignItems: 'center'
  },
  jumbo_text: {
    marginTop: 20,
    textAlign: 'center',
    fontSize: 25,
    fontWeight: 'bold'
  },
  text_field: {
    width: 200,
    height: 50,
    padding: 10,
    backgroundColor: '#FFF', 
    borderColor: 'gray', 
    borderWidth: 1
  }
});
Enter fullscreen mode Exit fullscreen mode

Map page

The Map page contains the main meat of the app. This allows the user to share or search for a ride. The tracking of location is implemented via Google Maps, Pusher Channels, and React Native’s Geolocation feature.

Start by including all the React Native packages that we need:

// Ridesharer/app/screens/Map.js
import React, { Component } from 'react';
import { 
  View, 
  Text, 
  StyleSheet, 
  Alert, 
  Dimensions, 
  ActivityIndicator
} from 'react-native';
Enter fullscreen mode Exit fullscreen mode

Next, include the packages that we installed earlier:

import { GooglePlacesAutocomplete } from 'react-native-google-places-autocomplete';
import MapView, { Marker, Callout } from 'react-native-maps';
import MapViewDirections from 'react-native-maps-directions';
import Icon from 'react-native-vector-icons/FontAwesome';
import Pusher from 'pusher-js/react-native'; 
import Geocoder from 'react-native-geocoding';
import axios from 'axios';
Enter fullscreen mode Exit fullscreen mode

Include the location library. We will be creating this later, but for now, know that these functions are used to center the map correctly (regionFrom()) and getting the difference of two coordinates in meters (getLatLonDiffInMeters()):

import { regionFrom, getLatLonDiffInMeters } from '../lib/location';
import Tapper from '../components/Tapper';
Enter fullscreen mode Exit fullscreen mode

Initialize your API keys and ngrok base URL:

const google_api_key = 'YOUR GOOGLE PROJECT API KEY';
const base_url = 'YOUR NGROK BASE URL';
const pusher_app_key = 'YOUR PUSHER APP KEY';
const pusher_app_cluster = 'YOUR PUSHER APP CLUSTER';

Geocoder.init(google_api_key); // initialize the geocoder
Enter fullscreen mode Exit fullscreen mode

Next, also declare the timeouts for searching and sharing a ride. We will be using these values later to reset the app’s UI if it couldn’t match two users within these timeouts:

const search_timeout = 1000 * 60 * 10; // 10 minutes
const share_timeout = 1000 * 60 * 5; // 5 minutes
Enter fullscreen mode Exit fullscreen mode

Setup a default region that the map will display:

const default_region = {
  latitude: 37.78825,
  longitude: -122.4324,
  latitudeDelta: 0.0922,
  longitudeDelta: 0.0421,
};
Enter fullscreen mode Exit fullscreen mode

Get the device width. We will be using this later to set the width of the auto-complete text field for searching places:

var device_width = Dimensions.get('window').width; 
Enter fullscreen mode Exit fullscreen mode

Next, create the Map component and set the navigationOptions. Unlike the Home page earlier, we need to set a few options for the navigation. This includes the header title and the styles applied to it. Putting these navigation options will automatically add a back button to the header to allow the user to go back to the Home page:

export default class Map extends Component {

  static navigationOptions = ({navigation}) => ({
    headerTitle: 'Map',
    headerStyle: {
      backgroundColor: '#007ff5'
    },
    headerTitleStyle: {
      color: '#FFF'
    }
  });

  // next: add the code for initializing the state
}
Enter fullscreen mode Exit fullscreen mode

Next, initialize the state:

state = {
  start_location: null, // the coordinates (latitude and longitude values) of the user's origin
  end_location: null, // the coordinates of the user's destination
  region: default_region, // the region displayed in the map
  from: '', // the name of the place where the user is from (origin)
  to: '', // the name of the place where the user is going (destination)
  rider_location: null, // the coordinates of the rider's current location
  hiker_location: null, // the coordinates of the hiker's origin
  is_loading: false, // for controlling the visibility of the ActivityIndicator
  has_journey: false // whether the rider has accepted a hiker's request or a hiker's request has been accepted by a rider 
}

// next: add the constructor
Enter fullscreen mode Exit fullscreen mode

Next, add the constructor:

constructor(props) {
  super(props);
  this.from_region = null;
  this.watchId = null; // unique ID for the geolocation watcher. Storing it in a variable allows us to stop it at a later time (for example: when the user is done using the app)
  this.pusher = null; // variable for storing the Pusher instance
  this.user_channel = null; // the Pusher channel for the current user
  this.journey_id = null; // the hiker's route ID
  this.riders_channel = []; // if current user is a hiker, the value of this will be the riders channel
  this.users_channel = null; // the current user's channel
  this.hiker = null // for storing the hiker's origin coordinates; primarily used for getting the distance between the rider and the hiker
}
Enter fullscreen mode Exit fullscreen mode

Once the component is mounted, you want to get the username that was passed from the Home page earlier. This username is used later on as the unique key for identifying each user that connects to Pusher Channels:

componentDidMount() {
  const { navigation } = this.props;
  const username = navigation.getParam('username');

  this.pusher = new Pusher(pusher_app_key, {
    authEndpoint: `${base_url}/pusher-auth.php`,
    cluster: pusher_app_cluster,
    encrypted: true
  });  

  // next: add the code for subscribing to the current user's own channel
}  
Enter fullscreen mode Exit fullscreen mode

Next, add the code for subscribing to the current user's own channel. This allows the user to send and receive data in realtime through this channel. In the hiker’s case, they use it to make a request to the matching rider. In the rider’s case, they use it to receive requests coming from hikers as well as sending an acceptance and their current location to the hiker:

this.users_channel = this.pusher.subscribe(`private-user-${username}`); // note that the private-* is required when using private channels 
Enter fullscreen mode Exit fullscreen mode

When a rider receives a request, the code below is executed. This alerts the rider that someone wants to ride with them. They can either accept or decline it:

this.users_channel.bind('client-rider-request', (hiker) => {

  Alert.alert(
    `${hiker.username} wants to ride with you`,
    `Pickup: ${hiker.origin} \nDrop off: ${hiker.dest}`,
    [
      {
        text: "Decline",
        onPress: () => {
          // do nothing
        },
        style: "cancel"
      },
      {
        text: "Accept", 
        onPress: () => {
          this.acceptRide(hiker);
        }
      },
    ],
    { cancelable: false } // no cancel button
  );

});

// next: add code for getting the user's origin
Enter fullscreen mode Exit fullscreen mode

Note that in the code above, we’re not really handling declines. This is to keep the focus on the key feature of the app.

Next, get the user’s current location via the Geolocation API. At this point, we can already use the API without problems (unless the user didn’t approve the permission). We’ll just focus our attention on the “happy path” to keep things simple so we’ll assume that the user approved the permission request:

navigator.geolocation.getCurrentPosition(
  (position) => {
    // get the region (this return the latitude and longitude delta values to be used by React Native Maps)
    var region = regionFrom(
      position.coords.latitude, 
      position.coords.longitude, 
      position.coords.accuracy
    );

    // convert the coordinates to the descriptive name of the place
    Geocoder.from({
      latitude: position.coords.latitude,
      longitude: position.coords.longitude
    })
    .then((response) => {
      // the response object is the same as what's returned in the HTTP API: https://developers.google.com/maps/documentation/geocoding/intro

      this.from_region = region; // for storing the region in case the user presses the "reset" button

      // update the state to indicate the user's origin on the map (using a marker)
      this.setState({
        start_location: {
          latitude: position.coords.latitude,
          longitude: position.coords.longitude
        },
        region: region, // the region displayed on the map
        from: response.results[0].formatted_address // the descriptive name of the place
      });

    });

  }
);
Enter fullscreen mode Exit fullscreen mode

Next, add the acceptRide() function. This function is executed when the rider accepts a hiker’s ride request:

acceptRide = (hiker) => {

  const username = this.props.navigation.getParam('username');

  let rider_data = {
    username: username,
    origin: this.state.from, // descriptive name of the rider's origin
    dest: this.state.to, // descriptive name of the rider's destination
    coords: this.state.start_location // the rider's origin coordinates
  };

  this.users_channel.trigger('client-rider-accepted', rider_data); // inform hiker that the rider accepted their request; send along the rider's info

  // make a request to delete the route so other hikers can no longer search for it (remember the 1:1 ratio for a rider to hiker?)
  axios.post(`${base_url}/delete-route.php`, {
    username: username
  })
  .then((response) => {
    console.log(response.data);
  })
  .catch((err) => {
    console.log('error excluding rider: ', err);
  });

  this.hiker = hiker; // store the hiker's info

  // update the state to stop the loading animation and show the hiker's location
  this.setState({
    is_loading: false,
    has_journey: true,
    hiker_location: hiker.origin_coords
  });

}
Enter fullscreen mode Exit fullscreen mode

Next, add the function for rendering the UI:

render() {
  const { navigation } = this.props;
  // get the navigation params passed from the Home page earlier
  const action = navigation.getParam('action'); // action is either "ride" or "hike"
  const username = navigation.getParam('username');

  let action_button_label = (action == 'share') ? 'Share Ride' : 'Search Ride';

  // next: add code for rendering the UI
}
Enter fullscreen mode Exit fullscreen mode

The map UI contains the following:

  • MapView component for rendering the map. Inside it are the following:
    • Marker component for showing the origin and destination of the user, as well as for showing the location of the rider (if the user is a hiker), or the hiker (if the user is a rider).
    • MapViewDirections component for showing the route from the origin to the destination of the current user.
  • GooglePlacesAutocomplete component for rendering an auto-complete text field for searching and selecting a destination.
  • ActivityIndicator for showing a loading animation while the rider waits for someone to request a ride, or when the hiker waits for the app to find a matching rider.
  • Tapper component for sharing a ride or searching a ride.
  • Tapper component for resetting the selection (auto-complete text field and marker).
return (
  <View style={styles.container}>

    <MapView
      style={styles.map}
      region={this.state.region}
      zoomEnabled={true}
      zoomControlEnabled={true}
    >
      {
        this.state.start_location &&
        <Marker coordinate={this.state.start_location}>
          <Callout>
            <Text>You are here</Text>
          </Callout>
        </Marker>
      }

      {
        this.state.end_location &&
        <Marker
          pinColor="#4196ea"
          coordinate={this.state.end_location}
          draggable={true}
          onDragEnd={this.tweakDestination}
        />
      }

      {
        this.state.rider_location &&
        <Marker 
          pinColor="#25a25a"
          coordinate={this.state.rider_location}
        >
          <Callout>
            <Text>Rider is here</Text>
          </Callout>
        </Marker>
      }

      {
        this.state.hiker_location &&
        <Marker 
          pinColor="#25a25a"
          coordinate={this.state.hiker_location}
        >
          <Callout>
            <Text>Hiker is here</Text>
          </Callout>
        </Marker>
      }

      {
        this.state.start_location && this.state.end_location &&
        <MapViewDirections
          origin={{
            'latitude': this.state.start_location.latitude,
            'longitude': this.state.start_location.longitude
          }}
          destination={{
            'latitude': this.state.end_location.latitude,
            'longitude': this.state.end_location.longitude
          }}
          strokeWidth={5}
          strokeColor={"#2d8cea"}
          apikey={google_api_key}
        />
      }

    </MapView>

    <View style={styles.search_field_container}>

      <GooglePlacesAutocomplete
        ref="endlocation"
        placeholder='Where do you want to go?'
        minLength={5} 
        returnKeyType={'search'} 
        listViewDisplayed='auto' 
        fetchDetails={true}            
        onPress={this.selectDestination}

        query={{
          key: google_api_key,
          language: 'en', 
        }}

        styles={{
          textInputContainer: {
            width: '100%',
            backgroundColor: '#FFF'
          },
          listView: {
            backgroundColor: '#FFF'
          }
        }}
        debounce={200} 
      />
    </View>

    <ActivityIndicator size="small" color="#007ff5" style={{marginBottom: 10}} animating={this.state.is_loading} />

    {
      !this.state.is_loading && !this.state.has_journey &&
      <View style={styles.input_container}>

        <Tapper 
          title={action_button_label}
          color={"#007ff5"}
          onPress={() => {
            this.onPressActionButton();
          }} />

        <Tapper
          title={"Reset"}
          color={"#555"}
          onPress={this.resetSelection} 
        />

      </View>
    }

  </View>
);
Enter fullscreen mode Exit fullscreen mode

The code above should be pretty self-explanatory. If you’re unsure what a specific prop does, how the component works, or what children is it expecting, you can always check the Github repo of the package we’re using.

Next, let’s move on to the functions used in the UI. The resetSelection() is executed when the reset button is pressed by the user. This empties the auto-complete text field for searching for places, it also updates the state so the UI reverts back to its previous state before the destination was selected. This effectively removes the marker showing the user’s destination, as well as the route going to it:

resetSelection = () => {
  this.refs.endlocation.setAddressText('');
  this.setState({
    end_location: null,
    region: this.from_region,
    to: ''
  });
}
Enter fullscreen mode Exit fullscreen mode

The tweakDestination() function is executed when the user drops the destination marker somewhere else:

tweakDestination = () => {
  // get the name of the place
  Geocoder.from({
    latitude: evt.nativeEvent.coordinate.latitude,
    longitude: evt.nativeEvent.coordinate.longitude
  })
  .then((response) => {
    this.setState({
      to: response.results[0].formatted_address
    });
  });

  this.setState({
    end_location: evt.nativeEvent.coordinate
  });
}
Enter fullscreen mode Exit fullscreen mode

The selectDestination() function is executed when the user selects their destination. This function will update the state so it shows the user’s destination in the map:

selectDestination = (data, details = null) => {

  const latDelta = Number(details.geometry.viewport.northeast.lat) - Number(details.geometry.viewport.southwest.lat)
  const lngDelta = Number(details.geometry.viewport.northeast.lng) - Number(details.geometry.viewport.southwest.lng)

  let region = {
    latitude: details.geometry.location.lat,
    longitude: details.geometry.location.lng,
    latitudeDelta: latDelta,
    longitudeDelta: lngDelta
  };

  this.setState({
    end_location: {
      latitude: details.geometry.location.lat,
      longitude: details.geometry.location.lng,
    },
    region: region,
    to: this.refs.endlocation.getAddressText() // get the full address of the user's destination
  });

}
Enter fullscreen mode Exit fullscreen mode

When the user presses the Share a Ride or Search a Ride button, the onPressActionButton() function is executed. This executes either the shareRide() function or the hikeRide() function depending on the action selected from the Home page earlier:

onPressActionButton = () => {

  const action = this.props.navigation.getParam('action');
  const username = this.props.navigation.getParam('username');

  this.setState({
    is_loading: true
  });

  if(action == 'share'){
    this.shareRide(username);
  }else if(action == 'hike'){
    this.hikeRide(username);      
  }

}
Enter fullscreen mode Exit fullscreen mode

The shareRide() function is executed when a rider shares their ride after selecting a destination. This makes a request to the server to save the route. The response contains the unique ID assigned to the rider’s route. This ID is assigned as the value of this.journey_id. This will be used later to:

  • Make a request to the server to update the route record stored in the Elasticsearch index.
  • Know when to start doing something with the current location data. This is because the current position begins to be watched right after the user presses on the Share a Ride button as you’ll see on the code block after this:
shareRide = (username) => {

  axios.post(`${base_url}/save-route.php`, {
    username: username,
    from: this.state.from, 
    to: this.state.to, 
    start_location: this.state.start_location,
    end_location: this.state.end_location
  })
  .then((response) => {
    this.journey_id = response.data.id;
    Alert.alert(
      'Ride was shared!',
      'Wait until someone makes a request.'
    );
  })
  .catch((error) => {
    console.log('error occurred while saving route: ', error);
  });

  // next: add code for watching the rider's current location

}
Enter fullscreen mode Exit fullscreen mode

Next, start watching the user’s current location. Note that we won’t actually do anything with the location data unless the rider has already shared their ride and that they have already approved a hiker to ride with them. Once both conditions are met, we make a request to the server to update the previously saved route with the rider’s current location. This way, when a hiker searches for a ride, the results will be biased based on the rider’s current location and not their origin:

this.watchId = navigator.geolocation.watchPosition(
  (position) => {

    let latitude = position.coords.latitude;
    let longitude = position.coords.longitude;
    let accuracy = position.coords.accuracy;

    if(this.journey_id && this.hiker){ // needs to have a destination and a hiker
      // update the route with the rider's current location
      axios.post(`${base_url}/update-route.php`, {
        id: this.journey_id,
        lat: latitude,
        lon: longitude
      })
      .then((response) => {
        console.log(response);
      });

      // next: add code for sending rider's current location to the hiker

    }

  },
  (error) => {
    console.log('error occured while watching position: ', error);
  },
  { 
    enableHighAccuracy: true, // get more accurate location
    timeout: 20000, // timeout after 20 seconds of not being able to get location
    maximumAge: 2000, // location has to be atleast 2 seconds old for it to be relevant
    distanceFilter: 10 // allow up to 10-meter difference from the previous location before executing the callback function again
  }
);

// last: add code for resetting the UI after 5 minutes of sharing a ride 
Enter fullscreen mode Exit fullscreen mode

Next, we send a client-rider-location event to the rider’s own channel. Later, we’ll have the hiker subscribe to the rider’s channel (the one they matched with) so that they’ll receive the location updates:

let location_data = {
  username: username,
  lat: latitude,
  lon: longitude,
  accy: accuracy 
};

this.users_channel.trigger('client-rider-locationchange', location_data); // note: client-* is required when sending client events through Pusher

// update the state so that the rider’s current location is displayed on the map and indicated with a marker
this.setState({
  region: regionFrom(latitude, longitude, accuracy),
  start_location: {
    latitude: latitude,
    longitude: longitude
  }
});

// next: add code for updating the app based on how near the rider and hiker are from each other
Enter fullscreen mode Exit fullscreen mode

Next, we need to get the difference (in meters) between the rider’s coordinates and the hiker’s origin:

let diff_in_meters = getLatLonDiffInMeters(latitude, longitude, this.hiker.origin_coords.latitude, this.hiker.origin_coords.longitude);

if(diff_in_meters <= 20){
  this.resetUI();
}else if(diff_in_meters <= 50){
  Alert.alert(
    'Hiker is near',
    'Hiker is around 50 meters from your current location'
  );
}
Enter fullscreen mode Exit fullscreen mode

Next, add the code for resetting the UI after five minutes without anyone requesting to share a ride with the rider:

setTimeout(() => {
  this.resetUI();
}, share_timeout);
Enter fullscreen mode Exit fullscreen mode

Here’s the code for resetting the UI:

resetUI = () => {

  this.from_region = null;
  this.watchId = null; 
  this.pusher = null; 
  this.user_channel = null; 
  this.journey_id = null;
  this.riders_channel = []; 
  this.users_channel = null; 
  this.hiker = null;

  this.setState({
    start_location: null,
    end_location: null,
    region: default_region,
    from: '',
    to: '',
    rider_location: null, 
    hiker_location: null,
    is_loading: false,
    has_journey: false
  });

  this.props.navigation.goBack(); // go back to the Home page

  Alert.alert('Awesome!', 'Thanks for using the app!');

}
Enter fullscreen mode Exit fullscreen mode

Now let’s move on to the hiker’s side of things. When the hiker presses the Search a Ride button, the hikeRide() function is executed. This function is executed every five seconds until it finds a rider which matches the hiker’s route. If a rider cannot be found within ten minutes, the function stops. Once the server returns a suitable rider, it responds with the rider’s information (username, origin, destination, coordinates). This is then used to subscribe to the rider’s channel so the hiker can request for a ride and receive location updates. Note that this is done automatically, so the hiker doesn’t have control over who they share a ride with:

hikeRide = (username) => {

  var interval = setInterval(() => {
    // make a request to the server to get riders that matches the hiker's route
    axios.post(`${base_url}/search-routes.php`, {
      origin: this.state.start_location,
      dest: this.state.end_location
    })
    .then((response) => {

      if(response.data){

        clearInterval(interval); // assumes the rider will accept the request

        let rider = response.data; // the rider's info

        // subscribe to the rider's channel so the hiker can make a request and receive updates from the rider
        this.riders_channel = this.pusher.subscribe(`private-user-${rider.username}`);

        this.riders_channel.bind('pusher:subscription_succeeded', () => {
          // when subscription succeeds, make a request to the rider to share the ride with them
          this.riders_channel.trigger('client-rider-request', {
            username: username, // username of the hiker
            origin: this.state.from, // descriptive name of the hiker's origin
            dest: this.state.to, // descriptive name of the hiker's destination
            origin_coords: this.state.start_location // coordinates of the hiker's origin
          });
        });

        // next: add code for listening for when the rider accepts their request
      }      
    })
    .catch((error) => {
      console.log('error occurred while searching routes: ', error);
    });

  }, 5000);

  setTimeout(() => {
    clearInterval(interval);
    this.resetUI();
  }, ten_minutes);

}
Enter fullscreen mode Exit fullscreen mode

Once the rider accepts the ride request, the function below is executed:

this.riders_channel.bind('client-rider-accepted', (rider_data) => {
  Alert.alert(
    `${rider_data.username} accepted your request`,
    `You will now receive updates of their current location`
  );

  // update the map to show the rider's origin
  this.setState({
    is_loading: false,
    has_journey: true,
    rider_location: rider_data.coords
  });

  // next: add code for subscribing to the rider's location change
});
Enter fullscreen mode Exit fullscreen mode

As you’ve seen earlier, when the rider’s location changes, it triggers an event called client-rider-location-change. Any user who is subscribed to the rider’s channel and is listening for that event will get the location data in realtime:

this.riders_channel.bind('client-rider-locationchange', (data) => {
  // update the map with the rider's current location
  this.setState({
    region: regionFrom(data.lat, data.lon, data.accy),
    rider_location: {
      latitude: data.lat,
      longitude: data.lon
    }
  });

  let hikers_origin = this.state.start_location;
  let diff_in_meters = getLatLonDiffInMeters(data.lat, data.lon, hikers_origin.latitude, hikers_origin.longitude);

  if(diff_in_meters <= 20){
    this.resetUI();
  }else if(diff_in_meters <= 50){
    Alert.alert(
      'Rider is near',
      'Rider is around 50 meters from your location'
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

Add the styles for the Map page:

const styles = StyleSheet.create({
  container: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    justifyContent: 'flex-end',
    alignItems: 'center',
  },
  map: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
  },
  search_field_container: {
    height: 150, 
    width: device_width, 
    position: 'absolute', 
    top: 10
  },
  input_container: {
    alignSelf: 'center',
    backgroundColor: '#FFF',
    opacity: 0.80,
    marginBottom: 25
  }
});
Enter fullscreen mode Exit fullscreen mode

Location library

Here’s the code for getting the latitude and longitude delta values. As you have seen from the code earlier, this function is mainly used to get the region displayed on the map:

// Ridesharer/app/lib/location.js
export function regionFrom(lat, lon, accuracy) {
  const oneDegreeOfLongitudeInMeters = 111.32 * 1000;
  const circumference = (40075 / 360) * 1000;

  const latDelta = accuracy * (1 / (Math.cos(lat) * circumference));
  const lonDelta = (accuracy / oneDegreeOfLongitudeInMeters);

  return {
    latitude: lat,
    longitude: lon,
    latitudeDelta: Math.max(0, latDelta),
    longitudeDelta: Math.max(0, lonDelta)
  };
}
Enter fullscreen mode Exit fullscreen mode

And here’s the function for getting the difference (in meters) between two coordinates. This is mainly used for notifying the users when they’re already near each other, and to reset the app UI when they’re already very near each other:

export function getLatLonDiffInMeters(lat1, lon1, lat2, lon2) {
  var R = 6371; // radius of the earth in km
  var dLat = deg2rad(lat2-lat1);  // deg2rad below
  var dLon = deg2rad(lon2-lon1); 
  var a = 
    Math.sin(dLat/2) * Math.sin(dLat/2) +
    Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * 
    Math.sin(dLon/2) * Math.sin(dLon/2)
    ; 
  var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); 
  var d = R * c; // distance in km
  return d * 1000;
}
Enter fullscreen mode Exit fullscreen mode

The deg2rad() function used above converts the degrees value to radians:

function deg2rad(deg) {
  return deg * (Math.PI/180)
}
Enter fullscreen mode Exit fullscreen mode

Running the app

Before you can run the app on Android, you need to make sure you have the following Android SDK packages installed, you can find these under SDK Tools on the SDK manager:

  • Google Play services
  • Android Support Repository
  • Google Repository

If you’re going to test the app on Genymotion, you need to install Google Play services first. Since the app is using Google Maps, you need Google Play services for the feature to work. If you have version 2.10 or above, they provide an easy way to install it. Just click on Open GAPPS on a running emulator instance and go through the installation wizard. After that, restart the device and you should be good to go:

Install Google Play services on Genymotion

To run the app on Android, execute the following command. This will run the app either on an opened emulator instance (for example: Genymotion) or an Android device (if you have connected one):

react-native run-android
Enter fullscreen mode Exit fullscreen mode

If you’re having problems with getting the app to run on Android, be sure to check my article on Debugging common React Native issues on Android.

For iOS, you just have to make sure you have the latest version of Xcode installed. Note that if you want to run the app on a device, you can only do it via Xcode by opening the .xcworkspace file.

To run the app on an iOS device, select your device on Xcode and click the big play button.

To run the app in the iOS simulator, you can also do it via Xcode using the method above. But if you want to run it from the terminal, you can execute the following command from the root directory of your project:

react-native run-ios
Enter fullscreen mode Exit fullscreen mode

If you want to run the app on a specific simulator, you first have to list which devices are available:

xcrun simctl list devicetypes
Enter fullscreen mode Exit fullscreen mode

This will return the list of devices:

Device types

You can then copy the device name (for example: iPhone 5s) and specify it as a value for the --simulator option:

react-native run-ios --simulator="iPhone 5s"
Enter fullscreen mode Exit fullscreen mode

If you’re having problems with running the app on an iOS simulator or device, be sure to check my article on Debugging common React Native issues on iOS.

Conclusion

That’s it! In this series, you’ve learned how to create a carpooling app with React Native. Along the way, you also learned the following:

  • How to use axios to make requests to the server.
  • How to use React Native’s Geolocation feature.
  • How to add Google Play Services to Genymotion.
  • How to use Genymotion’s GPS emulation tool.
  • How to use Pusher Channels.
  • How to use Google’s Geocoding API.

You can find all the codes used in this series on this GitHub repo.

Originally published on the Pusher tutorial hub.

Top comments (0)