Refactor example app and add location tracking functionality with tests
This commit is contained in:
111
example/App.tsx
111
example/App.tsx
@@ -1,111 +0,0 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
|
||||||
import { SafeAreaView, StyleSheet, Button, View } from 'react-native';
|
|
||||||
import { MapView, Marker, PropertyMap, CameraPresets } from '@lynkedup/map-sdk';
|
|
||||||
import type { MapHandle } from '@lynkedup/map-sdk';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const mapRef = useRef<MapHandle | null>(null);
|
|
||||||
const [showPropertyMap, setShowPropertyMap] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container}>
|
|
||||||
<View style={styles.controls}>
|
|
||||||
<Button title="Toggle SDK Example" onPress={() => setShowPropertyMap((s) => !s)} />
|
|
||||||
{!showPropertyMap && (
|
|
||||||
<>
|
|
||||||
<View style={{ flexDirection: 'row' }}>
|
|
||||||
<Button
|
|
||||||
title="Fly to SF"
|
|
||||||
onPress={() => mapRef.current?.flyTo({ latitude: 37.78825, longitude: -122.4324 })}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Fit Bounds"
|
|
||||||
onPress={() =>
|
|
||||||
mapRef.current?.fitBounds({ latitude: 37.809, longitude: -122.410 }, { latitude: 37.779, longitude: -122.450 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ marginTop: 8, flexDirection: 'row', justifyContent: 'space-between' }}>
|
|
||||||
<Button
|
|
||||||
title="Tilt 60°"
|
|
||||||
onPress={() =>
|
|
||||||
mapRef.current?.animateCamera(
|
|
||||||
{ center: { latitude: 37.78825, longitude: -122.4324 }, pitch: 60 },
|
|
||||||
{ duration: 800 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Rotate 90°"
|
|
||||||
onPress={() =>
|
|
||||||
mapRef.current?.animateCamera(
|
|
||||||
{ center: { latitude: 37.78825, longitude: -122.4324 }, heading: 90 },
|
|
||||||
{ duration: 800 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Zoom In"
|
|
||||||
onPress={() =>
|
|
||||||
mapRef.current?.animateCamera(
|
|
||||||
{ center: { latitude: 37.78825, longitude: -122.4324 }, zoom: 15 },
|
|
||||||
{ duration: 800 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={{ marginTop: 8, flexDirection: 'row', justifyContent: 'space-between' }}>
|
|
||||||
<Button
|
|
||||||
title="Overlook"
|
|
||||||
onPress={() =>
|
|
||||||
mapRef.current?.animateCamera(
|
|
||||||
CameraPresets.viewOverlook({ latitude: 37.78825, longitude: -122.4324 }),
|
|
||||||
{ duration: 1000 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Street"
|
|
||||||
onPress={() =>
|
|
||||||
mapRef.current?.animateCamera(
|
|
||||||
CameraPresets.streetLevel({ latitude: 37.78825, longitude: -122.4324 }),
|
|
||||||
{ duration: 1000 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
title="Overview"
|
|
||||||
onPress={() =>
|
|
||||||
mapRef.current?.animateCamera(
|
|
||||||
CameraPresets.overview({ latitude: 37.78825, longitude: -122.4324 }),
|
|
||||||
{ duration: 1000 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{showPropertyMap ? (
|
|
||||||
<PropertyMap apiUrl="http://64.227.108.180:5000/property-search" />
|
|
||||||
) : (
|
|
||||||
<MapView
|
|
||||||
ref={mapRef}
|
|
||||||
style={styles.map}
|
|
||||||
initialRegion={{ latitude: 37.78825, longitude: -122.4324, latitudeDelta: 0.0922, longitudeDelta: 0.0421 }}
|
|
||||||
>
|
|
||||||
<Marker coordinate={{ latitude: 37.78825, longitude: -122.4324 }} />
|
|
||||||
</MapView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1 },
|
|
||||||
controls: { position: 'absolute', top: 40, left: 10, right: 10, zIndex: 1000, flexDirection: 'row', justifyContent: 'space-between' },
|
|
||||||
map: { flex: 1 }
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Example App
|
|
||||||
|
|
||||||
This example demonstrates usage of `@lynkedup/map-sdk`.
|
|
||||||
|
|
||||||
Setup:
|
|
||||||
|
|
||||||
1. From repo root run `npm run bootstrap` to install workspace packages.
|
|
||||||
2. From `example` run `npm install` to install native deps.
|
|
||||||
3. iOS: run `npx pod-install ios` and add your Google Maps API key if you use Google provider.
|
|
||||||
4. Android: ensure Google Play services and API keys are set in `AndroidManifest.xml` if using Google maps.
|
|
||||||
|
|
||||||
Run:
|
|
||||||
- `npm run ios` or `npm run android` from `example` folder.
|
|
||||||
|
|
||||||
See `react-native-maps` docs for platform-specific instructions: https://github.com/react-native-maps/react-native-maps
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "example",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"scripts": {
|
|
||||||
"start": "react-native start",
|
|
||||||
"ios": "react-native run-ios",
|
|
||||||
"android": "react-native run-android"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-native": "0.71.0",
|
|
||||||
"react-native-maps": "^1.3.2",
|
|
||||||
"@lynkedup/map-sdk": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,20 +15,24 @@ Basic usage:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MapView, Marker } from '@lynkedup/map-sdk';
|
import { MapView, Marker, useLocationTracking, Polyline } from '@lynkedup/map-sdk';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { isTracking, path, startTracking, stopTracking, clear } = useLocationTracking();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapView
|
<MapView style={{ flex: 1 }} initialRegion={{ latitude: 37.78825, longitude: -122.4324, latitudeDelta: 0.0922, longitudeDelta: 0.0421 }}>
|
||||||
style={{ flex: 1 }}
|
|
||||||
initialRegion={{ latitude: 37.78825, longitude: -122.4324, latitudeDelta: 0.0922, longitudeDelta: 0.0421 }}
|
|
||||||
>
|
|
||||||
<Marker coordinate={{ latitude: 37.78825, longitude: -122.4324 }} />
|
<Marker coordinate={{ latitude: 37.78825, longitude: -122.4324 }} />
|
||||||
|
{path.length > 1 && <Polyline coordinates={path} strokeWidth={4} strokeColor="#007AFF" />}
|
||||||
</MapView>
|
</MapView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Tracking notes:
|
||||||
|
- `useLocationTracking()` uses the platform geolocation API (`navigator.geolocation.watchPosition`). Request permissions on iOS/Android as usual before starting tracking.
|
||||||
|
- It returns `{ isTracking, path, current, startTracking, stopTracking, clear }` so you can display the polyline and follow the user.
|
||||||
|
|
||||||
## Camera Controls 🔧
|
## Camera Controls 🔧
|
||||||
|
|
||||||
This package exposes a small imperative `MapHandle` API via `ref` on `MapView`. Use it to animate the camera or fit bounds.
|
This package exposes a small imperative `MapHandle` API via `ref` on `MapView`. Use it to animate the camera or fit bounds.
|
||||||
|
|||||||
13
packages/map-sdk/jest.config.js
Normal file
13
packages/map-sdk/jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(ts|tsx)$': 'ts-jest',
|
||||||
|
'^.+\\.(js|jsx)$': 'babel-jest'
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: ["node_modules/(?!(react-native|@react-native|@react-native/polyfills|@react-native-maps)/)"],
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^react-native$': 'react-native'
|
||||||
|
},
|
||||||
|
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
|
||||||
|
testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)']
|
||||||
|
};
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"lint": "eslint . --ext .ts,.tsx"
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
|
"test": "jest --runInBand"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=17",
|
"react": ">=17",
|
||||||
@@ -21,7 +22,18 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"eslint": "^8.0.0",
|
"eslint": "^8.0.0",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-native": "^0.70.0"
|
"@types/react-native": "^0.70.0",
|
||||||
|
"jest": "^29.0.0",
|
||||||
|
"@types/jest": "^29.0.0",
|
||||||
|
"babel-jest": "^29.0.0",
|
||||||
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
|
"@testing-library/react-native": "^12.9.0",
|
||||||
|
"@testing-library/jest-native": "^5.4.3",
|
||||||
|
"react-test-renderer": "^19.0.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"ts-jest": "^29.0.0",
|
||||||
|
"babel-jest": "^29.0.0",
|
||||||
|
"jest-environment-jsdom": "^29.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
require('react-test-renderer');
|
||||||
|
const { renderHook, act } = require('@testing-library/react-hooks');
|
||||||
|
const { useLocationTracking } = require('../useLocationTracking');
|
||||||
|
|
||||||
|
describe('useLocationTracking', () => {
|
||||||
|
let origGeo;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
origGeo = global.navigator && global.navigator.geolocation;
|
||||||
|
global.navigator = global.navigator || {};
|
||||||
|
global.navigator.geolocation = {
|
||||||
|
watchPosition: jest.fn((success) => {
|
||||||
|
// Immediately call with a sample position
|
||||||
|
success({ coords: { latitude: 10, longitude: 20 } });
|
||||||
|
// Return a fake id
|
||||||
|
return 42;
|
||||||
|
}),
|
||||||
|
clearWatch: jest.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (origGeo) global.navigator.geolocation = origGeo;
|
||||||
|
else delete global.navigator.geolocation;
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts and stops tracking and collects path', () => {
|
||||||
|
const { result } = renderHook(() => useLocationTracking());
|
||||||
|
|
||||||
|
expect(result.current.isTracking).toBe(false);
|
||||||
|
act(() => result.current.startTracking());
|
||||||
|
expect(result.current.isTracking).toBe(true);
|
||||||
|
expect(result.current.path.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
act(() => result.current.stopTracking());
|
||||||
|
expect(result.current.isTracking).toBe(false);
|
||||||
|
expect(global.navigator.geolocation.clearWatch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears path', () => {
|
||||||
|
const { result } = renderHook(() => useLocationTracking());
|
||||||
|
act(() => result.current.startTracking());
|
||||||
|
expect(result.current.path.length).toBeGreaterThanOrEqual(1);
|
||||||
|
act(() => result.current.clear());
|
||||||
|
expect(result.current.path.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
packages/map-sdk/src/hooks/useLocationTracking.ts
Normal file
63
packages/map-sdk/src/hooks/useLocationTracking.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useRef, useState, useCallback } from 'react';
|
||||||
|
import type { LatLng } from 'react-native-maps';
|
||||||
|
|
||||||
|
export type TrackingOptions = {
|
||||||
|
distanceFilter?: number; // meters
|
||||||
|
interval?: number; // ms
|
||||||
|
fastestInterval?: number; // ms
|
||||||
|
accuracy?: 'high' | 'balanced' | 'low';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLocationTracking(opts: TrackingOptions = {}) {
|
||||||
|
const [isTracking, setIsTracking] = useState(false);
|
||||||
|
const [path, setPath] = useState<LatLng[]>([]);
|
||||||
|
const [current, setCurrent] = useState<LatLng | null>(null);
|
||||||
|
const watchIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const onPosition = useCallback((pos: any) => {
|
||||||
|
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]);
|
||||||
|
setCurrent(point);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTracking = useCallback(() => {
|
||||||
|
if (isTracking) return;
|
||||||
|
if (!global.navigator?.geolocation?.watchPosition) {
|
||||||
|
console.warn('[useLocationTracking] Geolocation.watchPosition not available.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = global.navigator.geolocation.watchPosition(
|
||||||
|
onPosition,
|
||||||
|
(err: any) => console.warn('geolocation error', err),
|
||||||
|
{
|
||||||
|
enableHighAccuracy: opts.accuracy === 'high',
|
||||||
|
distanceFilter: opts.distanceFilter ?? 0,
|
||||||
|
interval: opts.interval
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watchIdRef.current = id as unknown as number;
|
||||||
|
setIsTracking(true);
|
||||||
|
}, [isTracking, onPosition, 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);
|
||||||
|
}
|
||||||
|
watchIdRef.current = null;
|
||||||
|
setIsTracking(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setPath([]);
|
||||||
|
setCurrent(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isTracking, path, current, startTracking, stopTracking, clear } as const;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export { default as MapView } from './MapView';
|
|||||||
export type { MapHandle } from './MapView';
|
export type { MapHandle } from './MapView';
|
||||||
export { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
|
export { Marker, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
|
||||||
export { CameraPresets } from './cameraPresets';
|
export { CameraPresets } from './cameraPresets';
|
||||||
|
export { useLocationTracking } from './hooks/useLocationTracking';
|
||||||
|
|
||||||
// High-level components
|
// High-level components
|
||||||
export { default as PropertyMap } from './components/PropertyMap';
|
export { default as PropertyMap } from './components/PropertyMap';
|
||||||
|
|||||||
Reference in New Issue
Block a user