Skip to main content
The ContextSwitcher component displays an interactive map that allows users to select a restaurant location. It uses GPS-based geofencing with the Haversine formula to automatically determine whether the user is within the restaurant (enabling in-situ table service) or outside (enabling delivery mode).

Component overview

Located at /src/components/location/ContextSwitcher.tsx, this component:
  • Requests and manages location permissions
  • Displays an interactive Mapbox map with the user’s current location
  • Allows users to tap on the map to set the restaurant location
  • Calculates distance between user and restaurant using the Haversine formula
  • Automatically switches app mode based on proximity (geofencing)
  • Handles permission denied states with recovery actions

Props

The ContextSwitcher component does not accept any props. It’s a standalone component that manages its own state and integrates with the app’s location and cart stores.

Usage

import ContextSwitcher from '@/src/components/location/ContextSwitcher';

export default function LocationScreen() {
  return <ContextSwitcher />;
}

Permission states

The component manages three permission states:

Loading

Displays while requesting permissions and acquiring GPS location:
if (permStatus === 'loading' || locating || !userLocation) {
  return (
    <View style={styles.centered}>
      <ActivityIndicator size="large" color={Brand.primary} />
      <Text style={styles.loadingText}>Obteniendo tu ubicación...</Text>
    </View>
  );
}

Denied

Shows an error state with a link to system settings:
if (permStatus === 'denied') {
  return (
    <ErrorState
      icon={<Navigation size={60} color={Brand.primary} strokeWidth={1.4} />}
      title="Ubicación bloqueada"
      message="TableOrder necesita acceso a tu ubicación para detectar si estás en el restaurante o necesitas delivery."
      primaryAction={{
        label: 'Abrir configuración',
        onPress: () => Linking.openSettings(),
        icon: <MapPin size={16} color="#fff" strokeWidth={2} />,
      }}
    />
  );
}

Granted

Renders the interactive map view.

Geofencing logic

The component uses the Haversine formula to calculate precise distances:

Haversine formula implementation

function calculateDistance(c1: Coordinates, c2: Coordinates): number {
  const R = 6_371_000; // Earth's radius in meters
  const φ1 = (c1.latitude * Math.PI) / 180;
  const φ2 = (c2.latitude * Math.PI) / 180;
  const Δφ = ((c2.latitude - c1.latitude) * Math.PI) / 180;
  const Δλ = ((c2.longitude - c1.longitude) * Math.PI) / 180;
  const a =
    Math.sin(Δφ / 2) ** 2 +
    Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
The formula calculates great-circle distance using the WGS-84 coordinate system:
  • R: Earth’s radius (6,371,000 meters)
  • φ: Latitude in radians
  • Δφ: Latitude difference
  • Δλ: Longitude difference
  • Returns distance in meters

Mode determination

When the user taps on the map, the component calculates the distance and switches modes:
const onMapPress = useCallback(
  (feature: MapPressFeature) => {
    if (!userLocation) return;

    const [longitude, latitude] = feature.geometry.coordinates;
    const tapped: Coordinates = { latitude, longitude };
    setRestaurant(tapped);

    const distanceMeters = calculateDistance(userLocation, tapped);

    if (distanceMeters <= Config.restaurant.geofenceRadiusMeters) {
      // IN-SITU: within restaurant
      setLocations(userLocation, tapped);
      setAppMode('SCANNER');
      setServiceType('TABLE');
    } else {
      // DELIVERY: outside restaurant
      setLocations(userLocation, tapped);
      setAppMode('DELIVERY');
      setServiceType('DELIVERY');
      router.push('/(delivery)/delivery-catalog');
    }
  },
  [userLocation, setLocations, setAppMode, setServiceType, router]
);

Map configuration

The component uses Mapbox Maps with specific settings:
facing
string
Camera facing direction (not applicable for maps)
logoEnabled
boolean
default:false
Hides the Mapbox logo overlay
attributionEnabled
boolean
default:false
Hides attribution controls
scaleBarEnabled
boolean
default:false
Hides the scale bar

Map elements

Camera

Centered on the user’s GPS location with appropriate zoom:
<Camera
  defaultSettings={{
    centerCoordinate: [userLocation.longitude, userLocation.latitude],
    zoomLevel: 15,
  }}
/>

User location

Displays a blue dot for the current user position:
<UserLocation visible androidRenderMode="compass" />

Restaurant pin

Custom marker that appears when the user taps the map:
{restaurant && (
  <PointAnnotation
    id="restaurant-pin"
    coordinate={[restaurant.longitude, restaurant.latitude]}
  >
    <View style={styles.restaurantPin}>
      <View style={styles.restaurantPinDot} />
    </View>
  </PointAnnotation>
)}

Instruction card

An overlay card explains the interaction:
<View style={styles.instructionCard}>
  <MapPinned size={20} color={Brand.primary} strokeWidth={1.8} />
  <View style={styles.instructionText}>
    <Text style={styles.instructionTitle}>Selecciona el restaurante</Text>
    <Text style={styles.instructionBody}>
      Toca en el mapa donde está el restaurante. Si estás a menos de{' '}
      {Config.restaurant.geofenceRadiusMeters} m se activa el escáner QR;
      si estás más lejos, se activa el modo delivery.
    </Text>
  </View>
</View>

Coordinate system

Mapbox uses GeoJSON coordinate order: [longitude, latitude]
const [longitude, latitude] = feature.geometry.coordinates;
const tapped: Coordinates = { latitude, longitude };
Note the order conversion from GeoJSON (lon, lat) to the app’s Coordinates type (lat, lon).

Dependencies

  • @rnmapbox/maps: Mapbox map rendering
  • expo-location: GPS location access
  • expo-router: Navigation to delivery catalog
  • lucide-react-native: Icons for UI elements
  • @/src/stores/useLocationStore: Location and app mode state
  • @/src/stores/useCartStore: Service type state
  • @/src/lib/core/config: Geofence radius configuration
  • @/src/lib/core/types: Coordinates type definition
  • @/src/components/ui/ErrorState: Permission denied UI

Configuration

Geofence radius is defined in the app config:
Config.restaurant.geofenceRadiusMeters
This value determines the maximum distance for in-situ mode activation.