import React, { useRef, useState, useCallback, useMemo } from 'react';
import {
View,
Text,
TouchableOpacity,
TextInput,
FlatList,
ActivityIndicator,
Keyboard,
Platform,
} from 'react-native';
import { OSMView, LocationButton } from 'expo-osm-sdk';
import type { OSMViewRef, MarkerConfig } from 'expo-osm-sdk';
import * as Location from 'expo-location';
import { getDigiPin, getLatLngFromDigiPin, formatDigiPin, isWithinIndia } from '../core/digipin';
import { autocompleteAddress, looksLikeDigiPin, couldBeDigiPinInput } from '../core/api';
import type { AutocompleteSuggestion } from '../core/api';
import type { MapPinSelectorProps } from '../core/types';
import { styles, COLORS } from '../styles/checkout.native';
// ─── Constants ────────────────────────────────────────────────────────────────
const INDIA_CENTER = { latitude: 20.5937, longitude: 78.9629 };
const OVERVIEW_ZOOM = 5;
const STREET_ZOOM = 16;
/**
* Carto Positron vector basemap — same source as the web version.
* Free, no API key required. Attribution required (shown by OSMView natively).
*/
const CARTO_STYLE_URL =
'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json';
const DEFAULT_BRAND = '#0ea5e9';
/**
* Custom pin SVG as a data URI — teardrop with white inner circle and brand-colour dot.
*/
function makePinIconDataUri(hex: string): string {
const c = hex.startsWith('#') ? hex : `#${hex}`;
return (
'data:image/svg+xml;utf8,' +
encodeURIComponent(
''
)
);
}
// ─── Helper ───────────────────────────────────────────────────────────────────
function computeDigiPinSafe(lat: number, lng: number): string | null {
if (!isWithinIndia(lat, lng)) return null;
try {
return getDigiPin(lat, lng);
} catch {
return null;
}
}
// ─── Component ────────────────────────────────────────────────────────────────
/**
* Native (iOS/Android) map pin selector.
* Uses expo-osm-sdk's OSMView with a draggable marker over Carto Positron tiles.
* Metro resolves this file on native; MapPinSelector.tsx is used on web.
*/
const MapPinSelector: React.FC = ({
onLocationConfirm,
defaultLat,
defaultLng,
mapHeight = 380,
theme = 'light',
enableSearch = true,
apiKey,
apiBaseUrl,
brandColor = DEFAULT_BRAND,
}) => {
const mapRef = useRef(null);
const initLat = defaultLat ?? INDIA_CENTER.latitude;
const initLng = defaultLng ?? INDIA_CENTER.longitude;
const hasDefault = defaultLat !== undefined && defaultLng !== undefined;
const [coord, setCoord] = useState({ latitude: initLat, longitude: initLng });
const [digipin, setDigipin] = useState(() =>
computeDigiPinSafe(initLat, initLng)
);
const [mapReady, setMapReady] = useState(false);
const [locateError, setLocateError] = useState(null);
// ── Search state ──────────────────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState(null);
const debounceRef = useRef | null>(null);
const isDark = theme === 'dark';
const accentColor = brandColor ?? DEFAULT_BRAND;
const pinIconUri = useMemo(() => makePinIconDataUri(accentColor), [accentColor]);
// Resolve numeric height: web passes '380px', native should pass 380 or a number
const mapHeightNum =
typeof mapHeight === 'string' ? parseInt(mapHeight, 10) || 380 : mapHeight;
const handleCoordChange = useCallback(
(latitude: number, longitude: number) => {
setCoord({ latitude, longitude });
setDigipin(computeDigiPinSafe(latitude, longitude));
},
[]
);
// ── Fly map + move pin ────────────────────────────────────────────────────
const flyToCoord = useCallback((latitude: number, longitude: number) => {
handleCoordChange(latitude, longitude);
void mapRef.current?.animateToLocation(latitude, longitude, STREET_ZOOM);
}, [handleCoordChange]);
// ── Search input change ───────────────────────────────────────────────────
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
setSearchError(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = value.trim();
if (!trimmed) {
setSuggestions([]);
setSearchLoading(false);
return;
}
// DigiPin — offline, instant
// Keep the text visible in the input; just close the keyboard and dropdown.
if (looksLikeDigiPin(trimmed)) {
try {
const coords = getLatLngFromDigiPin(trimmed);
flyToCoord(parseFloat(coords.latitude), parseFloat(coords.longitude));
setSuggestions([]);
setSearchQuery(formatDigiPin(trimmed));
Keyboard.dismiss();
} catch {
setSearchError('Invalid DigiPin code');
}
return;
}
// Partial DigiPin — wait for full code, skip autocomplete
if (couldBeDigiPinInput(trimmed)) {
setSuggestions([]);
setSearchLoading(false);
return;
}
if (trimmed.length < 3) {
setSuggestions([]);
return;
}
setSearchLoading(true);
debounceRef.current = setTimeout(async () => {
try {
// Forward current pin position so the API can bias/bound results nearby
const results = await autocompleteAddress(
trimmed, apiKey ?? '', apiBaseUrl, 5,
coord.latitude, coord.longitude,
);
setSuggestions(results);
} catch (err) {
setSearchError(err instanceof Error ? err.message : 'Search failed');
setSuggestions([]);
} finally {
setSearchLoading(false);
}
}, 400);
}, [apiKey, apiBaseUrl, flyToCoord]);
const handleSuggestionSelect = useCallback((s: AutocompleteSuggestion) => {
flyToCoord(s.coordinates.latitude, s.coordinates.longitude);
setSearchQuery('');
setSuggestions([]);
Keyboard.dismiss();
}, [flyToCoord]);
// ── Marker config (draggable checkout pin) ──────────────────────────────────
const pinMarkers: MarkerConfig[] = useMemo(
() => [
{
id: 'checkout-pin',
coordinate: coord,
draggable: true,
icon: {
uri: pinIconUri,
size: 40,
anchor: { x: 0.5, y: 1.0 }, // tip of the teardrop
},
},
],
[coord, pinIconUri]
);
// ── Locate-me: delegate to expo-osm-sdk's LocationButton via getCurrentLocation ──
const getCurrentLocation = useCallback(async () => {
setLocateError(null);
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== Location.PermissionStatus.GRANTED) {
throw new Error('Location permission denied');
}
const loc = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
return { latitude: loc.coords.latitude, longitude: loc.coords.longitude };
}, []);
// ── Confirm handler ──────────────────────────────────────────────────────────
const handleConfirm = useCallback(() => {
if (digipin) {
onLocationConfirm(coord.latitude, coord.longitude, digipin);
}
}, [coord, digipin, onLocationConfirm]);
const isInIndia = digipin !== null;
const canConfirm = isInIndia && mapReady;
return (
{/* ── Step header ── */}
1
Pin Your Location
Tap the map or drag the pin to your exact location
{/* ── Search bar ── */}
{enableSearch && (
{searchLoading && (
)}
{searchError ? (
{searchError}
) : null}
{suggestions.length > 0 ? (
String(i)}
style={[styles.searchDropdown, isDark && styles.searchDropdownDark]}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
handleSuggestionSelect(item)}
activeOpacity={0.7}
>
{item.displayName}
{item.address && item.address !== item.displayName ? (
{item.address}
) : null}
)}
/>
) : null}
DigiPin
Type your DigiPin for instant offline lookup
)}
{/* ── Map area ── */}
setMapReady(true)}
onPress={(coordinate) => {
handleCoordChange(coordinate.latitude, coordinate.longitude);
}}
onMarkerDragEnd={(_markerId, coordinate) => {
handleCoordChange(coordinate.latitude, coordinate.longitude);
}}
/>
{/* DigiPin badge overlay */}
{digipin ? (
DigiPin
{digipin}
) : null}
{/* Out-of-India notice */}
{!isInIndia && mapReady ? (
Move map to India to get a DigiPin
) : null}
{/* Locate-me button — expo-osm-sdk's built-in LocationButton */}
{
handleCoordChange(latitude, longitude);
void mapRef.current?.animateToLocation(latitude, longitude, STREET_ZOOM);
}}
onLocationError={(err) => setLocateError(err)}
/>
{/* Geo error toast */}
{locateError ? (
{locateError}
setLocateError(null)}
accessibilityLabel="Dismiss error"
>
×
) : null}
{/* ── Coordinates strip ── */}
{mapReady ? (
{coord.latitude.toFixed(5)}° N · {coord.longitude.toFixed(5)}° E
) : null}
{/* ── Confirm action bar ── */}
Confirm Location →
{!isInIndia && mapReady ? (
Move the pin to a location in India to continue
) : null}
);
};
export default MapPinSelector;