Skip to main content

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 LocationContext is 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 autocomplete
  • GET /api/places/details?placeId=... — fetch lat/lng for a selected prediction
  • GET /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).

SurfaceEndpointFilter behaviour
Shop / StoresTabGET /api/intelligence/feed?section=stores&lat=&lng= (with fallback to /api/stores)Returns stores within delivery radius / nearby
Shop / AdsTabGET /api/products?lat=&lng=Filtered by Haversine bounding box
RequestsGET /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.tsx useGPS()

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) and tinted (light grey background for white headers)
  • Tap → opens LocationPickerModal

LocationPickerModal

  • Bottom sheet (Swiggy pattern)
  • 3 rows, 64pt tap targets:
    • Use my current location (icon: locate blue) → useGPS()
    • Search address (icon: search amber) → opens AddressSearchModal
    • Pick a spot on the map (icon: pin purple, muted) → coming-soon Alert
  • 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 LocationPickerModal instead 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; uses Accuracy.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:

  1. Reverse-geocode Berlin Wartenberger Str. 42B → confirmed house number in result
  2. Autocomplete "Mahalaxmi Nagar Gadag" → 5+ predictions returned
  3. Register fresh consumer + mint JWT
  4. PUT location with full precision payload (Bangalore) → 200
  5. GET /api/consumer/location → returns the full payload including new fields
  6. GET /api/stores?lat=12.9716&lng=77.5946 → Bangalore-area stores
  7. GET /api/stores?lat=15.4319&lng=75.6297 → different Gadag-area stores
  8. GET /api/requests?sort=nearest&lat=12.9716&lng=77.5946 → distance-sorted

All assertions pass against prod.

10. Known follow-ups

  1. Map pin-drop screen — requires react-native-maps install + native rebuild + Google Maps Android key in app.json. Picker shows "coming soon" Alert. Will land in v28 with the native rebuild.
  2. Reels backend lat/lng filtering — pill is in the header for UX consistency; reels need a geoLat / geoLng column on the Reel model first.
  3. Saved-addresses surface in picker — Uber-style "Home / Work / Saved" rows above the GPS row. ConsumerAddress schema already exists; just needs UI wiring.
  4. 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.