Prerequisites
- React Native development environment setup
- Node.js and npm/yarn installed
- Access to InControl API credentials (Client ID and Client Secret)
- Basic knowledge of GraphQL and React Native
Tutorial Overview
We'll create a driver app that enables:
- Remote start/stop of charging sessions
- Real-time session monitoring
- Push notifications for session completion
- Charging station discovery
Step 1: Project Setup
Initialize React Native Project
npx react-native init InControlDriverApp
cd InControlDriverApp
Install Required Dependencies
npm install @apollo/client graphql react-native-push-notification
npm install @react-native-async-storage/async-storage
npm install react-native-permissions
npm install @react-native-community/netinfo
npm install react-native-maps
npm install @react-native-community/geolocation
For iOS, also run:
cd ios && pod install && cd ..
Step 2: API Configuration
Set up Apollo Client
Create src/apollo/client.js:
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import AsyncStorage from '@react-native-async-storage/async-storage';
const httpLink = createHttpLink({
uri: 'https://your-instance.inchargeus.net/api/graphql', // Replace with your instance
});
const authLink = setContext(async (_, { headers }) => {
const token = await AsyncStorage.getItem('apiToken');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
'Content-Type': 'application/json',
}
}
});
export const client = new ApolloClient({
link: from([authLink, httpLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
errorPolicy: 'all'
}
}
});
Authentication Service
Create src/services/authService.js:
import AsyncStorage from '@react-native-async-storage/async-storage';
export class AuthService {
static async authenticateWithCredentials(clientId, clientSecret) {
try {
const credentials = `${clientId}:${clientSecret}`;
const encodedCredentials = btoa(credentials); // Base64 encoding
await AsyncStorage.setItem('apiToken', encodedCredentials);
return true;
} catch (error) {
console.error('Authentication failed:', error);
return false;
}
}
static async getToken() {
return await AsyncStorage.getItem('apiToken');
}
static async logout() {
await AsyncStorage.removeItem('apiToken');
}
}
Step 3: Location Service
Set up Location Tracking
Create src/services/locationService.js:
import Geolocation from '@react-native-community/geolocation';
import { PermissionsAndroid, Platform, Alert } from 'react-native';
export class LocationService {
static async requestLocationPermission() {
if (Platform.OS === 'android') {
try {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
{
title: 'Location Permission',
message: 'This app needs access to your location to find nearby charging stations.',
buttonNeutral: 'Ask Me Later',
buttonNegative: 'Cancel',
buttonPositive: 'OK',
}
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
} catch (err) {
console.warn(err);
return false;
}
}
return true; // iOS permissions are handled by react-native-maps
}
static getCurrentPosition() {
return new Promise((resolve, reject) => {
Geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
},
(error) => {
console.error('Location error:', error);
reject(error);
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 10000,
}
);
});
}
static watchPosition(callback) {
return Geolocation.watchPosition(
(position) => {
callback({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
},
(error) => {
console.error('Location watch error:', error);
},
{
enableHighAccuracy: true,
distanceFilter: 10, // Update every 10 meters
}
);
}
static clearWatch(watchId) {
Geolocation.clearWatch(watchId);
}
static calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Radius of the Earth in kilometers
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
const distance = R * c; // Distance in kilometers
return distance;
}
}
Step 4: GraphQL Queries and Mutations
Define GraphQL Operations
Create src/graphql/operations.js:
import { gql } from '@apollo/client';
// Query to get available charging stations
export const GET_CHARGING_STATIONS = gql`
query GetChargingStations($filter: ChargingStationFilter) {
chargingStations(filter: $filter) {
id
name
location {
latitude
longitude
address
}
connectors {
id
type
status
power
}
availability
status
}
}
`;
// Query to get active sessions for a driver
export const GET_ACTIVE_SESSIONS = gql`
query GetActiveSessions($driverId: ID!) {
sessions(filter: { driverId: $driverId, status: ACTIVE }) {
id
status
startTime
estimatedEndTime
energyDelivered
chargingStation {
id
name
location {
address
}
}
connector {
id
type
power
}
}
}
`;
// Mutation to start a charging session
export const START_CHARGING_SESSION = gql`
mutation StartChargingSession($input: StartSessionInput!) {
startChargingSession(input: $input) {
success
session {
id
status
startTime
chargingStation {
name
}
}
errors {
message
code
}
}
}
`;
// Mutation to stop a charging session
export const STOP_CHARGING_SESSION = gql`
mutation StopChargingSession($sessionId: ID!) {
stopChargingSession(sessionId: $sessionId) {
success
session {
id
status
endTime
totalEnergyDelivered
totalCost
}
errors {
message
code
}
}
}
`;
// Subscription for real-time session updates
export const SESSION_UPDATES = gql`
subscription SessionUpdates($sessionId: ID!) {
sessionUpdated(sessionId: $sessionId) {
id
status
energyDelivered
estimatedTimeRemaining
currentPower
}
}
`;
Step 4: React Native Components
Main App Component
Create src/App.js:
import React, { useEffect, useState } from 'react';
import { ApolloProvider } from '@apollo/client';
import { client } from './apollo/client';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { AuthService } from './services/authService';
import LoginScreen from './screens/LoginScreen';
import DashboardScreen from './screens/DashboardScreen';
import ChargingStationsScreen from './screens/ChargingStationsScreen';
import StationMapScreen from './screens/StationMapScreen';
import { setupPushNotifications } from './services/notificationService';
const Stack = createStackNavigator();
export default function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuthStatus();
setupPushNotifications();
}, []);
const checkAuthStatus = async () => {
const token = await AuthService.getToken();
setIsAuthenticated(!!token);
setLoading(false);
};
if (loading) {
return null; // Add loading spinner here
}
return (
<ApolloProvider client={client}>
<NavigationContainer>
<Stack.Navigator>
{!isAuthenticated ? (
<Stack.Screen
name="Login"
component={LoginScreen}
options={{ title: 'InControl Driver' }}
/>
) : (
<>
<Stack.Screen
name="Dashboard"
component={DashboardScreen}
options={{ title: 'Dashboard' }}
/>
<Stack.Screen
name="StationMap"
component={StationMapScreen}
options={{ title: 'Charging Stations Map' }}
/>
<Stack.Screen
name="Stations"
component={ChargingStationsScreen}
options={{ title: 'Charging Stations' }}
/>
</>
)}
</Stack.Navigator>
</NavigationContainer>
</ApolloProvider>
);
}
Login Screen
Create src/screens/LoginScreen.js:
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { AuthService } from '../services/authService';
export default function LoginScreen({ navigation }) {
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
if (!clientId || !clientSecret) {
Alert.alert('Error', 'Please enter both Client ID and Client Secret');
return;
}
setLoading(true);
const success = await AuthService.authenticateWithCredentials(clientId, clientSecret);
if (success) {
navigation.replace('Dashboard');
} else {
Alert.alert('Error', 'Authentication failed. Please check your credentials.');
}
setLoading(false);
};
return (
<View style={styles.container}>
<Text style={styles.title}>InControl Driver App</Text>
<TextInput
style={styles.input}
placeholder="Client ID"
value={clientId}
onChangeText={setClientId}
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Client Secret"
value={clientSecret}
onChangeText={setClientSecret}
secureTextEntry
autoCapitalize="none"
/>
<TouchableOpacity
style={styles.button}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Logging in...' : 'Login'}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: 'center',
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
marginBottom: 40,
},
input: {
borderWidth: 1,
borderColor: '#ddd',
padding: 15,
marginVertical: 10,
borderRadius: 8,
},
button: {
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
marginTop: 20,
},
buttonText: {
color: 'white',
textAlign: 'center',
fontWeight: 'bold',
},
});
Enhanced Dashboard Screen
Create src/screens/DashboardScreen.js:
import React from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ScrollView,
} from 'react-native';
import { useQuery } from '@apollo/client';
import { GET_ACTIVE_SESSIONS } from '../graphql/operations';
import ActiveSessionCard from '../components/ActiveSessionCard';
export default function DashboardScreen({ navigation }) {
const driverId = 'current-driver-id'; // Get from user context
const { data, loading, error, refetch } = useQuery(GET_ACTIVE_SESSIONS, {
variables: { driverId },
pollInterval: 30000, // Poll every 30 seconds
});
return (
<ScrollView style={styles.container}>
<Text style={styles.title}>Dashboard</Text>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.actionButton, styles.mapButton]}
onPress={() => navigation.navigate('StationMap')}
>
<Text style={styles.buttonText}>🗺️ Station Map</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.listButton]}
onPress={() => navigation.navigate('Stations')}
>
<Text style={styles.buttonText}>📋 Station List</Text>
</TouchableOpacity>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Active Sessions</Text>
{loading ? (
<Text>Loading sessions...</Text>
) : error ? (
<Text>Error loading sessions</Text>
) : data?.sessions?.length > 0 ? (
data.sessions.map(session => (
<ActiveSessionCard
key={session.id}
session={session}
onRefresh={refetch}
/>
))
) : (
<Text style={styles.noSessions}>No active charging sessions</Text>
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
},
actionButton: {
flex: 1,
padding: 15,
borderRadius: 8,
marginHorizontal: 5,
},
mapButton: {
backgroundColor: '#34C759',
},
listButton: {
backgroundColor: '#007AFF',
},
buttonText: {
color: 'white',
textAlign: 'center',
fontWeight: 'bold',
},
section: {
marginBottom: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 10,
},
noSessions: {
textAlign: 'center',
color: '#666',
fontStyle: 'italic',
},
});
Active Session Card Component
Create src/components/ActiveSessionCard.js:
import React, { useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { useMutation, useSubscription } from '@apollo/client';
import { STOP_CHARGING_SESSION, SESSION_UPDATES } from '../graphql/operations';
import { showNotification } from '../services/notificationService';
export default function ActiveSessionCard({ session, onRefresh }) {
const [stopSession, { loading: stopping }] = useMutation(STOP_CHARGING_SESSION);
// Subscribe to real-time updates
const { data: updateData } = useSubscription(SESSION_UPDATES, {
variables: { sessionId: session.id }
});
useEffect(() => {
if (updateData?.sessionUpdated?.status === 'COMPLETED') {
showNotification({
title: 'Charging Complete',
message: `Your charging session at ${session.chargingStation.name} has completed.`,
});
onRefresh();
}
}, [updateData]);
const handleStopSession = () => {
Alert.alert(
'Stop Charging',
'Are you sure you want to stop this charging session?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Stop',
style: 'destructive',
onPress: async () => {
try {
const result = await stopSession({
variables: { sessionId: session.id }
});
if (result.data?.stopChargingSession?.success) {
Alert.alert('Success', 'Charging session stopped successfully');
onRefresh();
} else {
Alert.alert('Error', 'Failed to stop charging session');
}
} catch (error) {
Alert.alert('Error', 'Network error occurred');
}
}
}
]
);
};
const currentSession = updateData?.sessionUpdated || session;
return (
<View style={styles.card}>
<Text style={styles.stationName}>
{session.chargingStation.name}
</Text>
<Text style={styles.address}>
{session.chargingStation.location.address}
</Text>
<View style={styles.details}>
<Text>Status: {currentSession.status}</Text>
<Text>Energy: {currentSession.energyDelivered} kWh</Text>
{currentSession.currentPower && (
<Text>Power: {currentSession.currentPower} kW</Text>
)}
{currentSession.estimatedTimeRemaining && (
<Text>Time Remaining: {currentSession.estimatedTimeRemaining} min</Text>
)}
</View>
<TouchableOpacity
style={styles.stopButton}
onPress={handleStopSession}
disabled={stopping}
>
<Text style={styles.stopButtonText}>
{stopping ? 'Stopping...' : 'Stop Charging'}
</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: 'white',
padding: 15,
borderRadius: 8,
marginBottom: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
stationName: {
fontSize: 16,
fontWeight: 'bold',
},
address: {
color: '#666',
marginBottom: 10,
},
details: {
marginBottom: 15,
},
stopButton: {
backgroundColor: '#FF3B30',
padding: 12,
borderRadius: 6,
},
stopButtonText: {
color: 'white',
textAlign: 'center',
fontWeight: 'bold',
},
});
Dedicated Station Map Screen
Create src/screens/StationMapScreen.js:
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Dimensions,
ScrollView,
} from 'react-native';
import MapView, { Marker, PROVIDER_GOOGLE, Circle } from 'react-native-maps';
import { useQuery, useMutation } from '@apollo/client';
import { GET_CHARGING_STATIONS, START_CHARGING_SESSION } from '../graphql/operations';
import { LocationService } from '../services/locationService';
const { width, height } = Dimensions.get('window');
export default function StationMapScreen({ navigation }) {
const [userLocation, setUserLocation] = useState(null);
const [selectedStation, setSelectedStation] = useState(null);
const [searchRadius, setSearchRadius] = useState(5000); // 5km default
const [mapRegion, setMapRegion] = useState({
latitude: 37.78825,
longitude: -122.4324,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
const { data, loading, error, refetch } = useQuery(GET_CHARGING_STATIONS, {
variables: {
filter: {
availability: 'AVAILABLE',
...(userLocation && {
location: {
latitude: userLocation.latitude,
longitude: userLocation.longitude,
radius: searchRadius,
}
})
}
}
});
const [startSession, { loading: starting }] = useMutation(START_CHARGING_SESSION);
useEffect(() => {
initializeLocation();
}, []);
useEffect(() => {
// Watch for location changes
let watchId;
if (userLocation) {
watchId = LocationService.watchPosition((newLocation) => {
setUserLocation(newLocation);
refetch();
});
}
return () => {
if (watchId) {
LocationService.clearWatch(watchId);
}
};
}, [userLocation, refetch]);
const initializeLocation = async () => {
const hasPermission = await LocationService.requestLocationPermission();
if (hasPermission) {
try {
const location = await LocationService.getCurrentPosition();
setUserLocation(location);
setMapRegion({
...location,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
});
refetch();
} catch (error) {
Alert.alert('Location Error', 'Could not get your current location');
}
} else {
Alert.alert('Permission Denied', 'Location permission is required to find nearby stations');
}
};
const handleStartCharging = async (stationId, connectorId) => {
try {
const result = await startSession({
variables: {
input: {
chargingStationId: stationId,
connectorId: connectorId,
driverId: 'current-driver-id',
}
}
});
if (result.data?.startChargingSession?.success) {
Alert.alert('Success', 'Charging session started successfully', [
{
text: 'OK',
onPress: () => {
setSelectedStation(null);
navigation.navigate('Dashboard');
}
}
]);
} else {
const error = result.data?.startChargingSession?.errors?.[0]?.message;
Alert.alert('Error', error || 'Failed to start charging session');
}
} catch (error) {
Alert.alert('Error', 'Network error occurred');
}
};
const getMarkerColor = (station) => {
const availableConnectors = station.connectors.filter(c => c.status === 'AVAILABLE').length;
if (availableConnectors === 0) return '#FF3B30';
if (availableConnectors <= 2) return '#FF9500';
return '#34C759';
};
const getDistanceText = (station) => {
if (!userLocation) return '';
const distance = LocationService.calculateDistance(
userLocation.latitude,
userLocation.longitude,
station.location.latitude,
station.location.longitude
);
return distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)}km`;
};
const adjustSearchRadius = (newRadius) => {
setSearchRadius(newRadius);
// Update map region to show the new search area
if (userLocation) {
const latitudeDelta = (newRadius / 111320) * 2.5; // Rough conversion
const longitudeDelta = latitudeDelta;
setMapRegion({
...userLocation,
latitudeDelta,
longitudeDelta,
});
}
refetch();
};
if (loading && !data) {
return (
<View style={styles.centered}>
<Text>Loading charging stations...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centered}>
<Text>Error loading stations</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => refetch()}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
{/* Search Radius Controls */}
<View style={styles.radiusControls}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{[1000, 2000, 5000, 10000, 20000].map(radius => (
<TouchableOpacity
key={radius}
style={[
styles.radiusButton,
searchRadius === radius && styles.activeRadiusButton
]}
onPress={() => adjustSearchRadius(radius)}
>
<Text style={[
styles.radiusButtonText,
searchRadius === radius && styles.activeRadiusButtonText
]}>
{radius < 1000 ? `${radius}m` : `${radius/1000}km`}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
<MapView
provider={PROVIDER_GOOGLE}
style={styles.map}
region={mapRegion}
showsUserLocation={true}
showsMyLocationButton={true}
onRegionChangeComplete={setMapRegion}
>
{/* Search radius circle */}
{userLocation && (
<Circle
center={userLocation}
radius={searchRadius}
strokeColor="rgba(0, 122, 255, 0.5)"
fillColor="rgba(0, 122, 255, 0.1)"
strokeWidth={2}
/>
)}
{/* Charging Station Markers */}
{data?.chargingStations?.map(station => (
<Marker
key={station.id}
coordinate={{
latitude: station.location.latitude,
longitude: station.location.longitude,
}}
title={station.name}
description={`${station.connectors.filter(c => c.status === 'AVAILABLE').length} available`}
pinColor={getMarkerColor(station)}
onPress={() => setSelectedStation(station)}
/>
))}
</MapView>
{/* Station Details Bottom Sheet */}
{selectedStation && (
<View style={styles.bottomSheet}>
<ScrollView style={styles.bottomSheetContent}>
<View style={styles.bottomSheetHeader}>
<Text style={styles.stationName}>{selectedStation.name}</Text>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setSelectedStation(null)}
>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
</View>
<Text style={styles.address}>{selectedStation.location.address}</Text>
{userLocation && (
<Text style={styles.distance}>
📍 {getDistanceText(selectedStation)} away
</Text>
)}
<Text style={styles.status}>
Status: <Text style={styles.statusValue}>{selectedStation.status}</Text>
</Text>
<View style={styles.connectorsSection}>
<Text style={styles.connectorsTitle}>
Available Connectors ({selectedStation.connectors.filter(c => c.status === 'AVAILABLE').length}):
</Text>
{selectedStation.connectors
.filter(connector => connector.status === 'AVAILABLE')
.map(connector => (
<TouchableOpacity
key={connector.id}
style={styles.connectorButton}
onPress={() => handleStartCharging(selectedStation.id, connector.id)}
disabled={starting}
>
<Text style={styles.connectorText}>
🔌 {connector.type} - {connector.power}kW
</Text>
{starting && <Text style={styles.startingText}>Starting...</Text>}
</TouchableOpacity>
))
}
{selectedStation.connectors.filter(c => c.status === 'AVAILABLE').length === 0 && (
<View style={styles.noConnectorsContainer}>
<Text style={styles.noConnectors}>⚠️ No available connectors</Text>
<Text style={styles.noConnectorsSubtext}>All connectors are currently in use</Text>
</View>
)}
</View>
{/* Navigation Button */}
<TouchableOpacity
style={styles.navigationButton}
onPress={() => {
// This would typically open the native maps app
Alert.alert('Navigation', 'This would open your preferred navigation app');
}}
>
<Text style={styles.navigationButtonText}>🗺️ Get Directions</Text>
</TouchableOpacity>
</ScrollView>
</View>
)}
{/* Floating Action Buttons */}
<View style={styles.floatingButtons}>
{userLocation && (
<TouchableOpacity
style={styles.centerButton}
onPress={() => setMapRegion({
...userLocation,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
})}
>
<Text style={styles.centerButtonText}>📍</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.refreshButton}
onPress={() => refetch()}
>
<Text style={styles.refreshButtonText}>🔄</Text>
</TouchableOpacity>
</View>
{/* Stats Bar */}
<View style={styles.statsBar}>
<Text style={styles.statsText}>
Found {data?.chargingStations?.length || 0} stations within {searchRadius < 1000 ? `${searchRadius}m` : `${searchRadius/1000}km`}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
map: {
flex: 1,
},
radiusControls: {
position: 'absolute',
top: 10,
left: 0,
right: 0,
zIndex: 1,
paddingHorizontal: 15,
},
radiusButton: {
backgroundColor: 'white',
paddingHorizontal: 15,
paddingVertical: 8,
borderRadius: 20,
marginHorizontal: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
activeRadiusButton: {
backgroundColor: '#007AFF',
},
radiusButtonText: {
fontWeight: 'bold',
color: '#333',
},
activeRadiusButtonText: {
color: 'white',
},
retryButton: {
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
marginTop: 20,
},
retryText: {
color: 'white',
fontWeight: 'bold',
},
bottomSheet: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: height * 0.6,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
bottomSheetContent: {
padding: 20,
},
bottomSheetHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
stationName: {
fontSize: 20,
fontWeight: 'bold',
flex: 1,
color: '#333',
},
closeButton: {
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center',
},
closeButtonText: {
fontSize: 20,
fontWeight: 'bold',
color: '#666',
},
address: {
color: '#666',
marginBottom: 8,
fontSize: 14,
},
distance: {
color: '#007AFF',
fontWeight: 'bold',
marginBottom: 8,
fontSize: 14,
},
status: {
marginBottom: 15,
fontSize: 14,
},
statusValue: {
fontWeight: 'bold',
color: '#34C759',
},
connectorsSection: {
marginBottom: 15,
},
connectorsTitle: {
fontWeight: 'bold',
marginBottom: 10,
fontSize: 16,
color: '#333',
},
connectorButton: {
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
marginVertical: 3,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
connectorText: {
color: 'white',
fontWeight: 'bold',
flex: 1,
},
startingText: {
color: 'white',
fontStyle: 'italic',
},
noConnectorsContainer: {
padding: 20,
alignItems: 'center',
},
noConnectors: {
textAlign: 'center',
color: '#FF3B30',
fontWeight: 'bold',
fontSize: 16,
},
noConnectorsSubtext: {
textAlign: 'center',
color: '#666',
fontSize: 12,
marginTop: 5,
},
navigationButton: {
backgroundColor: '#34C759',
padding: 15,
borderRadius: 8,
marginTop: 10,
},
navigationButtonText: {
color: 'white',
textAlign: 'center',
fontWeight: 'bold',
fontSize: 16,
},
floatingButtons: {
position: 'absolute',
right: 20,
top: 80,
},
centerButton: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
centerButtonText: {
fontSize: 24,
},
refreshButton: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
refreshButtonText: {
fontSize: 20,
},
statsBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 10,
},
statsText: {
color: 'white',
textAlign: 'center',
fontSize: 12,
},
});
Enhanced Charging Stations Screen with Map
Create src/screens/ChargingStationsScreen.js:
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Dimensions,
ScrollView,
} from 'react-native';
import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps';
import { useQuery, useMutation } from '@apollo/client';
import { GET_CHARGING_STATIONS, START_CHARGING_SESSION } from '../graphql/operations';
import { LocationService } from '../services/locationService';
const { width, height } = Dimensions.get('window');
export default function ChargingStationsScreen() {
const [userLocation, setUserLocation] = useState(null);
const [selectedStation, setSelectedStation] = useState(null);
const [mapRegion, setMapRegion] = useState({
latitude: 37.78825,
longitude: -122.4324,
latitudeDelta: 0.0922,
longitudeDelta: 0.0421,
});
const { data, loading, error, refetch } = useQuery(GET_CHARGING_STATIONS, {
variables: {
filter: {
availability: 'AVAILABLE',
// Add radius filter based on user location
...(userLocation && {
location: {
latitude: userLocation.latitude,
longitude: userLocation.longitude,
radius: 10000, // 10km radius
}
})
}
}
});
const [startSession, { loading: starting }] = useMutation(START_CHARGING_SESSION);
useEffect(() => {
initializeLocation();
}, []);
const initializeLocation = async () => {
const hasPermission = await LocationService.requestLocationPermission();
if (hasPermission) {
try {
const location = await LocationService.getCurrentPosition();
setUserLocation(location);
setMapRegion({
...location,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
});
// Refetch stations with new location
refetch();
} catch (error) {
Alert.alert('Location Error', 'Could not get your current location');
}
} else {
Alert.alert('Permission Denied', 'Location permission is required to find nearby stations');
}
};
const handleStartCharging = async (stationId, connectorId) => {
try {
const result = await startSession({
variables: {
input: {
chargingStationId: stationId,
connectorId: connectorId,
driverId: 'current-driver-id',
}
}
});
if (result.data?.startChargingSession?.success) {
Alert.alert('Success', 'Charging session started successfully');
setSelectedStation(null);
} else {
const error = result.data?.startChargingSession?.errors?.[0]?.message;
Alert.alert('Error', error || 'Failed to start charging session');
}
} catch (error) {
Alert.alert('Error', 'Network error occurred');
}
};
const getMarkerColor = (station) => {
const availableConnectors = station.connectors.filter(c => c.status === 'AVAILABLE').length;
if (availableConnectors === 0) return '#FF3B30'; // Red for no availability
if (availableConnectors <= 2) return '#FF9500'; // Orange for limited availability
return '#34C759'; // Green for good availability
};
const getDistanceText = (station) => {
if (!userLocation) return '';
const distance = LocationService.calculateDistance(
userLocation.latitude,
userLocation.longitude,
station.location.latitude,
station.location.longitude
);
return distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)}km`;
};
if (loading && !data) {
return (
<View style={styles.centered}>
<Text>Loading charging stations...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centered}>
<Text>Error loading stations</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => refetch()}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<MapView
provider={PROVIDER_GOOGLE}
style={styles.map}
region={mapRegion}
showsUserLocation={true}
showsMyLocationButton={true}
onRegionChangeComplete={setMapRegion}
>
{/* User Location Marker */}
{userLocation && (
<Marker
coordinate={userLocation}
title="Your Location"
pinColor="blue"
/>
)}
{/* Charging Station Markers */}
{data?.chargingStations?.map(station => (
<Marker
key={station.id}
coordinate={{
latitude: station.location.latitude,
longitude: station.location.longitude,
}}
title={station.name}
description={`${station.connectors.filter(c => c.status === 'AVAILABLE').length} available connectors`}
pinColor={getMarkerColor(station)}
onPress={() => setSelectedStation(station)}
/>
))}
</MapView>
{/* Station Details Bottom Sheet */}
{selectedStation && (
<View style={styles.bottomSheet}>
<ScrollView style={styles.bottomSheetContent}>
<View style={styles.bottomSheetHeader}>
<Text style={styles.stationName}>{selectedStation.name}</Text>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setSelectedStation(null)}
>
<Text style={styles.closeButtonText}>×</Text>
</TouchableOpacity>
</View>
<Text style={styles.address}>{selectedStation.location.address}</Text>
{userLocation && (
<Text style={styles.distance}>
Distance: {getDistanceText(selectedStation)}
</Text>
)}
<Text style={styles.status}>Status: {selectedStation.status}</Text>
<View style={styles.connectorsSection}>
<Text style={styles.connectorsTitle}>Available Connectors:</Text>
{selectedStation.connectors
.filter(connector => connector.status === 'AVAILABLE')
.map(connector => (
<TouchableOpacity
key={connector.id}
style={styles.connectorButton}
onPress={() => handleStartCharging(selectedStation.id, connector.id)}
disabled={starting}
>
<Text style={styles.connectorText}>
{connector.type} - {connector.power}kW
</Text>
</TouchableOpacity>
))
}
{selectedStation.connectors.filter(c => c.status === 'AVAILABLE').length === 0 && (
<Text style={styles.noConnectors}>No available connectors</Text>
)}
</View>
</ScrollView>
</View>
)}
{/* Floating Action Button to Center on User */}
{userLocation && (
<TouchableOpacity
style={styles.centerButton}
onPress={() => setMapRegion({
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
})}
>
<Text style={styles.centerButtonText}>📍</Text>
</TouchableOpacity>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
map: {
flex: 1,
},
retryButton: {
backgroundColor: '#007AFF',
padding: 15,
borderRadius: 8,
marginTop: 20,
},
retryText: {
color: 'white',
fontWeight: 'bold',
},
bottomSheet: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: height * 0.5,
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
bottomSheetContent: {
padding: 20,
},
bottomSheetHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 10,
},
stationName: {
fontSize: 20,
fontWeight: 'bold',
flex: 1,
},
closeButton: {
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: '#f0f0f0',
justifyContent: 'center',
alignItems: 'center',
},
closeButtonText: {
fontSize: 20,
fontWeight: 'bold',
color: '#666',
},
address: {
color: '#666',
marginBottom: 5,
},
distance: {
color: '#007AFF',
fontWeight: 'bold',
marginBottom: 5,
},
status: {
marginBottom: 15,
},
connectorsSection: {
marginBottom: 20,
},
connectorsTitle: {
fontWeight: 'bold',
marginBottom: 10,
fontSize: 16,
},
connectorButton: {
backgroundColor: '#007AFF',
padding: 12,
borderRadius: 8,
marginVertical: 3,
},
connectorText: {
color: 'white',
textAlign: 'center',
fontWeight: 'bold',
},
noConnectors: {
textAlign: 'center',
color: '#666',
fontStyle: 'italic',
padding: 20,
},
centerButton: {
position: 'absolute',
top: 60,
right: 20,
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: 'white',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
centerButtonText: {
fontSize: 24,
},
});
Step 5: Push Notification Service
Create src/services/notificationService.js:
import PushNotification from 'react-native-push-notification';
import { Platform } from 'react-native';
export const setupPushNotifications = () => {
PushNotification.configure({
onNotification: function(notification) {
console.log('Notification received:', notification);
},
requestPermissions: Platform.OS === 'ios',
});
// Create notification channels for Android
if (Platform.OS === 'android') {
PushNotification.createChannel(
{
channelId: 'charging-sessions',
channelName: 'Charging Sessions',
channelDescription: 'Notifications for charging session updates',
soundName: 'default',
importance: 4,
vibrate: true,
},
(created) => console.log(`Channel created: ${created}`)
);
}
};
export const showNotification = ({ title, message, data = {} }) => {
PushNotification.localNotification({
channelId: 'charging-sessions',
title,
message,
userInfo: data,
playSound: true,
soundName: 'default',
vibrate: true,
});
};
Step 6: App Configuration
Update App.js (Root)
import React from 'react';
import App from './src/App';
export default App;
Configure Permissions
Android (android/app/src/main/AndroidManifest.xml)
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Add Google Maps API key -->
<application>
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_GOOGLE_MAPS_API_KEY_HERE"/>
</application>
iOS (ios/YourApp/Info.plist)
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs location access to find nearby charging stations</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>This app needs location access to find nearby charging stations</string>
Google Maps Setup
Get Google Maps API Key:
- Go to Google Cloud Console
- Enable Maps SDK for Android/iOS
- Create API key and restrict it appropriately
Configure for iOS (ios/Podfile):
# Add to your Podfile
pod 'GoogleMaps'
pod 'Google-Maps-iOS-Utils'
- Run pod install:
cd ios && pod install && cd ..
Step 7: Running and Testing
Start the Development Server
npm start
Run on Device/Emulator
# iOS
npm run ios
# Android
npm run android
Key Features Implemented
- Authentication: Secure API key-based authentication
- Real-time Updates: GraphQL subscriptions for live session monitoring
- Remote Control: Start and stop charging sessions remotely
- Push Notifications: Local notifications for session completion
- Interactive Map: Native map with user location and charging station markers
- Location Services: GPS tracking and distance calculations
- Station Discovery: Browse and filter available charging stations by radius
- Session Management: View active sessions with real-time status
- Visual Indicators: Color-coded markers showing station availability
- Search Radius Control: Adjustable search area from 1km to 20km
Best Practices
- Error Handling: Comprehensive error handling for network issues
- Loading States: User-friendly loading indicators
- Offline Support: Cache important data for offline viewing
- Security: Secure storage of API credentials
- Performance: Efficient polling and subscription management
- Location Privacy: Request permissions appropriately and handle denials gracefully
- Map Performance: Optimize marker rendering for large datasets
- Battery Optimization: Efficient location tracking with appropriate distance filters
Map-Specific Features
Color-Coded Markers
- 🟢 Green: Good availability (3+ connectors)
- 🟠 Orange: Limited availability (1-2 connectors)
- 🔴 Red: No availability (0 connectors)
Location Features
- User Location Tracking: Real-time GPS positioning
- Distance Calculations: Accurate distance measurements to stations
- Search Radius: Adjustable from 1km to 20km
- Auto-refresh: Location updates trigger station data refresh
Map Controls
- Center Button: Quick return to user location
- Refresh Button: Manual data refresh
- Radius Picker: Easy search area adjustment
- Stats Bar: Shows number of stations found
Next Steps
- Add route planning to selected charging stations
- Implement real-time availability updates on map
- Add clustering for better performance with many markers
- Enhance offline support with cached map tiles
- Implement advanced filtering (connector type, power level, pricing)
- Add navigation integration with native maps apps
- Include augmented reality features for station identification
- Add charging history and analytics dashboard
- Implement favorite stations and trip planning
- Add voice guidance and accessibility features
Troubleshooting Map Issues
Common Problems and Solutions
Map not showing:
- Verify Google Maps API key is configured
- Check that Maps SDK is enabled in Google Cloud Console
Location not working:
- Ensure location permissions are granted
- Check that location services are enabled on device
Markers not appearing:
- Verify GraphQL queries return valid coordinates
- Check that latitude/longitude values are valid numbers
Performance issues:
- Implement marker clustering for large datasets
- Use appropriate map region bounds
- Optimize GraphQL queries with proper filtering
This tutorial provides a solid foundation for building a production-ready driver app with the InControl API. Remember to test thoroughly and handle edge cases for the best user experience.