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;