Skip to content

Commit 4fd91fc

Browse files
Banyel3claude
andcommitted
feat(ui,rider): shared pluggable map (picker+viewer), Nominatim reverse, fix flicker
Investigation (/plan-eng-review): the two mobile apps differ by role, so parity means sharing the UX *primitives*, not the screens. The map was customer-only and duplicated; rider navigated to a fuzzy text address instead of the exact point the customer pinned. packages/ui — one map stack, two interactions: - leaflet-map.ts: shared Leaflet-in-WebView html + pluggable tile presets (mapbox @2x, maptiler @2x, tomtom, osm) resolved from env via resolveTiles(). Tiles are the only HD lever; provider swaps with one env var, no code change. - MapPicker: refactored to take an injected `tiles` + `reverseGeocode` (api- agnostic). FIX: memoise the WebView `source` — an inline source object was a new ref every render, so RN WebView reloaded the map, which re-posted its centre, which re-rendered... the endless "Locating… <-> address" flicker. - MapView (new): read-only pin + coord Navigate, for the rider. - 11 unit tests for the tile resolver + html builder. rider-mobile: - orders/[id]: embeds MapView on the pickup, and Navigate now uses the exact pickupLat/Lng (falls back to text only for older orders with no pin). A text address can resolve blocks away — the whole point of the map picker was the coords, and the rider was throwing them away. - added react-native-webview + expo-location. customer-mobile: - components/MapPicker is now a thin wrapper injecting api.reverseGeocode + the env tile provider into the shared UI picker. api — reverseGeocode gains a keyless OSM Nominatim fallback: the TomTom dev key has Maps (tiles) scope but not Search, so reverse always returned null and the picker showed raw lat/lng. Nominatim is keyless AND permits storing the result (Mapbox geocoding forbids storage — we persist the label), so it's the correct provider for our use. TomTom stays primary when its Search scope is enabled. Tile provider (opt-in, no card needed for the free HD path): EXPO_PUBLIC_MAP_TILE_PROVIDER=maptiler|mapbox|tomtom|osm EXPO_PUBLIC_MAPTILER_KEY / EXPO_PUBLIC_MAPBOX_TOKEN / EXPO_PUBLIC_TOMTOM_MAP_KEY NOTE: react-native-webview + expo-location are native — rebuild both apps (`npx expo run:ios --device`) to pick them up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2853b21 commit 4fd91fc

13 files changed

Lines changed: 778 additions & 181 deletions

File tree

apps/api/src/maps/tomtom.provider.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,29 @@ describe('TomTomProvider', () => {
141141
).toBe('Tetuan, ZC');
142142
});
143143

