Create a food ordering app in React Native - Part 2: Adding the driver app and chat functionality
You will need Node 11.10+, Yarn 1.17+, React Native CLI 2+ and React Native 0.61+ installed on your machine.
This is part two of a three-part series on creating a food delivery app with React Native.
In this part, we’ll be going through the code for the driver app. After that, we’ll be adding the chat functionality so that the customer and the driver can contact each other.
Prerequisites
This part has the same prerequisites as the first part.
You’ll need a ChatKit account for implementing the chat functionality.
IMPORTANT:
ChatKit has been retired from the Pusher product stack as of April, 2020. This tutorial will no longer be functional in its current version. Explore our other Pusher tutorials to find out how Channels and Beams can help you build.
Feature overview
We will be creating the driver app in this part of the series. Here’s what the driver will see by default when they open the app. Basically, it’s just showing a map of where the driver currently is. In the header, there’s a button that the driver can click to update the status of the order. By default, there will be no button in there. But once the driver has accepted an order, it will say PICKED ORDER. The driver can click on this button to send an update to the customer that they have picked their order from the restaurant. It will then turn to DELIVERED ORDER and the driver can click on it once they have delivered the order to the customer:
When a driver receives an order, a modal which displays the pick up (restaurant’s location) and drop off (customer’s location) will show up. From here, the driver can either accept or decline:
Once the driver agrees, the restaurant’s and customer’s location is plotted on the map along with the route between the two. The map is updated in realtime as the driver drives to their destination:
Once the driver has picked the order, a new button will be overlayed on top of the map. This will allow the driver to contact the customer. This will be useful in cases wherein the address is in a subdivision or a building:
Here’s what the chat interface looks like:
You can find the code on this GitHub repo. The completed code for this part of the series is on the driver-app
branch.
Setting up Chatkit
On your Chatkit dashboard, click on the + CREATE button to create a new Chatkit app instance:
Under the Credentials tab, enable the test token provider. This allows us to use Pusher’s servers for authenticating users of our app. Note that this is only for testing purposes. If you’re going to use this for production, be sure to check out the Authentication docs so you can create your own authentication server.
Bootstrapping the driver app
If you’ve followed part one of this series, there’s really no starter code for the driver app so you’ll have to create everything from scratch. Start by generating a new React Native project:
react-native init RNFoodDeliveryDriver
Once that’s done, open the package.json
file and replace the dependencies
with the following:
"dependencies": {
"@pusher/chatkit-client": "^1.13.1",
"@react-native-community/netinfo": "^4.4.0",
"axios": "^0.19.0",
"pusher-js": "^5.0.2",
"react": "16.9.0",
"react-native": "0.61.2",
"react-native-config": "^0.11.7",
"react-native-geocoding": "^0.4.0",
"react-native-geolocation-service": "^3.1.0",
"react-native-gesture-handler": "^1.4.1",
"react-native-gifted-chat": "^0.11.0",
"react-native-maps": "0.25.0",
"react-native-maps-directions": "^1.7.3",
"react-native-modal": "^11.4.0",
"react-native-permissions": "^2.0.2",
"react-native-reanimated": "^1.3.0",
"react-native-screens": "^1.0.0-alpha.23",
"react-navigation": "^4.0.10",
"react-navigation-stack": "^1.9.4"
},
Install the dependencies:
yarn install
The driver app depends on the same packages as the ordering app so you can go ahead and re-visit the Bootstrapping the app section of part one for the instructions on how to configure the packages.
Next, copy over the src/helpers/location.js
file from the ordering app to the root directory of the driver app as we’re going to use the same helper function.
Lastly, create a .env
file at the root of the project directory and add your Channels, Chatkit, and Google API credentials. We’ll add the ngrok URL later:
CHANNELS_APP_KEY="YOUR CHANNELS APP KEY"
CHANNELS_APP_CLUSTER="YOUR CHANNELS APP CLUSTER"
CHATKIT_INSTANCE_LOCATOR_ID="YOUR CHATKIT INSTANCE LOCATOR ID"
CHATKIT_SECRET_KEY="YOUR CHATKIT SECRET KEY"
CHATKIT_TOKEN_PROVIDER_ENDPOINT="YOUR CHATKIT TOKEN PROVIDER ENDPOINT"
GOOGLE_API_KEY="YOUR GOOGLE API KEY"
NGROK_HTTPS_URL="YOUR NGROK HTTPS URL"
Setting up the ordering app
If you’ve followed part one of this series, the only thing you need to do is include the additional packages that we need:
"dependencies": {
// ...
"@pusher/chatkit-client": "^1.13.1",
"react-native-gifted-chat": "^0.11.0",
"string-random": "^0.1.3"
}
Here’s a brief overview of each:
- @pusher/chatkit-client - the client-side library of Chatkit. We use this for easily implementing chat functionality.
- react-native-gifted-chat - used for easily building chat UI.
- string-random - used for generating random room IDs for chat rooms.
Install them once added:
yarn install
Setting up the server
To set up the server, all you need is install the Chatkit server SDK for Node.js:
yarn add @pusher/chatkit-server
Creating the driver app
Now we’re ready to build the driver app. Start by updating the App.js
file with the following:
import React, {Fragment} from 'react';
import {SafeAreaView, StatusBar, View, StyleSheet} from 'react-native';
import Root from './Root';
const App = () => {
return (
<Fragment>
<StatusBar barStyle="light-content" />
<SafeAreaView style={styles.container}>
<Root />
</SafeAreaView>
</Fragment>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default App;
Then create a Root.js
file at the root of the project directory. This is where we use React Navigation to build a stack navigator (navigates by pushing and popping screens from a stack):
import React, {Component} from 'react';
import {YellowBox} from 'react-native';
import {createAppContainer} from 'react-navigation';
import {createStackNavigator} from 'react-navigation-stack';
import OrderMap from './src/screens/OrderMap';
YellowBox.ignoreWarnings(['Setting a timer']);
const RootStack = createStackNavigator(
{
OrderMap,
},
{
initialRouteName: 'OrderMap',
},
);
const AppContainer = createAppContainer(RootStack);
class Router extends Component {
render() {
return <AppContainer />;
}
}
export default Router;
Next, create the src/screens/OrderMap.js
file. This will serve as the main screen for the driver. As you’ve seen in the feature overview earlier, this is where the driver can wait for orders and either accept or decline them. Start by importing the packages we need:
import React, {Component} from 'react';
import {
View,
Text,
Button,
TouchableOpacity,
Alert,
StyleSheet,
} from 'react-native';
import MapView from 'react-native-maps';
import Pusher from 'pusher-js/react-native';
import {check, request, PERMISSIONS, RESULTS} from 'react-native-permissions';
import Geolocation from 'react-native-geolocation-service';
import Modal from 'react-native-modal';
import Config from 'react-native-config';
import MapViewDirections from 'react-native-maps-directions';
import axios from 'axios';
import {regionFrom} from '../helpers/location';
const CHANNELS_APP_KEY = Config.CHANNELS_APP_KEY;
const CHANNELS_APP_CLUSTER = Config.CHANNELS_APP_CLUSTER;
const BASE_URL = Config.NGROK_HTTPS_URL;
const GOOGLE_API_KEY = Config.GOOGLE_API_KEY;
Next, add the navigation header. This is where we optionally render the main action button for the driver. The action it performs depends on which step of the order process they currently are. So we use a navigation param to store the title
and the onPress
action:
class OrderMap extends Component {
static navigationOptions = ({navigation}) => {
const showHeaderButton = navigation.getParam('showHeaderButton');
return {
title: 'Order Map',
headerRight: showHeaderButton ? (
<View style={styles.navHeaderRight}>
<Button
onPress={navigation.getParam('headerButtonAction')}
title={navigation.getParam('headerButtonLabel')}
color="#e19400"
/>
</View>
) : null,
};
};
// next: initialize state
}
Next, initialize the state:
state = {
locationPermission: 'undetermined',
isOrderDetailsModalVisible: false,
customer: null, // customer info
currentLocation: null, // driver's current location
hasOrder: false, // whether the driver is currently handling an order or not
restaurantAddress: '',
customerAddress: '',
};
// next: add constructor()
Inside the constructor, initialize the communication channels. This uses pretty much the same channels as the ordering app so I’ll no longer elaborate on it:
constructor(props) {
super(props);
this.available_drivers_channel = null; // this is where customer will send a request to any available driver
this.ride_channel = null; // the channel used for communicating the current location
// for a specific order. Channel name is the customer's username
this.pusher = null; // the pusher client
}
Inside componentDidMount()
, we set the default navigation params for the main action button. By default, the action is to set the order as “picked”. Note that the button wouldn’t be visible until the driver accepts an order:
async componentDidMount() {
this.props.navigation.setParams({
headerButtonLabel: 'Picked Order',
headerButtonAction: this._pickedOrder,
});
// next: subscribe to channels
}
Next, subscribe to the same channel as the one we subscribed to earlier in part one with the ordering app. This time, we’re implementing the receiving end of the order request. When the driver receives an order request, we update the state with the order details and show the order details modal:
this.pusher = new Pusher(CHANNELS_APP_KEY, {
authEndpoint: `${BASE_URL}/pusher/auth`,
cluster: CHANNELS_APP_CLUSTER,
encrypted: true,
});
this.available_drivers_channel = this.pusher.subscribe(
'private-available-drivers',
); // subscribe to "available-drivers" channel
this.available_drivers_channel.bind('pusher:subscription_succeeded', () => {
this.available_drivers_channel.bind(
'client-driver-request',
order_data => {
if (!this.state.hasOrder) {
// if the driver has currently no order
this.setState({
isOrderDetailsModalVisible: true,
customer: order_data.customer,
restaurantLocation: {
latitude: order_data.restaurant_location[0],
longitude: order_data.restaurant_location[1],
},
customerLocation: order_data.customer_location,
restaurantAddress: order_data.restaurant_address,
customerAddress: order_data.customer_address,
});
}
},
);
});
// next: add code for getting driver's current location
Next, we request for the location permission and get the current location. We use it for setting the initial location to be displayed on the map. After that, we start watching for location changes and trigger the event for updating the location on the customer’s side when an order is already accepted by the driver:
let location_permission = await check(
PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
);
if (location_permission === 'denied') {
location_permission = await request(
PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
);
}
if (location_permission === 'granted') {
Geolocation.getCurrentPosition(
position => {
const {latitude, longitude, accuracy} = position.coords;
const initialRegion = regionFrom(latitude, longitude, accuracy);
this.setState({
initialRegion,
});
},
error => {
console.log(error.code, error.message);
},
{enableHighAccuracy: true, timeout: 15000, maximumAge: 10000},
);
this.watch_location_id = Geolocation.watchPosition(
position => {
this.setState({
currentLocation: position.coords,
});
if (this.state.hasOrder) {
this.ride_channel.trigger('client-driver-location', {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
});
}
},
error => {
console.log(error.code, error.message);
},
{enableHighAccuracy: true},
);
}
Here’s the render()
method. This has pretty much the same code as the TrackOrder screen screen in the ordering app. The only difference is the order details modal. We also have the button for contacting the customer which is overlayed on top of the map:
render() {
const {
isOrderDetailsModalVisible,
restaurantAddress,
customerAddress,
currentLocation, // driver's current location
restaurantLocation,
customerLocation,
initialRegion,
} = this.state;
return (
<View style={styles.wrapper}>
<MapView initialRegion={initialRegion} style={styles.map}>
{currentLocation && (
<MapView.Marker
coordinate={{
latitude: currentLocation.latitude,
longitude: currentLocation.longitude,
}}
title={"You're here"}
/>
)}
{currentLocation && restaurantLocation && (
<MapViewDirections
origin={currentLocation}
destination={restaurantLocation}
apikey={GOOGLE_API_KEY}
strokeWidth={3}
strokeColor="hotpink"
/>
)}
{restaurantLocation && customerLocation && (
<MapViewDirections
origin={restaurantLocation}
destination={customerLocation}
apikey={GOOGLE_API_KEY}
strokeWidth={3}
strokeColor="#1b77fb"
/>
)}
{restaurantLocation && (
<MapView.Marker
coordinate={{
latitude: restaurantLocation.latitude,
longitude: restaurantLocation.longitude,
}}
title={'Restaurant is here'}
pinColor={'#4CDB00'}
/>
)}
{customerLocation && (
<MapView.Marker
coordinate={{
latitude: customerLocation.latitude,
longitude: customerLocation.longitude,
}}
title={'Your customer is here'}
pinColor={'#6f42c1'}
/>
)}
</MapView>
<View style={styles.floatingButtonContainer}>
<Button
onPress={this._contactCustomer}
title={'Contact customer'}
color="#c53c3c"
/>
</View>
<Modal isVisible={isOrderDetailsModalVisible}>
{restaurantAddress && (
<View style={styles.modal}>
<TouchableOpacity onPress={this._hideOrderDetailsModal}>
<Text style={styles.close}>Close</Text>
</TouchableOpacity>
<View style={styles.modalBody}>
<View style={styles.addressContainer}>
<Text style={styles.labelText}>Pick up</Text>
<Text style={styles.valueText}>
{restaurantAddress.replace(',', '\n')}
</Text>
</View>
<View style={styles.addressContainer}>
<Text style={styles.labelText}>Drop off</Text>
<Text style={styles.valueText}>
{customerAddress.replace(',', '\n')}
</Text>
</View>
<View style={styles.buttonContainer}>
<Button
onPress={this._acceptOrder}
title={'Accept order'}
color="#28a745"
/>
</View>
<View style={styles.buttonContainer}>
<Button
onPress={this._declineOrder}
title={'Decline order'}
color="#c53c3c"
/>
</View>
</View>
</View>
)}
</Modal>
</View>
);
}
Next, add the code for accepting an order. When this happens, we subscribe to the customer’s channel then trigger an event that tells them they’re available. This is the part where the customer then triggers the client-driver-response
on their side to confirm that they still haven’t found another driver. When this happens, the driver triggers the client-found-driver
event to the customer to inform them that they accepted their request. We also trigger a client-order-update
event to update the order status:
_acceptOrder = () => {
const {customer, currentLocation} = this.state;
this.setState({
isOrderDetailsModalVisible: false,
});
this.ride_channel = this.pusher.subscribe(
'private-ride-' + customer.username,
);
this.ride_channel.bind('pusher:subscription_succeeded', () => {
this.ride_channel.trigger('client-driver-response', {
response: 'yes', // yes, I'm available
});
// listen for the acknowledgement from the customer
this.ride_channel.bind('client-driver-response', customer_response => {
if (customer_response.response == 'yes') {
this.setState({
hasOrder: true,
});
this.props.navigation.setParams({
showHeaderButton: true, // show the main action button
});
this.ride_channel.trigger('client-found-driver', {
driver: {
name: this.user_name,
},
location: {
latitude: currentLocation.latitude,
longitude: currentLocation.longitude,
accuracy: currentLocation.accuracy,
},
});
setTimeout(() => {
// update order status
this.ride_channel.trigger('client-order-update', {
step: 1,
});
}, 2000);
} else {
Alert.alert(
'Order no longer available',
'Someone else already took the order. Or the customer cancelled.',
[
{
text: 'Ok',
},
],
{cancelable: false},
);
}
});
});
};
Here’s the method for handling the onPress
event on the close button of the order details modal. This is equivalent to declining the order, but I’ll leave that to you to implement. It will have the same code as the _declineOrder()
method:
_hideOrderDetailsModal = () => {
this.setState({
isOrderDetailsModalVisible: false,
});
// homework: add code for declining order
};
_declineOrder = () => {
// homework: add code for declining order
};
Here’s the first action for the main action button. This will update the customer that the driver has picked their order from the restaurant. This will also change the action of the main action button to that of setting the order as delivered:
_pickedOrder = async () => {
this.props.navigation.setParams({
headerButtonLabel: 'Delivered Order',
headerButtonAction: this._deliveredOrder,
});
this.ride_channel.trigger('client-order-update', {
step: 2,
});
};
Here’s the _deliveredOrder()
method. This updates the customer that their order has been delivered at their doorstep. Having completed the delivery means that the order has been completely processed so we reset the UI as well:
_deliveredOrder = () => {
this.ride_channel.unbind('client-driver-response'); // unbind from passenger event
this.pusher.unsubscribe('private-ride-' + this.state.customer.username); // unsubscribe from passenger channel
this.setState({
hasOrder: false,
customer: null,
restaurantLocation: null,
customerLocation: null,
restaurantAddress: null,
customerAddress: null,
});
this.props.navigation.setParams({
headerButtonLabel: 'Picked Order',
headerButtonAction: this._pickedOrder,
});
this.ride_channel.trigger('client-order-update', {
step: 3,
});
};
Before the component is unmounted, we clean up after ourselves by stopping the app from watching the driver’s location:
componentWillUnmount() {
Geolocation.clearWatch(this.watch_location_id);
}
Lastly, add the styles and export the component:
class OrderMap extends Component {
// ..
}
const styles = StyleSheet.create({
navHeaderRight: {
marginRight: 10,
},
wrapper: {
flex: 1,
},
map: {
...StyleSheet.absoluteFillObject,
},
floatingButtonContainer: {
position: 'absolute',
bottom: '2%',
left: '2%',
alignSelf: 'flex-end',
},
modal: {
flex: 1,
backgroundColor: '#FFF',
padding: 20,
},
close: {
alignSelf: 'flex-end',
marginBottom: 10,
color: '#0366d6',
},
modalBody: {
marginTop: 20,
},
addressContainer: {
marginBottom: 20,
},
labelText: {
fontSize: 18,
fontWeight: 'bold',
},
valueText: {
fontSize: 16,
color: '#333',
},
buttonContainer: {
marginBottom: 10,
},
});
export default OrderMap;
Adding the chat functionality
Now that we’ve created the driver app, it’s time to implement the chat functionality on both apps. We’ll first implement the chat for the driver app.
Driver app: OrderMap screen
First, open the src/screens/OrderMap.js
file and import axios
:
import MapViewDirections from 'react-native-maps-directions';
import axios from 'axios'; // add this
Then in the constructor, add the user_id
. This will serve as the unique ID for the user in Chatkit:
constructor(props) {
// ..
this.pusher = null;
// add these:
this.user_name = 'John Doe';
this.user_id = 'johndoe'; // add this
}
Next, update componentDidMount()
to make a request to the server to log in the user in Chatkit. Note that this doesn’t actually log in the user. It’s purpose is to create a corresponding Chatkit user for the driver if it doesn’t already exists. You’ll see this in the server code later on:
async componentDidMount() {
// ...
if (location_permission === 'granted') {
// ...
}
// add these
try {
await axios.post(`${BASE_URL}/login`, {
user_id: this.user_id,
user_name: this.user_name,
});
} catch (err) {
console.log('error creating user: ', err);
}
}
Next, update the code for accepting orders so that it extracts the room_id
and room_name
for the chat room. We will add this on the code for the ordering app later on:
_acceptOrder = () => {
// ...
this.props.navigation.setParams({
showHeaderButton: true,
});
this.ride_channel.bind('pusher:subscription_succeeded', () => {
// ..
this.ride_channel.bind('client-driver-response', customer_response => {
if (customer_response.response == 'yes') {
// ..
// add these:
const {room_id, room_name} = customer_response;
this.room_id = room_id;
this.room_name = room_name;
// end add
this.ride_channel.trigger('client-found-driver', {
// ...
});
// ...
} else {
// ...
}
}
}
// ..
}
Next update the code for setting the order as “picked” so that it makes a request to let the driver join the chat room. We’ll add the code for this later on once we get to the server part:
_pickedOrder = () => {
// ...
this.ride_channel.trigger('client-order-update', {
step: 2,
});
// add these:
try {
await axios.post(`${BASE_URL}/room`, {
room_id: this.room_id,
room_name: this.room_name,
user_id: this.user_id,
});
} catch (room_err) {
console.log('room error: ', room_err);
}
}
When the contact customer button is clicked, we navigate to the ContactCustomer
screen. We pass along the user_id
and room_id
as navigation params because we’ll need it to subscribe to the chat room:
_contactCustomer = () => {
this.props.navigation.navigate('ContactCustomer', {
user_id: this.user_id,
room_id: this.room_id,
});
};
Driver app: ContactCustomer screen
Now we proceed to implementing the actual chat functionality. Create a src/screens/ContactCustomer.js
file and add the following. We’re using Chatkit and Gifted Chat to easily implement the chat functionality:
import React, {Component} from 'react';
import {View, StyleSheet} from 'react-native';
import {GiftedChat} from 'react-native-gifted-chat';
import {ChatManager, TokenProvider} from '@pusher/chatkit-client';
import Config from 'react-native-config';
const CHATKIT_INSTANCE_LOCATOR_ID = Config.CHATKIT_INSTANCE_LOCATOR_ID;
const CHATKIT_SECRET_KEY = Config.CHATKIT_SECRET_KEY;
const CHATKIT_TOKEN_PROVIDER_ENDPOINT = Config.CHATKIT_TOKEN_PROVIDER_ENDPOINT;
Next, create the component class. Initialize the messages
state and extract the navigation params that were passed from the OrderMap
screen earlier:
class ContactCustomer extends Component {
static navigationOptions = ({navigation}) => {
return {
title: 'Contact Customer',
};
};
state = {
messages: [], // array of messages that are sent in the room
};
constructor(props) {
super(props);
this.user_id = this.props.navigation.getParam('user_id');
this.room_id = this.props.navigation.getParam('room_id');
}
// next: add componentDidMount()
}
Next, connect to Chatkit and subscribe to the room that was specifically created for this order. This allows us to attach to the onMessage
hook which gets called everytime a new message is sent in the room (including the one’s sent by the current user):
async componentDidMount() {
try {
const chatManager = new ChatManager({
instanceLocator: CHATKIT_INSTANCE_LOCATOR_ID,
userId: this.user_id,
tokenProvider: new TokenProvider({
url: CHATKIT_TOKEN_PROVIDER_ENDPOINT,
}),
});
let currentUser = await chatManager.connect();
this.currentUser = currentUser;
await this.currentUser.subscribeToRoomMultipart({
roomId: this.room_id,
hooks: {
onMessage: this._onMessage,
},
messageLimit: 30,
});
} catch (err) {
console.log('chatkit error: ', err);
}
}
Here’s the render()
method. This uses Gifted Chat to render a chat UI. All it requires us to supply is the array of messages
, the function to execute when a message is sent, and the user ID of the current user. This is what Gifted Chat uses to determine which side of the chat UI a specific message will be rendered. If the message contains the same user ID specified here, then it will be rendered on the right side to indicate that it’s the current user:
render() {
const {messages} = this.state;
return (
<View style={styles.wrapper}>
<GiftedChat
messages={messages}
onSend={messages => this._onSend(messages)}
showUserAvatar={true}
user={{
_id: this.user_id,
}}
/>
</View>
);
}
Here’s the onSend()
method. This gets executed when the user clicks on the send button in the chat UI. This uses Chatkit’s sendSimpleMessage()
method to send a simple text message. You can also send multipart messages if you want to attach photos or other media to the message:
_onSend = async ([message]) => {
try {
await this.currentUser.sendSimpleMessage({
roomId: this.room_id,
text: message.text,
});
} catch (send_msg_err) {
console.log('error sending message: ', send_msg_err);
}
};
Here’s the function that gets executed whenever a new message is received. This will create a new array consisting of the current messages with the new message appended at the end:
_onMessage = data => {
const {message} = this._getMessage(data);
this.setState(previousState => ({
messages: GiftedChat.append(previousState.messages, message),
}));
};
Here’s the _getMessage()
function. This creates a new object consisting only of the data required by Gifted Chat in order to render a message. Note that even though we’re only sending simple text messages, we still have to extract the inline
part to get the actual text as messages still follow the same multi-part structure regardless of whether they’re sent as a simple message or multi-part:
_getMessage = ({id, sender, parts, createdAt}) => {
const text = parts.find(part => part.partType === 'inline').payload.content;
const msg_data = {
_id: id,
text: text,
createdAt: new Date(createdAt),
user: {
_id: sender.id.toString(),
name: sender.name,
avatar: `https://na.ui-avatars.com/api/?name=${sender.name}`,
},
};
return {
message: msg_data,
};
};
Lastly, in your Root.js
file, add the ContactCustomer
screen:
import OrderMap from './src/screens/OrderMap';
import ContactCustomer from './src/screens/ContactCustomer';
const RootStack = createStackNavigator(
{
OrderMap,
ContactCustomer,
},
{
initialRouteName: 'OrderMap',
},
);
Ordering app: OrderSummary screen
Next, we now proceed to the ordering app. Open the src/screens/OrderSummary.js
file and import the two additional packages that we need:
Geocoder.init(GOOGLE_API_KEY);
// add these
const random = require('string-random');
import axios from 'axios';
Next, update the placeOrder()
method so it generates a random room ID for the chat room. We’ll be needing this along with the room name later so we store it in the global app state. We’ll update the GlobalContext.js
file later to add this method. After that, make a request to log in the user and create the chat room:
placeOrder = async () => {
const {customer_location, customer_address} = this.state;
// add these
const room_id = random();
const room_name = `Order ${room_id}`;
this.context.setRoom(room_id, room_name);
// end
const {
address: restaurant_address,
location: restaurant_location,
} = this.context.cart_items[0].restaurant;
// add these
try {
// creates the Chatkit user if it doesn't already exists
await axios.post(`${BASE_URL}/login`, {
user_id: this.context.user_id,
user_name: this.context.user_name,
});
// create the Chatkit room
await axios.post(`${BASE_URL}/room`, {
room_id,
room_name: room_name,
user_id: this.context.user_id,
});
} catch (err) {
console.log('login err: ', err);
}
}
Ordering app: TrackOrder screen
Open the src/screens/TrackOrder.js
file and update the client-driver-response
event to include the room_id
and room_name
. Since we saved it in the global app state earlier, we can now access it via this.context
:
componentDidMount() {
// ...
this.user_ride_channel = this.pusher.subscribe(
'private-ride-' + this.context.user_id,
);
// update this:
this.user_ride_channel.bind('client-driver-response', data => {
const {hasDriver} = this.state;
this.user_ride_channel.trigger('client-driver-response', {
response: hasDriver ? 'no' : 'yes',
// add these
room_id: hasDriver ? '0' : this.context.room_id,
room_name: hasDriver ? '' : this.context.room_name,
});
});
}
After that, add the code for navigating to the ContactDriver
screen:
contactDriver = () => {
this.props.navigation.navigate('ContactDriver');
};
Ordering app: GlobalContext
Open the GlobalContext.js
file and add the initial value for the room_id
and room_name
:
export class AppContextProvider extends React.Component {
state = {
cart_items: [],
user_id: 'wernancheta',
user_name: 'Wern Ancheta',
// add these:
room_id: '',
room_name: '',
};
}
Then add the method for updating them:
setRoom = (id, name) => {
this.setState({
room_id: id,
room_name: name,
});
};
Don’t forget to add setRoom()
method as a value:
render() {
return (
<AppContext.Provider
value={{
...this.state,
addToCart: this.addToCart,
setRoom: this.setRoom,
}}>
{this.props.children}
</AppContext.Provider>
);
}
Ordering app: ContactDriver screen
Create a src/screens/ContactDriver.js
file and add the following. This has pretty much the same code as the src/screens/ContactCustomer.js
file from the driver app earlier. The only difference is that we’re using the user_id
and room_id
from the context and not from the navigation params:
import React, {Component} from 'react';
import {View, StyleSheet} from 'react-native';
import {GiftedChat} from 'react-native-gifted-chat';
import {ChatManager, TokenProvider} from '@pusher/chatkit-client';
import Config from 'react-native-config';
const CHATKIT_INSTANCE_LOCATOR_ID = Config.CHATKIT_INSTANCE_LOCATOR_ID;
const CHATKIT_SECRET_KEY = Config.CHATKIT_SECRET_KEY;
const CHATKIT_TOKEN_PROVIDER_ENDPOINT = Config.CHATKIT_TOKEN_PROVIDER_ENDPOINT;
import {AppContext} from '../../GlobalContext';
class ContactDriver extends Component {
static navigationOptions = ({navigation}) => {
return {
title: 'Contact Driver'
};
};
static contextType = AppContext;
state = {
messages: [],
};
async componentDidMount() {
try {
const chatManager = new ChatManager({
instanceLocator: CHATKIT_INSTANCE_LOCATOR_ID,
userId: this.context.user_id,
tokenProvider: new TokenProvider({
url: CHATKIT_TOKEN_PROVIDER_ENDPOINT,
}),
});
let currentUser = await chatManager.connect();
this.currentUser = currentUser;
await this.currentUser.subscribeToRoomMultipart({
roomId: this.context.room_id,
hooks: {
onMessage: this._onMessage,
},
messageLimit: 30,
});
} catch (err) {
console.log('chatkit error: ', err);
}
}
_onMessage = data => {
const {message} = this._getMessage(data);
this.setState(previousState => ({
messages: GiftedChat.append(previousState.messages, message),
}));
};
_getMessage = ({id, sender, parts, createdAt}) => {
const text = parts.find(part => part.partType === 'inline').payload.content;
const msg_data = {
_id: id,
text: text,
createdAt: new Date(createdAt),
user: {
_id: sender.id.toString(),
name: sender.name,
avatar: `https://na.ui-avatars.com/api/?name=${sender.name}`,
},
};
return {
message: msg_data,
};
};
render() {
const {messages} = this.state;
return (
<View style={styles.wrapper}>
<GiftedChat
messages={messages}
onSend={messages => this._onSend(messages)}
showUserAvatar={true}
user={{
_id: this.context.user_id,
}}
/>
</View>
);
}
_onSend = async ([message]) => {
try {
await this.currentUser.sendSimpleMessage({
roomId: this.context.room_id,
text: message.text,
});
} catch (send_msg_err) {
console.log('error sending message: ', send_msg_err);
}
};
}
const styles = StyleSheet.create({
wrapper: {
flex: 1,
},
});
export default ContactDriver;
Lastly, in your Root.js
file, add the ContactDriver
screen:
import TrackOrder from './src/screens/TrackOrder';
import ContactDriver from './src/screens/ContactDriver';
const RootStack = createStackNavigator(
{
FoodList,
FoodDetails,
OrderSummary,
TrackOrder,
ContactDriver,
},
{
initialRouteName: 'FoodList',
},
);
Chatkit server
Now we proceed with the server code. Start by importing the Chatkit server SDK:
const cors = require('cors');
const Chatkit = require('@pusher/chatkit-server'); // add this
Initialize Chatkit:
const app = express();
// add these:
const CHATKIT_INSTANCE_LOCATOR_ID = process.env.CHATKIT_INSTANCE_LOCATOR_ID;
const CHATKIT_SECRET_KEY = process.env.CHATKIT_SECRET_KEY;
const chatkit = new Chatkit.default({
instanceLocator: CHATKIT_INSTANCE_LOCATOR_ID,
key: CHATKIT_SECRET_KEY,
});
Add the /login
route. As mentioned earlier, all this does is create a corresponding Chatkit user for the user_id
and user_name
provided if it doesn’t already exists:
app.post('/login', async (req, res) => {
const {user_id, user_name} = req.body;
try {
const user = await chatkit.getUser({
id: user_id,
});
} catch (err) {
if (err.error == 'services/chatkit/not_found/user_not_found') {
await chatkit.createUser({
id: user_id,
name: user_name,
});
}
}
return res.send('ok');
});
The /room
route works the same way. It will create a Chatkit room if it doesn’t already exists. But if it already does then it will add the user to the room. As you’ve seen earlier, the first time this gets called is when the customer places their order so the room is created at that point. The second time is when the driver marks the order as “picked”. At that point, the driver is added to the room:
app.post('/room', async (req, res) => {
const {room_id, room_name, user_id} = req.body;
try {
const room = await chatkit.getRoom({
roomId: room_id,
includePrivate: true,
});
if (room) {
const user_rooms = await chatkit.getUserRooms({
userId: user_id,
});
const room_index = user_rooms.findIndex(item => item.id == room_id);
if (room_index == -1) {
await chatkit.addUsersToRoom({
roomId: room_id,
userIds: [user_id],
});
}
}
} catch (err) {
if (err.error == 'services/chatkit/not_found/room_not_found') {
const created_room = await chatkit.createRoom({
id: room_id,
creatorId: user_id,
name: room_name,
isPrivate: true,
});
}
}
return res.send('ok');
});
Running the app
At this point, you can now run the app. Start by running the server and exposing it via ngrok:
cd RNFoodDelivery
node server/index.js
~/Downloads/ngrok http 5000
Replace the NGROK_HTTPS_URL
on the .env
file then run the app. If you want to test on the same machine, you need to start the metro bundler first on a different port (default is port 8081):
react-native start --port=8080
Then run the ordering app:
react-native run-android
After that, update the server host and port under the debug server host & port settings (you can access the settings by shaking the device):
Once that’s done, disconnect the first device and connect the second device (or launch the emulator) then run the driver app. This will now run using the default port so there’s no need to separately run the metro bundler. Don’t forget to update the .env
file with the ngrok HTTPS URL before doing so:
react-native run-android
If you want to easily test the changing of locations, you can run the driver app using the Genymotion emulator. It has a feature for changing the location via a Mapbox UI:
Conclusion
That’s it! In this part of the series, you learned how to create the driver side of the app. You also learned how to implement chat functionality using Chatkit.
Stay tuned for the final part wherein we will implement Push Notifications using Pusher Beams.
You can find the app’s code on this GitHub repo.
IMPORTANT:
ChatKit has been retired from the Pusher product stack as of April, 2020. This tutorial will no longer be functional in its current version. Explore our other Pusher tutorials to find out how Channels and Beams can help you build.
23 October 2019
by Wern Ancheta