v0.6.4: refresh the map's GPS every 30 s while it's on screen
All checks were successful
build-apk / build (push) Successful in 18m52s

A single fix at mount went stale the moment you walked a block, which is
most of the time you'd be looking at the map.

useLocation takes { intervalMs, active }: it polls only while active,
reads immediately on becoming active (re-opening the tab is when the
held fix is most stale), asks for permission once rather than every
poll, and stops polling on a hard denial. The last-known-position
shortcut now runs only before there is anything to show — on a later
poll it is usually older than what we hold, so it would make the dot
jump backwards. It also returns updatedAt.

MapScreen gates the poll on screen focus AND app foreground; focus
alone isn't enough, since a backgrounded app stays "focused" on its
last tab and Android won't hand location to one that isn't in front.

"Park here" / "My location" took nativeFix.current ?? coords, so the
MapLibre dot's cached fix won unconditionally and never expired — a
fresher polled fix could never get past it. Both now use bestFix(),
which takes whichever source is under 45 s old (a poll and a half, so
an on-schedule fix never reads as stale), forces a read if neither is,
and falls back to a stale fix rather than nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DE83epHK8WbkppNszG3A2
This commit is contained in:
Erik 2026-09-01 22:11:20 +00:00
parent 4217e88338
commit 718c72dde1
3 changed files with 127 additions and 25 deletions

View file

@ -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",

View file

@ -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<Coords | null> {
/**
* 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<Coords | null>(null);
const [updatedAt, setUpdatedAt] = useState(0);
const [granted, setGranted] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(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 };
}

View file

@ -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<RootStackParamList>;
/**
@ -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<Zone[]>([]);
const [status, setStatus] = useState<string>('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<Coords | null>(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<Coords | null> => {
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(),
};
}
}}