144-
it('degrades to null when the Reverse Geocoding product is off (403)', async () => {
144+
it('falls back to Nominatim when TomTom Search is off (403)', async () => {
145+
const fetchFn = mockFetch((url) => {
146+
if (url.includes('nominatim')) {
147+
return {
148+
ok: true,
149+
status: 200,
150+
body: {
151+
address: {
152+
road: 'Veterans Ave',
153+
suburb: 'Tetuan',
154+
city: 'Zamboanga City',
155+
},
156+
},
157+
};
158+
}
159+
return { ok: false, status: 403, body: {} }; // TomTom reverse off
160+
});
161+
expect(
162+
await new TomTomProvider(KEY, fetchFn).reverseGeocode({ lat: 1, lng: 1 }),
163+
).toBe('Veterans Ave, Tetuan, Zamboanga City');
164+
});
165+
166+
it('degrades to null when both TomTom and Nominatim fail', async () => {
145167
const fetchFn = mockFetch(() => ({ ok: false, status: 403, body: {} }));
146168
expect(
147169
await new TomTomProvider(KEY, fetchFn).reverseGeocode({ lat: 1, lng: 1 }),

apps/api/src/maps/tomtom.provider.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import type {
77
} from '@wash-and-go/maps';
88

99
// Injectable fetch so specs can drive the adapter without real HTTP.
10-
export type FetchLike = (url: string) => Promise<{
10+
export type FetchLike = (
11+
url: string,
12+
init?: { headers?: Record<string, string> },
13+
) => Promise<{
1114
ok: boolean;
1215
status: number;
1316
json(): Promise<unknown>;
@@ -22,14 +25,34 @@ export type FetchLike = (url: string) => Promise<{
2225
* throw. Geocode failures also degrade to null so a booking never hard-fails on
2326
* a maps outage; route() throws (the caller needs a distance to price).
2427
*/
28+
// Build a short, human label from a Nominatim reverse result: street (with
29+
// house number if present), barangay/suburb, city — skipping blanks and dupes.
30+
// Falls back to the full display_name if we can't assemble parts.
31+
function shortLabel(body: {
32+
name?: string;
33+
display_name?: string;
34+
address?: Record<string, string>;
35+
}): string | undefined {
36+
const a = body.address ?? {};
37+
const street = [a.house_number, a.road ?? body.name].filter(Boolean).join(' ');
38+
const area = a.neighbourhood ?? a.suburb ?? a.village ?? a.quarter;
39+
const city = a.city ?? a.town ?? a.municipality ?? a.county;
40+
const parts = [street || body.name, area, city].filter(
41+
(p): p is string => !!p,
42+
);
43+
const seen = new Set<string>();
44+
const label = parts.filter((p) => !seen.has(p) && seen.add(p)).join(', ');
45+
return label || body.display_name || undefined;
46+
}
47+
2548
export class TomTomProvider implements MapsProvider {
2649
readonly name = 'tomtom';
2750
private readonly logger = new Logger('TomTomProvider');
2851
private readonly base = 'https://api.tomtom.com';
2952

3053
constructor(
3154
private readonly apiKey: string,
32-
private readonly fetchFn: FetchLike = (url) => fetch(url),
55+
private readonly fetchFn: FetchLike = (url, init) => fetch(url, init),
3356
) {}
3457

3558
async geocode(query: string): Promise<GeocodeResult | null> {
@@ -99,13 +122,42 @@ export class TomTomProvider implements MapsProvider {
99122
`?key=${this.apiKey}`;
100123
try {
101124
const res = await this.fetchFn(url);
102-
if (!res.ok) return this.warnNull('reverseGeocode', res.status);
125+
if (res.ok) {
126+
const body = (await res.json()) as {
127+
addresses?: { address?: { freeformAddress?: string } }[];
128+
};
129+
const label = body.addresses?.[0]?.address?.freeformAddress;
130+
if (label) return label;
131+
} else {
132+
this.warnNull('reverseGeocode', res.status);
133+
}
134+
} catch (e) {
135+
this.logger.warn(`reverseGeocode error: ${String(e)}`);
136+
}
137+
// Fallback: keyless OSM Nominatim. The dev TomTom key often has only the
138+
// Maps (tiles) product enabled, not Search — so reverse returns a readable
139+
// street name instead of raw coordinates. Nominatim policy: identify via
140+
// User-Agent, keep it to ~1 req/s (the picker debounces to one call).
141+
return this.nominatimReverse(point);
142+
}
143+
144+
private async nominatimReverse(point: GeoPoint): Promise<string | null> {
145+
const url =
146+
`https://nominatim.openstreetmap.org/reverse?format=jsonv2` +
147+
`&lat=${point.lat}&lon=${point.lng}&zoom=18&addressdetails=1`;
148+
try {
149+
const res = await this.fetchFn(url, {
150+
headers: { 'User-Agent': 'WashAndGo/1.0 (pilot; Zamboanga)' },
151+
});
152+
if (!res.ok) return this.warnNull('nominatimReverse', res.status);
103153
const body = (await res.json()) as {
104-
addresses?: { address?: { freeformAddress?: string } }[];
154+
name?: string;
155+
display_name?: string;
156+
address?: Record<string, string>;
105157
};
106-
return body.addresses?.[0]?.address?.freeformAddress ?? null;
158+
return shortLabel(body) ?? null;
107159
} catch (e) {
108-
this.logger.warn(`reverseGeocode error: ${String(e)}`);
160+
this.logger.warn(`nominatimReverse error: ${String(e)}`);
109161
return null;
110162
}
111163
}
Lines changed: 18 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,179 +1,29 @@
1-
import * as Location from 'expo-location';
2-
import React, { useCallback, useEffect, useRef, useState } from 'react';
3-
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
4-
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
5-
import { PrimaryButton, colors, space, type as typo } from '@wash-and-go/ui';
1+
import React from 'react';
2+
import { MapPicker as UIMapPicker, resolveTiles } from '@wash-and-go/ui';
63
import { api } from '../lib/api';
74

8-
// Center-pin map picker (Grab/Uber style): the map moves under a fixed pin; the
9-
// map centre is the chosen point. TomTom tiles via Leaflet in a WebView (reuses
10-
// the TomTom key, cross-platform, no Google key). Replaces the unreliable
11-
// type-an-address flow.
12-
const KEY = process.env.EXPO_PUBLIC_TOMTOM_MAP_KEY ?? '';
13-
const ZAMBOANGA: [number, number] = [6.9214, 122.079];
14-
15-
function mapHtml(lat: number, lng: number): string {
16-
return `<!doctype html><html><head>
17-
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
18-
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
19-
<style>
20-
html,body,#map{height:100%;margin:0;padding:0;background:#e7eef5}
21-
#pin{position:absolute;top:50%;left:50%;transform:translate(-50%,-100%);font-size:40px;z-index:1000;pointer-events:none;filter:drop-shadow(0 3px 3px rgba(0,0,0,.35))}
22-
</style></head><body>
23-
<div id="map"></div><div id="pin">📍</div>
24-
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
25-
<script>
26-
var map = L.map('map',{zoomControl:false,attributionControl:false}).setView([${lat},${lng}],16);
27-
L.tileLayer('https://api.tomtom.com/map/1/tile/basic/main/{z}/{x}/{y}.png?key=${KEY}',{maxZoom:22,minZoom:5}).addTo(map);
28-
function post(){var c=map.getCenter();window.ReactNativeWebView.postMessage(JSON.stringify({lat:c.lat,lng:c.lng}));}
29-
map.on('moveend', post);
30-
setTimeout(post, 300);
31-
</script></body></html>`;
32-
}
5+
// App-local wrapper: injects this app's API client (reverse-geocode) and the
6+
// tile provider resolved from env into the shared UI picker. Tile provider is
7+
// pluggable — set EXPO_PUBLIC_MAP_TILE_PROVIDER=maptiler|mapbox|tomtom|osm and
8+
// the matching key; falls back to the sharpest key present, then keyless OSM.
9+
const tiles = resolveTiles({
10+
provider: process.env.EXPO_PUBLIC_MAP_TILE_PROVIDER,
11+
mapboxToken: process.env.EXPO_PUBLIC_MAPBOX_TOKEN,
12+
mapTilerKey: process.env.EXPO_PUBLIC_MAPTILER_KEY,
13+
tomtomKey: process.env.EXPO_PUBLIC_TOMTOM_MAP_KEY,
14+
});
3315

34-
export function MapPicker({
35-
visible,
36-
initial,
37-
onClose,
38-
onPick,
39-
}: {
16+
export function MapPicker(props: {
4017
visible: boolean;
4118
initial?: { lat: number; lng: number } | null;
4219
onClose: () => void;
4320
onPick: (p: { lat: number; lng: number; address: string }) => void;
4421
}) {
45-
const [center, setCenter] = useState<[number, number] | null>(null);
46-
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
47-
const [label, setLabel] = useState<string>('Move the map to your pickup point');
48-
const [resolving, setResolving] = useState(false);
49-
const revTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
50-
51-
// Decide the starting centre once the sheet opens: passed-in point → device
52-
// GPS → Zamboanga fallback.
53-
useEffect(() => {
54-
if (!visible) {
55-
setCenter(null);
56-
return;
57-
}
58-
let cancelled = false;
59-
(async () => {
60-
if (initial) {
61-
if (!cancelled) setCenter([initial.lat, initial.lng]);
62-
return;
63-
}
64-
try {
65-
const perm = await Location.getForegroundPermissionsAsync();
66-
if (perm.status === 'granted') {
67-
const pos = await Location.getCurrentPositionAsync({
68-
accuracy: Location.Accuracy.Balanced,
69-
});
70-
if (!cancelled) {
71-
setCenter([pos.coords.latitude, pos.coords.longitude]);
72-
return;
73-
}
74-
}
75-
} catch {
76-
// fall through to default
77-
}
78-
if (!cancelled) setCenter(ZAMBOANGA);
79-
})();
80-
return () => {
81-
cancelled = true;
82-
};
83-
}, [visible, initial]);
84-
85-
const onMessage = useCallback((e: WebViewMessageEvent) => {
86-
let c: { lat: number; lng: number };
87-
try {
88-
c = JSON.parse(e.nativeEvent.data);
89-
} catch {
90-
return;
91-
}
92-
setCoords(c);
93-
// Debounce the reverse-geocode — one call after the map settles.
94-
if (revTimer.current) clearTimeout(revTimer.current);
95-
setResolving(true);
96-
revTimer.current = setTimeout(async () => {
97-
try {
98-
const { label: l } = await api.reverseGeocode(c.lat, c.lng);
99-
setLabel(l ?? `${c.lat.toFixed(5)}, ${c.lng.toFixed(5)}`);
100-
} catch {
101-
setLabel(`${c.lat.toFixed(5)}, ${c.lng.toFixed(5)}`);
102-
} finally {
103-
setResolving(false);
104-
}
105-
}, 450);
106-
}, []);
107-
108-
const confirm = () => {
109-
if (!coords) return;
110-
onPick({ lat: coords.lat, lng: coords.lng, address: label });
111-
};
112-
11322
return (
114-
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
115-
<View style={styles.root}>
116-
{center ? (
117-
<WebView
118-
style={{ flex: 1 }}
119-
originWhitelist={['*']}
120-
source={{ html: mapHtml(center[0], center[1]) }}
121-
onMessage={onMessage}
122-
scrollEnabled={false}
123-
/>
124-
) : (
125-
<View style={styles.loading}>
126-
<Text style={typo.body}>Getting your location…</Text>
127-
</View>
128-
)}
129-
130-
<Pressable onPress={onClose} style={styles.close} accessibilityRole="button">
131-
<Text style={styles.closeText}></Text>
132-
</Pressable>
133-
134-
<View style={styles.sheet}>
135-
<Text style={styles.sheetLabel} numberOfLines={2}>
136-
{resolving ? 'Locating…' : label}
137-
</Text>
138-
<PrimaryButton
139-
label="Use this location"
140-
onPress={confirm}
141-
disabled={!coords}
142-
testID="map-confirm"
143-
/>
144-
</View>
145-
</View>
146-
</Modal>
23+
<UIMapPicker
24+
{...props}
25+
tiles={tiles}
26+
reverseGeocode={(lat, lng) => api.reverseGeocode(lat, lng)}
27+
/>
14728
);
14829
}
149-
150-
const styles = StyleSheet.create({
151-
root: { flex: 1, backgroundColor: colors.bg },
152-
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
153-
close: {
154-
position: 'absolute',
155-
top: 52,
156-
left: 16,
157-
width: 40,
158-
height: 40,
159-
borderRadius: 20,
160-
backgroundColor: colors.surface,
161-
alignItems: 'center',
162-
justifyContent: 'center',
163-
shadowColor: '#000',
164-
shadowOpacity: 0.18,
165-
shadowRadius: 6,
166-
shadowOffset: { width: 0, height: 2 },
167-
elevation: 4,
168-
},
169-
closeText: { fontSize: 18, color: colors.text, fontWeight: '700' },
170-
sheet: {
171-
padding: space.lg,
172-
paddingBottom: space.xl,
173-
gap: space.md,
174-
backgroundColor: colors.surface,
175-
borderTopLeftRadius: 20,
176-
borderTopRightRadius: 20,
177-
},
178-
sheetLabel: { ...typo.title, color: colors.text },
179-
});

apps/rider-mobile/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"expo-constants": "~57.0.6",
2121
"expo-font": "^57.0.1",
2222
"expo-linking": "~57.0.3",
23+
"expo-location": "~57.0.5",
2324
"expo-router": "~57.0.7",
2425
"expo-splash-screen": "~57.0.4",
2526
"expo-status-bar": "~57.0.1",
@@ -31,6 +32,7 @@
3132
"react-native-reanimated": "4.5.0",
3233
"react-native-safe-area-context": "~5.7.0",
3334
"react-native-screens": "4.25.2",
35+
"react-native-webview": "13.16.1",
3436
"react-native-web": "~0.21.0",
3537
"react-native-worklets": "0.10.0",
3638
"firebase": "^12.16.0",

0 commit comments

Comments
 (0)