sg-delivery-customer/customer_tracking_guide.md

6.7 KiB

React Native Customer Tracking Integration Guide

This guide outlines how to connect and implement live driver location tracking in your React Native Customer App.


📦 Dependencies

Install the required packages in your React Native project:

npm install socket.io-client react-native-maps

🔌 1. Tracking Socket Service (trackingSocket.ts)

Create a socket helper to handle the namespace connection (/tracking) and events:

import { io, Socket } from "socket.io-client";

const SOCKET_URL = "http://<YOUR_BACKEND_IP>:8000/tracking";

export interface DriverLocationUpdate {
  orderId: string;
  latitude: number;
  longitude: number;
  heading?: number;
  speedKph?: number;
  recordedAt: string;
}

export interface TrackingStatusUpdate {
  orderId: string;
  status: "OUT_FOR_DELIVERY" | "DELIVERED";
  timestamp: string;
}

class TrackingSocketService {
  private socket: Socket | null = null;

  connect(token: string): Socket {
    if (this.socket?.connected) {
      return this.socket;
    }

    this.socket = io(SOCKET_URL, {
      auth: { token },
      transports: ["websocket"],
      reconnection: true,
    });

    this.socket.on("connect", () => {
      console.log("Connected to Tracking WebSocket Namespace");
    });

    this.socket.on("connect_error", (error) => {
      console.error("Socket Connection Error:", error);
    });

    return this.socket;
  }

  joinOrderTracking(orderId: string) {
    this.socket?.emit("join_order_tracking", { orderId });
  }

  leaveOrderTracking(orderId: string) {
    this.socket?.emit("leave_order_tracking", { orderId });
  }

  onDriverLocationUpdate(callback: (data: DriverLocationUpdate) => void) {
    this.socket?.on("driver_location_update", callback);
  }

  onOrderStatusUpdate(callback: (data: TrackingStatusUpdate) => void) {
    this.socket?.on("order_tracking_status", callback);
  }

  disconnect(orderId: string) {
    if (this.socket) {
      this.leaveOrderTracking(orderId);
      this.socket.off("driver_location_update");
      this.socket.off("order_tracking_status");
      this.socket.disconnect();
      this.socket = null;
    }
  }
}

export const trackingSocketService = new TrackingSocketService();

🪝 2. Customer Tracking Hook (useOrderTracking.ts)

Manage room state subscription, events listening, and automatic teardown on component unmount:

import { useEffect, useState } from "react";
import { trackingSocketService, DriverLocationUpdate } from "./trackingSocket";

export function useOrderTracking(orderId: string, userJwtToken: string) {
  const [driverLocation, setDriverLocation] =
    useState<DriverLocationUpdate | null>(null);
  const [orderStatus, setOrderStatus] = useState<string | null>(null);

  useEffect(() => {
    if (!orderId || !userJwtToken) return;

    const socket = trackingSocketService.connect(userJwtToken);

    trackingSocketService.joinOrderTracking(orderId);

    trackingSocketService.onDriverLocationUpdate((data) => {
      if (data.orderId === orderId) {
        setDriverLocation(data);
      }
    });

    trackingSocketService.onOrderStatusUpdate((data) => {
      if (data.orderId === orderId) {
        setOrderStatus(data.status);
      }
    });

    return () => {
      trackingSocketService.disconnect(orderId);
    };
  }, [orderId, userJwtToken]);

  return { driverLocation, orderStatus };
}

🗺️ 3. Tracking Screen Component

Render Google Maps with the destination address, live driver position, and auto-camera alignment:

import React, { useRef, useEffect } from "react";
import { StyleSheet, View, Text } from "react-native";
import MapView, { Marker, PROVIDER_GOOGLE } from "react-native-maps";
import { useOrderTracking } from "./useOrderTracking";

interface TrackingScreenProps {
  orderId: string;
  token: string;
  customerDropoff: { latitude: number; longitude: number };
}

export default function OrderTrackingScreen({
  orderId,
  token,
  customerDropoff,
}: TrackingScreenProps) {
  const mapRef = useRef<MapView>(null);
  const { driverLocation, orderStatus } = useOrderTracking(orderId, token);

  useEffect(() => {
    if (driverLocation && mapRef.current) {
      mapRef.current.animateToRegion(
        {
          latitude: driverLocation.latitude,
          longitude: driverLocation.longitude,
          latitudeDelta: 0.01,
          longitudeDelta: 0.01,
        },
        1000,
      );
    }
  }, [driverLocation]);

  return (
    <View style={styles.container}>
      <MapView
        ref={mapRef}
        provider={PROVIDER_GOOGLE}
        style={styles.map}
        initialRegion={{
          latitude: customerDropoff.latitude,
          longitude: customerDropoff.longitude,
          latitudeDelta: 0.02,
          longitudeDelta: 0.02,
        }}
      >
        {/* Customer dropoff point */}
        <Marker
          coordinate={customerDropoff}
          title="Delivery Location"
          pinColor="red"
        />

        {/* Live Driver */}
        {driverLocation && (
          <Marker
            coordinate={{
              latitude: driverLocation.latitude,
              longitude: driverLocation.longitude,
            }}
            title="Your Delivery Driver"
            anchor={{ x: 0.5, y: 0.5 }}
            rotation={driverLocation.heading ?? 0}
          >
            <View style={styles.driverMarker}>
              <Text style={styles.driverEmoji}>🛵</Text>
            </View>
          </Marker>
        )}
      </MapView>

      <View style={styles.statusContainer}>
        <Text style={styles.statusTitle}>
          Status:{" "}
          {orderStatus === "OUT_FOR_DELIVERY"
            ? "Out for Delivery 🛵"
            : orderStatus || "Preparing"}
        </Text>
        {driverLocation?.speedKph !== undefined && (
          <Text style={styles.statusSubtitle}>
            Driver speed: {Math.round(driverLocation.speedKph)} km/h
          </Text>
        )}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    ...StyleSheet.absoluteFillObject,
    justifyContent: "flex-end",
    alignItems: "center",
  },
  map: {
    ...StyleSheet.absoluteFillObject,
  },
  driverMarker: {
    backgroundColor: "#FFF",
    padding: 6,
    borderRadius: 20,
    borderWidth: 2,
    borderColor: "#FF7F00",
    elevation: 4,
  },
  driverEmoji: {
    fontSize: 20,
  },
  statusContainer: {
    position: "absolute",
    bottom: 40,
    backgroundColor: "white",
    padding: 16,
    borderRadius: 12,
    width: "90%",
    elevation: 5,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  statusTitle: {
    fontSize: 16,
    fontWeight: "bold",
    color: "#333",
  },
  statusSubtitle: {
    fontSize: 14,
    color: "#666",
    marginTop: 4,
  },
});