Consumer Location Live
The header pill + bottom sheet picker that lets a consumer set "where I'm browsing" and have every shop / product / request filtered to that location. Modeled after Swiggy / Zomato / Lieferando.
Shipped 2026-05-05.
1. Why this matters
The consumer-app brief was: "the entire relevance of the platform depends on showing the right content based on where the user is." Before this feature:
- No visible location control in the UI
- Location was set ad-hoc per screen via raw AsyncStorage reads (
ka26_user_location) - GPS calls used
Accuracy.Balanced/Accuracy.Low→ generic street-level results - Content endpoints accepted lat/lng but most screens didn't pass them
After:
- Top-right pill on every consumer tab, always visible
- Bottom-sheet picker: use current GPS / search address / pick on map
- Single
LocationContextis the source of truth across every fetch - GPS upgraded to
Accuracy.Highest→ ≤5 m precision - Shop + Requests fetches pass lat/lng so results match the active location
2. Architecture
App start
│
▼
LocationProvider (mounted at root _layout)
│
├─ STEP 1: AsyncStorage cache (instant)
│ ka26_user_location_v2 → setLocation immediately, loading=false
│
├─ STEP 2: GET /api/consumer/location (if logged in)
│ server wins → other-device updates propagate
│
├─ STEP 3: GPS auto-detect (Accuracy.Highest)
│ only if cache miss + server miss
│
└─ STEP 4: Gadag city centre default
last resort
User taps LocationPill
│
▼
LocationPickerModal opens
│
├─ "Use my current location" → useGPS() → reverse-geocode → setLocation
│
├─ "Search address" → AddressSearchModal (Google Places autocomplete)
│ → user picks → setLocation
│
└─ "Pick on map" → coming-soon Alert (next iteration)
setLocation:
1. setLocationState(next) // local
2. AsyncStorage.setItem(...) // cache
3. api.put('/api/consumer/location') // server (best-effort)
3. Schema
Added 2026-05-05 — additive, zero-downtime push to prod.
model Consumer {
// ... existing
latitude Float?
longitude Float?
city String?
// ─── New precision fields ───
addressLine String? // "12 MG Road, Gadag, Karnataka 582101"
locality String? // "MG Road" — for terse UI display
pincode String?
locationSource String? // "gps" | "manual" | "map"
locationUpdatedAt DateTime?
}
4. API
GET /api/consumer/location
Returns the consumer's stored location with the full precision payload:
{
"latitude": 12.9716,
"longitude": 77.5946,
"city": "Bangalore",
"addressLine": "Cubbon Park, Bangalore, Karnataka 560001",
"locality": "Cubbon Park",
"pincode": "560001",
"locationSource": "manual",
"locationUpdatedAt": "2026-05-05T..."
}
PUT /api/consumer/location
Body:
{
"latitude": 12.9716,
"longitude": 77.5946,
"city": "Bangalore",
"addressLine": "Cubbon Park, Bangalore, Karnataka 560001",
"locality": "Cubbon Park",
"pincode": "560001",
"source": "manual"
}
Backwards-compatible — old {lat, lng, city} callers keep working.
/api/places/* (existing server proxies)
GET /api/places/autocomplete?input=...— Google Places autocompleteGET /api/places/details?placeId=...— fetch lat/lng for a selected predictionGET /api/places/reverse-geocode?lat=...&lng=...— coords → address
API key (GOOGLE_MAPS_API_KEY) lives only on Cloud Run env, never on the client.
5. Content filtering
Every consumer fetch reads the active LocationContext and passes lat+lng to the backend. Endpoints already accept these params and filter via Haversine bounding box (see src/lib/geo.ts).
| Surface | Endpoint | Filter behaviour |
|---|---|---|
| Shop / StoresTab | GET /api/intelligence/feed?section=stores&lat=&lng= (with fallback to /api/stores) | Returns stores within delivery radius / nearby |
| Shop / AdsTab | GET /api/products?lat=&lng= | Filtered by Haversine bounding box |
| Requests | GET /api/requests?sort=nearest&lat=&lng= | Sorted by distance |
| Reels | (pill present for UX consistency; backend not yet geotag-indexed) |
When the user changes location via the pill, the React useCallback dep array includes location, which automatically triggers the fetch to re-run.
6. Precision upgrade
Before (Accuracy.Balanced / Accuracy.Low):
- Generic street-level resolution: "Wartenberger Straße"
- ~30-100 m typical fix on Android
After (Accuracy.Highest):
- House-number precision: "Wartenberger Straße 42B, 13053 Berlin"
- ≤5 m typical fix with GPS lock
- Slightly more battery + wait (~2-3 s extra) — acceptable trade for "set my location" use case
The upgrade applies to:
mobile/src/components/AddressSearchModal.tsx(mount-side + Use Current button)mobile/app/(tabs)/reels.tsx(post-flow location capture)mobile/src/contexts/LocationContext.tsxuseGPS()
The reverse-geocode endpoint also picks the most precise Google result (street_address / premise types) instead of the first-returned (which is sometimes a plus_code or generic political).
7. UI components
LocationPill
- Top-right header position on Shop / Reels / Requests
- Shows
📍 [locality or city]with a chevron-down hint - Two variants:
bare(transparent for dark headers) andtinted(light grey background for white headers) - Tap → opens
LocationPickerModal
LocationPickerModal
- Bottom sheet (Swiggy pattern)
- 3 rows, 64pt tap targets:
- Use my current location (icon:
locateblue) →useGPS() - Search address (icon:
searchamber) → opensAddressSearchModal - Pick a spot on the map (icon:
pinpurple, muted) → coming-soon Alert
- Use my current location (icon:
- Cancel button at bottom
- Shows "Currently: [addressLine]" header so user knows what they're switching from
AddressSearchModal (existing, enhanced)
- Already implemented Swiggy-style autocomplete via
/api/places/* - Returns
SelectedAddress { name, address, city, pincode, lat, lng } - Now invoked from
LocationPickerModalinstead of being scattered across screens - Also still standalone-mountable from any screen that needs an address picker
8. Test coverage
tests/consumer-location.test.ts — 23 regression-pinning assertions:
- Schema: 5 fields present on Consumer
- API: PUT accepts precision payload + writes
locationUpdatedAt; GET returns full shape - LocationContext: exists, exports
useLocation+LocationProvider+UserLocation; hydrates in correct order; usesAccuracy.Highest; persists to v2 cache key; pushes to server - UI: LocationPill exists; PickerModal has 3 rows; nests AddressSearchModal
- Wiring: LocationProvider in root
_layout; pill in Shop / Reels / Requests - Content filtering: fetchStores / fetchProducts / fetchRequests pass lat+lng
- GPS accuracy: no Low/Balanced left in mobile code; Highest everywhere
Full project after this change: 2,825 / 2,825 passing.
9. Live verification (2026-05-05)
/tmp/location-e2e.sh:
- Reverse-geocode Berlin Wartenberger Str. 42B → confirmed house number in result
- Autocomplete "Mahalaxmi Nagar Gadag" → 5+ predictions returned
- Register fresh consumer + mint JWT
- PUT location with full precision payload (Bangalore) → 200
- GET /api/consumer/location → returns the full payload including new fields
- GET /api/stores?lat=12.9716&lng=77.5946 → Bangalore-area stores
- GET /api/stores?lat=15.4319&lng=75.6297 → different Gadag-area stores
- GET /api/requests?sort=nearest&lat=12.9716&lng=77.5946 → distance-sorted
All assertions pass against prod.
10. Known follow-ups
- Map pin-drop screen — requires
react-native-mapsinstall + native rebuild + Google Maps Android key inapp.json. Picker shows "coming soon" Alert. Will land in v28 with the native rebuild. - Reels backend lat/lng filtering — pill is in the header for UX consistency; reels need a
geoLat/geoLngcolumn on the Reel model first. - Saved-addresses surface in picker — Uber-style "Home / Work / Saved" rows above the GPS row.
ConsumerAddressschema already exists; just needs UI wiring. - City-level fallback when GPS denied + first install — currently falls back to Gadag. Could ask the user "Where are you browsing from?" instead of silent fallback.
11. Related
- Account Deletion — the parallel destructive flow on the consumer app
- Seller Onboarding — the analogous wizard for sellers
- Why monolith — why mobile + backend ship together
- CHANGELOG entry