diff --git a/packages/map-sdk/README.md b/packages/map-sdk/README.md index f979f7c..76688a5 100644 --- a/packages/map-sdk/README.md +++ b/packages/map-sdk/README.md @@ -18,7 +18,7 @@ import React from 'react'; import { MapView, Marker, useLocationTracking, Polyline } from '@lynkedup/map-sdk'; export default function App() { - const { isTracking, path, startTracking, stopTracking, clear } = useLocationTracking(); + const { isTracking, path, startTracking, stopTracking, clear } = useLocationTracking(/* opts?: TrackingOptions, geolocation?: GeolocationAPI */); return ( @@ -27,6 +27,8 @@ export default function App() { ); } + +> Tip: `useLocationTracking()` accepts an optional `TrackingOptions` object and an optional `GeolocationAPI` (handy for testing with a mocked geolocation provider). Ensure you request platform location permissions before starting tracking. ``` Tracking notes: @@ -103,11 +105,14 @@ export default function PropertiesScreen() { Props summary: -- `apiUrl?: string` — POST endpoint used for property searches (defaults to the demo host). -- `initialRegion?: Region` — starting region. -- `radius?: number` — search radius in meters (default: 500). -- `showSearch?: boolean` — render search bar (default: true). -- `onSelectProperty?: (item) => void` — callback for property selection. +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `apiUrl` | `string` | demo host | POST endpoint used for property searches. Should accept `{ location, radius }` and return `{ success, properties, location }`. +| `initialRegion` | `Region` | `DEFAULT_REGION` | Starting region for the map. +| `radius` | `number` | `500` | Search radius in meters. +| `showSearch` | `boolean` | `true` | Show or hide the built-in search bar. +| `onSelectProperty` | `(item: PropertyItem) => void` | — | Callback invoked when a property is selected. +| `mapProps` | `Partial` | — | Props forwarded to the underlying `react-native-maps` view (e.g., `mapType`, `showsMyLocationButton`). --- diff --git a/packages/map-sdk/src/components/PriceMarker.tsx b/packages/map-sdk/src/components/PriceMarker.tsx index a9b3e29..5e62682 100644 --- a/packages/map-sdk/src/components/PriceMarker.tsx +++ b/packages/map-sdk/src/components/PriceMarker.tsx @@ -2,41 +2,53 @@ import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { Marker } from 'react-native-maps'; +/** + * Minimal property item shape used by the SDK components. + */ export type PropertyItem = { id: string | number; location?: { lat: number; lng: number }; price?: number; price_display?: string; + address?: string; + bedrooms?: number; + bathrooms?: number; + polygon?: number[][]; // [[lat, lng], ...] }; +/** + * Simple price formatter that returns human-readable price strings (K/M abbreviations). + */ export const formatPrice = (value?: number | string) => { - if (!value) return ''; + if (value === null || value === undefined || value === '') return ''; const num = typeof value === 'string' ? Number(value) : value; - if (!num) return String(value); + if (!num && num !== 0) return String(value); if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; if (num >= 1000) return `${Math.round(num / 1000)}K`; return num.toString(); }; -const PriceMarker: React.FC<{ +export type PriceMarkerProps = { item: PropertyItem; selected?: boolean; onPress?: (item: PropertyItem) => void; -}> = ({ item, selected, onPress }) => { - if (!item?.location) return null; +}; - return ( - { - e.stopPropagation(); - onPress && onPress(item); - }} +/** + * A small marker component that displays a price label and forwards press events. + */ +const PriceMarker: React.FC = ({ item, selected, onPress }) => { + coordinate = {{ latitude: item.location.lat, longitude: item.location.lng } +} +onPress = {(e) => { + e.stopPropagation(); + onPress && onPress(item); +}} > - - {item.price_display ?? formatPrice(item.price)} - - + + {item.price_display ?? formatPrice(item.price)} + + ); }; diff --git a/packages/map-sdk/src/components/PropertyMap.tsx b/packages/map-sdk/src/components/PropertyMap.tsx index 807d772..9f8f3b4 100644 --- a/packages/map-sdk/src/components/PropertyMap.tsx +++ b/packages/map-sdk/src/components/PropertyMap.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { View, StyleSheet, - Platform, Text, FlatList, Image, @@ -13,21 +12,32 @@ import { TouchableOpacity, } from 'react-native'; import ClusteredMapView from 'react-native-map-clustering'; -import { Polygon } from 'react-native-maps'; -import Geolocation from '@react-native-community/geolocation'; +import RNMapView, { Region, Polygon, LatLng } from 'react-native-maps'; +import type { MapViewProps as RNMapViewProps } from 'react-native-maps'; import PriceMarker, { formatPrice, PropertyItem } from './PriceMarker'; const { width } = Dimensions.get('window'); +/** + * Props for the `PropertyMap` high-level component. + */ export type PropertyMapProps = { + /** API endpoint used for POST property searches. Should accept `{ location, radius }` and return `{ success, properties, location }` */ apiUrl?: string; - initialRegion?: any; + /** Initial map region */ + initialRegion?: Region; + /** Search radius in meters (default: 500) */ radius?: number; + /** Show/hide the search bar */ showSearch?: boolean; + /** Callback when a property/item is selected */ onSelectProperty?: (item: PropertyItem) => void; + /** Optional props forwarded to the underlying map view */ + mapProps?: Partial; }; -export const DEFAULT_REGION = { +/** Default region used when none is provided */ +export const DEFAULT_REGION: Region = { latitude: 25.48131, longitude: 81.854249, latitudeDelta: 0.02, @@ -35,20 +45,24 @@ export const DEFAULT_REGION = { }; export const getPolygonCoords = (polygon?: number[][]) => { - if (!polygon) return []; + if (!polygon) return [] as LatLng[]; return polygon.map(([lat, lng]) => ({ latitude: lat, longitude: lng })); }; +/** + * `PropertyMap` — a ready-to-use map screen for property searches. Includes clustering, polygon rendering and a bottom property card. + */ const PropertyMap: React.FC = ({ apiUrl = 'http://64.227.108.180:5000/property-search', initialRegion = DEFAULT_REGION, radius = 500, showSearch = true, onSelectProperty, + mapProps = {}, }) => { - const mapRef = useRef(null); + const mapRef = useRef(null); - const [region, setRegion] = useState(initialRegion); + const [region, setRegion] = useState(initialRegion); const [properties, setProperties] = useState([]); const [selectedItem, setSelectedItem] = useState(null); const [loading, setLoading] = useState(false); @@ -70,12 +84,16 @@ const PropertyMap: React.FC = ({ const requestLocation = async () => { try { - Geolocation.getCurrentPosition((pos) => { - const { latitude, longitude } = pos.coords; - const newRegion = { latitude, longitude, latitudeDelta: 0.02, longitudeDelta: 0.02 }; - setRegion(newRegion); - mapRef.current?.animateToRegion?.(newRegion, 1000); - }); + // prefer bundled community geolocation if available + const geo = (global.navigator && (global.navigator as any).geolocation) || null; + if (geo?.getCurrentPosition) { + geo.getCurrentPosition((pos: any) => { + const { latitude, longitude } = pos.coords; + const newRegion = { latitude, longitude, latitudeDelta: 0.02, longitudeDelta: 0.02 } as Region; + setRegion(newRegion); + mapRef.current?.animateToRegion?.(newRegion, 1000); + }); + } } catch (err) { // ignore } @@ -96,13 +114,13 @@ const PropertyMap: React.FC = ({ const json = await response.json(); if (json?.success && json?.location?.coordinates?.lat && json?.location?.coordinates?.lng) { - const newRegion = { + const newRegion: Region = { latitude: json.location.coordinates.lat, longitude: json.location.coordinates.lng, latitudeDelta: 0.02, longitudeDelta: 0.02, }; - setProperties(json.properties || []); + setProperties((json.properties as PropertyItem[]) || []); setRegion(newRegion); mapRef.current?.animateToRegion?.(newRegion, 1000); } @@ -149,7 +167,7 @@ const PropertyMap: React.FC = ({ ref={mapRef} style={StyleSheet.absoluteFillObject} region={region} - onRegionChangeComplete={(r: any) => { + onRegionChangeComplete={(r: Region) => { if (!r?.longitudeDelta) return; setRegion(r); }} @@ -157,53 +175,54 @@ const PropertyMap: React.FC = ({ animationEnabled clusterPressMaxZoom={16} spiralEnabled={false} - > - {properties.map((item) => - (item as any).polygon ? ( - - ) : null + {...mapProps} + key={`poly-${item.id}`} + coordinates={getPolygonCoords((item as any).polygon)} + strokeColor={selectedItem?.id === item.id ? '#F4C430' : '#8B0000'} + fillColor="rgba(139, 0, 0, 0.15)" + strokeWidth={2} + /> + ) : null )} - {properties.map((item) => ( - - ))} - + {properties.map((item) => ( + + ))} + - {loading && ( - - - - )} + { + loading && ( + + + + ) + } - {selectedItem && ( - - String(item.id)} - showsHorizontalScrollIndicator={false} - renderItem={({ item }) => ( - - - - {item.price_display ?? formatPrice(item.price)} - {(item as any).bedrooms} Beds • {(item as any).bathrooms} Baths - - {(item as any).address} - - + { + selectedItem && ( + + String(item.id)} + showsHorizontalScrollIndicator={false} + renderItem={({ item }) => ( + + + + {item.price_display ?? formatPrice(item.price)} + {(item as any).bedrooms} Beds • {(item as any).bathrooms} Baths + + {(item as any).address} + - )} - /> - - )} - + + )} + /> + + ) + } + ); }; diff --git a/packages/map-sdk/src/hooks/useLocationTracking.ts b/packages/map-sdk/src/hooks/useLocationTracking.ts index 02226f4..de4816c 100644 --- a/packages/map-sdk/src/hooks/useLocationTracking.ts +++ b/packages/map-sdk/src/hooks/useLocationTracking.ts @@ -1,22 +1,49 @@ import { useRef, useState, useCallback } from 'react'; import type { LatLng } from 'react-native-maps'; +/** + * Options for location tracking. + * - distanceFilter: minimum change in meters to receive updates. + * - interval / fastestInterval: Android/JS timers (ms). + * - accuracy: 'high' enables high accuracy via GPS. + */ export type TrackingOptions = { - distanceFilter?: number; // meters - interval?: number; // ms - fastestInterval?: number; // ms + distanceFilter?: number; + interval?: number; + fastestInterval?: number; accuracy?: 'high' | 'balanced' | 'low'; }; -export function useLocationTracking(opts: TrackingOptions = {}) { +/** + * Minimal Geolocation API interface (compatible with browser and @react-native-community/geolocation). + */ +export type GeolocationAPI = { + watchPosition: ( + success: (pos: { coords: { latitude: number; longitude: number; accuracy?: number } }) => void, + error?: (err: any) => void, + options?: { enableHighAccuracy?: boolean; distanceFilter?: number; interval?: number } + ) => number | string; + clearWatch: (id: number | string) => void; +}; + +/** + * Hook to track user's location path. + * + * @param opts TrackingOptions - config for watchPosition + * @param geolocation optional custom geolocation implementation (useful for testing) + * @returns object with tracking state and control functions + */ +export function useLocationTracking(opts: TrackingOptions = {}, geolocation?: GeolocationAPI) { const [isTracking, setIsTracking] = useState(false); const [path, setPath] = useState([]); const [current, setCurrent] = useState(null); - const watchIdRef = useRef(null); + const watchIdRef = useRef(null); - const onPosition = useCallback((pos: any) => { - const lat = pos.coords?.latitude; - const lng = pos.coords?.longitude; + const geo = geolocation ?? (global.navigator && (global.navigator as any).geolocation) as GeolocationAPI | undefined; + + const onPosition = useCallback((pos: { coords: { latitude: number; longitude: number } }) => { + const lat = pos?.coords?.latitude; + const lng = pos?.coords?.longitude; if (typeof lat === 'number' && typeof lng === 'number') { const point = { latitude: lat, longitude: lng } as LatLng; setPath((p) => [...p, point]); @@ -26,33 +53,33 @@ export function useLocationTracking(opts: TrackingOptions = {}) { const startTracking = useCallback(() => { if (isTracking) return; - if (!global.navigator?.geolocation?.watchPosition) { + if (!geo || !geo.watchPosition) { console.warn('[useLocationTracking] Geolocation.watchPosition not available.'); return; } - const id = global.navigator.geolocation.watchPosition( + const id = geo.watchPosition( onPosition, (err: any) => console.warn('geolocation error', err), { enableHighAccuracy: opts.accuracy === 'high', distanceFilter: opts.distanceFilter ?? 0, - interval: opts.interval + interval: opts.interval, } ); - watchIdRef.current = id as unknown as number; + watchIdRef.current = id; setIsTracking(true); - }, [isTracking, onPosition, opts.accuracy, opts.distanceFilter, opts.interval]); + }, [isTracking, onPosition, geo, opts.accuracy, opts.distanceFilter, opts.interval]); const stopTracking = useCallback(() => { const id = watchIdRef.current; - if (id != null && global.navigator?.geolocation?.clearWatch) { - global.navigator.geolocation.clearWatch(id); + if (id != null && geo && geo.clearWatch) { + geo.clearWatch(id); } watchIdRef.current = null; setIsTracking(false); - }, []); + }, [geo]); const clear = useCallback(() => { setPath([]); diff --git a/packages/map-sdk/src/index.ts b/packages/map-sdk/src/index.ts index d32f29c..17ccf07 100644 --- a/packages/map-sdk/src/index.ts +++ b/packages/map-sdk/src/index.ts @@ -3,9 +3,11 @@ export type { MapHandle } from './MapView'; export { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps'; export { CameraPresets } from './cameraPresets'; export { useLocationTracking } from './hooks/useLocationTracking'; +export type { TrackingOptions, GeolocationAPI } from './hooks/useLocationTracking'; // High-level components export { default as PropertyMap } from './components/PropertyMap'; +export type { PropertyMapProps } from './components/PropertyMap'; export { default as PriceMarker } from './components/PriceMarker'; export { formatPrice } from './components/PriceMarker'; -export type { PropertyItem } from './components/PriceMarker'; +export type { PropertyItem, PriceMarkerProps } from './components/PriceMarker';