Skip to content

Commit 2853b21

Browse files
Banyel3claude
andcommitted
feat(customer): map-based location picker (TomTom tiles) replaces text address
Typing an address for a GPS pickup service was unreliable and produced two conflicting buttons ("Use my location" vs "Find this address" set different points). Replaced with one map picker: - MapPicker (react-native-webview + Leaflet + TomTom raster tiles, EXPO_PUBLIC_ TOMTOM_MAP_KEY): Grab-style centre pin, opens on device GPS (falls back to Zamboanga), pan to place the pin, debounced reverse-geocode (/geocode/reverse) labels it, "Use this location" returns { lat, lng, address }. Reuses the TomTom key — no Google Maps key, cross-platform, matches the admin map's tile approach. - book.tsx: dropped the address TextInput + the two location buttons for a single "Set pickup on map"; shows the pinned address in a card. - addresses.tsx: the add form now pins the location on the map (and finally SAVES lat/lng — the old text-only form saved addresses with no coordinates, useless for a pickup service). NOTE: react-native-webview is a native module → run `npx expo run:ios --device` once to pick it up (JS reload won't include it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e206222 commit 2853b21

5 files changed

Lines changed: 286 additions & 131 deletions

File tree

apps/customer-mobile/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
"private": true,
66
"scripts": {
77
"start": "expo start",
8-
"ios": "expo start --ios",
9-
"android": "expo start --android",
8+
"ios": "expo run:ios",
9+
"android": "expo run:android",
1010
"web": "expo start --web --port 3000",
1111
"type-check": "tsc --noEmit",
1212
"test": "jest --config jest.logic.config.js"
@@ -37,6 +37,7 @@
3737
"react-native-safe-area-context": "~5.7.0",
3838
"react-native-screens": "4.25.2",
3939
"react-native-web": "~0.21.0",
40+
"react-native-webview": "13.16.1",
4041
"react-native-worklets": "0.10.0"
4142
},
4243
"devDependencies": {

apps/customer-mobile/src/app/addresses.tsx

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ import {
1515
useToast,
1616
} from '@wash-and-go/ui';
1717
import { api } from '../lib/api';
18+
import { MapPicker } from '../components/MapPicker';
1819

1920
export default function AddressesScreen() {
2021
const toast = useToast();
2122
const [list, setList] = useState<AddressView[] | null>(null);
2223
const [error, setError] = useState<string | null>(null);
2324
const [label, setLabel] = useState('');
2425
const [line, setLine] = useState('');
26+
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
27+
const [mapOpen, setMapOpen] = useState(false);
2528
const [busy, setBusy] = useState(false);
2629

2730
const load = useCallback(async () => {
@@ -38,25 +41,27 @@ export default function AddressesScreen() {
3841
}, [load]);
3942

4043
const add = useCallback(async () => {
41-
const l = line.trim();
42-
if (l.length < 3 || busy) return;
44+
if (!coords || busy) return;
4345
setBusy(true);
4446
try {
4547
await api.createAddress({
46-
line: l,
48+
line: line.trim() || 'Pinned location',
4749
label: label.trim() || undefined,
50+
lat: coords.lat,
51+
lng: coords.lng,
4852
isDefault: (list?.length ?? 0) === 0, // first one is the default
4953
});
5054
setLabel('');
5155
setLine('');
56+
setCoords(null);
5257
await load();
5358
toast.success('Address saved');
5459
} catch (e) {
5560
toast.error(e instanceof Error ? e.message : 'Could not add that address.');
5661
} finally {
5762
setBusy(false);
5863
}
59-
}, [line, label, busy, list, load, toast]);
64+
}, [coords, line, label, busy, list, load, toast]);
6065

6166
const setDefault = useCallback(
6267
async (id: string) => {
@@ -162,22 +167,38 @@ export default function AddressesScreen() {
162167
style={styles.input}
163168
testID="addr-label-input"
164169
/>
165-
<TextInput
166-
value={line}
167-
onChangeText={setLine}
168-
placeholder="Address (street, barangay)"
169-
placeholderTextColor={colors.textMuted}
170-
style={styles.input}
171-
multiline
172-
testID="addr-line-input"
170+
{coords ? (
171+
<Card style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
172+
<Text style={{ fontSize: 18 }}>📍</Text>
173+
<Text style={[type.body, { flex: 1, color: colors.text }]} numberOfLines={2}>
174+
{line || 'Pinned location'}
175+
</Text>
176+
</Card>
177+
) : null}
178+
<PrimaryButton
179+
label={coords ? 'Change location on map' : '📍 Set location on map'}
180+
onPress={() => setMapOpen(true)}
181+
tone="terra"
182+
testID="addr-open-map"
173183
/>
174184
<PrimaryButton
175185
label="Add address"
176186
onPress={add}
177-
disabled={line.trim().length < 3}
187+
disabled={!coords}
178188
loading={busy}
179189
testID="add-address"
180190
/>
191+
192+
<MapPicker
193+
visible={mapOpen}
194+
initial={coords}
195+
onClose={() => setMapOpen(false)}
196+
onPick={(p) => {
197+
setCoords({ lat: p.lat, lng: p.lng });
198+
setLine(p.address);
199+
setMapOpen(false);
200+
}}
201+
/>
181202
</Screen>
182203
);
183204
}

apps/customer-mobile/src/app/book.tsx

Lines changed: 30 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { router } from 'expo-router';
2-
import * as Location from 'expo-location';
32
import React, { useCallback, useEffect, useMemo, useState } from 'react';
4-
import { Alert, Linking, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
3+
import { Pressable, StyleSheet, Text, View } from 'react-native';
54
import type { AddressView } from '@wash-and-go/domain';
5+
import { MapPicker } from '../components/MapPicker';
66
import {
77
Card,
88
Muted,
@@ -47,11 +47,9 @@ export default function BookScreen() {
4747
const slots = useMemo(buildSlots, []);
4848
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
4949
const [address, setAddress] = useState('');
50-
const [gpsLoading, setGpsLoading] = useState(false);
51-
const [geoLoading, setGeoLoading] = useState(false);
50+
const [mapOpen, setMapOpen] = useState(false);
5251
const [saved, setSaved] = useState<AddressView[]>([]);
5352
const [saveNew, setSaveNew] = useState(false);
54-
const toast = useToast();
5553

5654
// Load the address book to prefill pickup (best-effort — booking works without).
5755
useEffect(() => {
@@ -69,69 +67,15 @@ export default function BookScreen() {
6967
}
7068
}, []);
7169

72-
// Geocode the typed address → pin its coordinates (no GPS needed).
73-
const findAddress = useCallback(async () => {
74-
const q = address.trim();
75-
if (q.length < 2) return;
76-
setGeoLoading(true);
77-
try {
78-
const hit = await api.geocode(q);
79-
if (hit) {
80-
setCoords(hit.point);
81-
setAddress(hit.label);
82-
toast.success('Address pinned.');
83-
} else {
84-
toast.error('No match for that address. Try GPS or add more detail.');
85-
}
86-
} catch {
87-
toast.error('Could not search that address. Try again or use GPS.');
88-
} finally {
89-
setGeoLoading(false);
90-
}
91-
}, [address]);
92-
93-
const useMyLocation = useCallback(async () => {
94-
setGpsLoading(true);
95-
try {
96-
const { status, canAskAgain } = await Location.requestForegroundPermissionsAsync();
97-
if (status !== 'granted') {
98-
// Not an error — prompt them to turn it on. If iOS won't re-ask
99-
// (previously denied), the only way back is Settings, so offer it.
100-
Alert.alert(
101-
'Location is off',
102-
'Turn on location access to drop your pickup pin automatically.',
103-
canAskAgain
104-
? [{ text: 'OK' }]
105-
: [
106-
{ text: 'Not now', style: 'cancel' },
107-
{ text: 'Open Settings', onPress: () => void Linking.openSettings() },
108-
],
109-
);
110-
return;
111-
}
112-
const pos = await Location.getCurrentPositionAsync({
113-
accuracy: Location.Accuracy.Balanced,
114-
});
115-
const { latitude, longitude } = pos.coords;
116-
setCoords({ lat: latitude, lng: longitude });
117-
try {
118-
const [place] = await Location.reverseGeocodeAsync({ latitude, longitude });
119-
if (place) {
120-
setAddress(
121-
[place.name, place.street, place.district, place.city]
122-
.filter(Boolean)
123-
.join(', '),
124-
);
125-
}
126-
} catch {
127-
// best-effort
128-
}
129-
} catch {
130-
toast.error('Could not read your location. Try again or type your address.');
131-
} finally {
132-
setGpsLoading(false);
133-
}
134-
}, []);
70+
// Pickup point is chosen on the map (single source of truth) — see MapPicker.
71+
const onPickLocation = useCallback(
72+
(p: { lat: number; lng: number; address: string }) => {
73+
setCoords({ lat: p.lat, lng: p.lng });
74+
setAddress(p.address);
75+
setMapOpen(false);
76+
},
77+
[],
78+
);
13579

13680
// Loads over the Express ceiling route to Scheduled (Tier 1) — the customer
13781
// picks a pickup window; Express stays on-demand.
@@ -290,35 +234,28 @@ export default function BookScreen() {
290234
</View>
291235
) : null}
292236

237+
{coords ? (
238+
<Card style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
239+
<Text style={{ fontSize: 18 }}>📍</Text>
240+
<Text style={[type.body, { flex: 1, color: colors.text }]} numberOfLines={2}>
241+
{address || 'Pinned location'}
242+
</Text>
243+
</Card>
244+
) : null}
245+
293246
<PrimaryButton
294-
label={coords ? '📍 Location set — update' : '📍 Use my location'}
295-
onPress={useMyLocation}
296-
loading={gpsLoading}
247+
label={coords ? 'Change pickup on map' : '📍 Set pickup on map'}
248+
onPress={() => setMapOpen(true)}
297249
tone="terra"
298-
/>
299-
<TextInput
300-
value={address}
301-
onChangeText={setAddress}
302-
placeholder="Pickup address (street, barangay)"
303-
placeholderTextColor={colors.textMuted}
304-
style={styles.input}
305-
multiline
250+
testID="open-map"
306251
/>
307252

308-
<Pressable
309-
onPress={findAddress}
310-
disabled={address.trim().length < 2 || geoLoading}
311-
style={({ pressed }) => [
312-
styles.findBtn,
313-
(address.trim().length < 2 || geoLoading) && { opacity: 0.5 },
314-
pressed && { opacity: 0.8 },
315-
]}
316-
accessibilityRole="button"
317-
>
318-
<Text style={styles.findBtnT}>
319-
{geoLoading ? 'Searching…' : '🔎 Find this address'}
320-
</Text>
321-
</Pressable>
253+
<MapPicker
254+
visible={mapOpen}
255+
initial={coords}
256+
onClose={() => setMapOpen(false)}
257+
onPick={onPickLocation}
258+
/>
322259

323260
<Pressable
324261
onPress={() => setSaveNew((v) => !v)}

0 commit comments

Comments
 (0)