diff --git a/app/app.json b/app/app.json index 27cdffe..361913f 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.6.3", + "version": "0.6.4", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 24, + "versionCode": 25, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/features/location/useLocation.ts b/app/src/features/location/useLocation.ts index 43eb230..c546634 100644 --- a/app/src/features/location/useLocation.ts +++ b/app/src/features/location/useLocation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import * as Location from 'expo-location'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -7,6 +7,20 @@ export interface Coords { longitude: number; } +export interface UseLocationOptions { + /** + * Re-read the OS fix this often, in ms. Omit (or 0) for a single fix at mount. + * A fix goes stale as soon as you drive a block, so any screen that shows + * "where am I" for more than a moment wants this. + */ + intervalMs?: number; + /** + * Poll only while true. Callers pass screen focus AND app foreground: polling + * a map nobody is looking at spends battery on an answer no one reads. + */ + active?: boolean; +} + const LAST_LOC_KEY = 'ps_last_location'; /** Persist the most recent fix so the "near my last location" button works cold. */ @@ -21,32 +35,54 @@ export async function getLastKnownSavedLocation(): Promise { /** * Foreground location. On GrapheneOS this uses the OS location provider directly * (no Google Play Services). We prefer a fast last-known fix, then refine. + * + * `updatedAt` is when `coords` was actually read, so callers can tell a fresh fix + * from one that has been sitting there since the screen opened. */ -export function useLocation() { +export function useLocation({ intervalMs = 0, active = true }: UseLocationOptions = {}) { const [coords, setCoords] = useState(null); + const [updatedAt, setUpdatedAt] = useState(0); const [granted, setGranted] = useState(null); const [error, setError] = useState(null); + // Read inside refresh() without making it a dependency — refresh is the + // interval's callback, and a changing identity would restart the timer on + // every fix, so it would never actually reach the interval. + const haveFix = useRef(false); + const permitted = useRef(false); const refresh = useCallback(async () => { try { - const { status } = await Location.requestForegroundPermissionsAsync(); - const ok = status === 'granted'; - setGranted(ok); - if (!ok) { - setError('Location permission denied.'); - return null; + if (!permitted.current) { + const { status } = await Location.requestForegroundPermissionsAsync(); + const ok = status === 'granted'; + permitted.current = ok; + setGranted(ok); + if (!ok) { + setError('Location permission denied.'); + return null; + } } - const last = await Location.getLastKnownPositionAsync(); - if (last) { - const c = { latitude: last.coords.latitude, longitude: last.coords.longitude }; - setCoords(c); - void saveLastLocation(c); + // Only worth it before we have anything to show: on a later poll the + // last-known fix is usually older than the one we already hold, and + // publishing it would make the dot jump backwards. + if (!haveFix.current) { + const last = await Location.getLastKnownPositionAsync(); + if (last) { + const c = { latitude: last.coords.latitude, longitude: last.coords.longitude }; + haveFix.current = true; + setCoords(c); + setUpdatedAt(Date.now()); + void saveLastLocation(c); + } } const cur = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced, }); const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude }; + haveFix.current = true; setCoords(c); + setUpdatedAt(Date.now()); + setError(null); void saveLastLocation(c); return c; } catch (e: any) { @@ -56,8 +92,19 @@ export function useLocation() { }, []); useEffect(() => { + if (!active) return; + // Denied is denied — polling it every 30s just burns wake-ups to be told no. + if (granted === false) return; + // Re-activating (screen focused, app foregrounded) is exactly when the held + // fix is most likely to be stale, so read one straight away rather than + // waiting out a whole interval. void refresh(); - }, [refresh]); + if (!intervalMs) return; + const id = setInterval(() => { + void refresh(); + }, intervalMs); + return () => clearInterval(id); + }, [active, granted, intervalMs, refresh]); - return { coords, granted, error, refresh }; + return { coords, updatedAt, granted, error, refresh }; } diff --git a/app/src/screens/MapScreen.tsx b/app/src/screens/MapScreen.tsx index e1cce18..bc6acef 100644 --- a/app/src/screens/MapScreen.tsx +++ b/app/src/screens/MapScreen.tsx @@ -1,5 +1,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { + ActivityIndicator, + Alert, + AppState, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; import Constants from 'expo-constants'; import { MapView, @@ -45,6 +53,20 @@ const MAP_STYLE_DARK = const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 }; const DEFAULT_ZOOM = 14; +/** + * How often to re-read the GPS while the map is the screen you're looking at. + * A single fix at mount goes stale the moment you walk a block, which is the + * whole time you'd be looking at this screen. + */ +const GPS_REFRESH_MS = 30_000; + +/** + * How old a fix can be before "Park here" / "My location" stops trusting it and + * goes and asks again. A poll and a half, so a fix arriving on schedule is never + * treated as stale. + */ +const FIX_MAX_AGE_MS = 45_000; + type Nav = NativeStackNavigationProp; /** @@ -85,7 +107,19 @@ export function MapScreen() { const insets = useSafeAreaInsets(); const { mode } = useTheme(); const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT; - const { coords, refresh } = useLocation(); + // Poll the GPS while this screen is actually in front of someone. Focus alone + // isn't enough: a backgrounded app stays "focused" on its last tab, and Android + // won't give a foreground app's location out to one that isn't. + const [focused, setFocused] = useState(true); + const [foreground, setForeground] = useState(AppState.currentState === 'active'); + useEffect(() => { + const sub = AppState.addEventListener('change', (next) => setForeground(next === 'active')); + return () => sub.remove(); + }, []); + const { coords, updatedAt, refresh } = useLocation({ + intervalMs: GPS_REFRESH_MS, + active: focused && foreground, + }); const { isAnonymous } = useAuth(); const [zones, setZones] = useState([]); const [status, setStatus] = useState('Pan to an area and tap “Search this area”.'); @@ -113,7 +147,9 @@ export function MapScreen() { const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null); // The native UserLocation dot has its own GPS feed — capture it so "My // location" works even when expo-location can't get a fix (e.g. indoors). - const nativeFix = useRef(null); + // Stamped, because that feed goes quiet whenever the map isn't drawing and a + // silently stale fix is worse than no fix. + const nativeFix = useRef<(Coords & { at: number }) | null>(null); // Look up the LAST session's parking-lot coordinate (the meter's own location // from history — never the user's GPS). Used to open the map and by "Last lot". @@ -216,13 +252,32 @@ export function MapScreen() { }, []); // Re-read the pin on every focus: the session may have ended on another screen - // (or from the notification), which clears it. + // (or from the notification), which clears it. Focus also gates the GPS poll. useFocusEffect( useCallback(() => { void getParkedPin().then(setSpot); + setFocused(true); + return () => setFocused(false); }, []), ); + /** + * The freshest fix we can get, in the order it's cheapest to get it: the map's + * own dot, then the polled expo fix, then a forced read. Whichever we hold is + * only used if it's recent — the point of the poll is that "where am I" answers + * with where you are now, not where you were when the screen opened. + */ + const bestFix = useCallback(async (): Promise => { + const now = Date.now(); + const n = nativeFix.current; + if (n && now - n.at < FIX_MAX_AGE_MS) return { latitude: n.latitude, longitude: n.longitude }; + if (coords && now - updatedAt < FIX_MAX_AGE_MS) return coords; + const fresh = await refresh(); + if (fresh) return fresh; + // Nothing current and nothing new — a stale fix still beats no answer. + return n ? { latitude: n.latitude, longitude: n.longitude } : coords; + }, [coords, updatedAt, refresh]); + // Search whatever the map is currently centered on. This only ever sends the // map's center point — never the device GPS. (If you want to search your own // location, tap "My location" to center there first, then Search this area.) @@ -246,10 +301,9 @@ export function MapScreen() { await searchAt({ latitude: center[1], longitude: center[0] }, 'this area'); }; - // Recenter on the live GPS fix (if available) and search there. Prefer the - // native map fix (the blue dot), then expo-location, then a forced refresh. + // Recenter on the live GPS fix (if available) and search there. const goToMyLocation = async () => { - const c = nativeFix.current ?? coords ?? (await refresh()); + const c = await bestFix(); if (!c) { setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).'); return; @@ -341,7 +395,7 @@ export function MapScreen() { setStatus('Pin cancelled.'); return; } - const c = nativeFix.current ?? coords ?? (await refresh()); + const c = await bestFix(); if (!c) { setPinning(true); setStatus('No GPS fix — tap the map where you parked.'); @@ -458,6 +512,7 @@ export function MapScreen() { nativeFix.current = { latitude: loc.coords.latitude, longitude: loc.coords.longitude, + at: Date.now(), }; } }}