Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 718c72dde1 |
3 changed files with 127 additions and 25 deletions
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.6.3",
|
"version": "0.6.4",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"android": {
|
"android": {
|
||||||
"package": "top.mowden.bigbrainparking",
|
"package": "top.mowden.bigbrainparking",
|
||||||
"versionCode": 24,
|
"versionCode": 25,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
|
@ -7,6 +7,20 @@ export interface Coords {
|
||||||
longitude: number;
|
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';
|
const LAST_LOC_KEY = 'ps_last_location';
|
||||||
|
|
||||||
/** Persist the most recent fix so the "near my last location" button works cold. */
|
/** 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
|
* Foreground location. On GrapheneOS this uses the OS location provider directly
|
||||||
* (no Google Play Services). We prefer a fast last-known fix, then refine.
|
* (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 [coords, setCoords] = useState<Coords | null>(null);
|
||||||
|
const [updatedAt, setUpdatedAt] = useState(0);
|
||||||
const [granted, setGranted] = useState<boolean | null>(null);
|
const [granted, setGranted] = useState<boolean | null>(null);
|
||||||
const [error, setError] = useState<string | 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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!permitted.current) {
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
const ok = status === 'granted';
|
const ok = status === 'granted';
|
||||||
|
permitted.current = ok;
|
||||||
setGranted(ok);
|
setGranted(ok);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
setError('Location permission denied.');
|
setError('Location permission denied.');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 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();
|
const last = await Location.getLastKnownPositionAsync();
|
||||||
if (last) {
|
if (last) {
|
||||||
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
|
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
|
||||||
|
haveFix.current = true;
|
||||||
setCoords(c);
|
setCoords(c);
|
||||||
|
setUpdatedAt(Date.now());
|
||||||
void saveLastLocation(c);
|
void saveLastLocation(c);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const cur = await Location.getCurrentPositionAsync({
|
const cur = await Location.getCurrentPositionAsync({
|
||||||
accuracy: Location.Accuracy.Balanced,
|
accuracy: Location.Accuracy.Balanced,
|
||||||
});
|
});
|
||||||
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
|
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
|
||||||
|
haveFix.current = true;
|
||||||
setCoords(c);
|
setCoords(c);
|
||||||
|
setUpdatedAt(Date.now());
|
||||||
|
setError(null);
|
||||||
void saveLastLocation(c);
|
void saveLastLocation(c);
|
||||||
return c;
|
return c;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -56,8 +92,19 @@ export function useLocation() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 Constants from 'expo-constants';
|
||||||
import {
|
import {
|
||||||
MapView,
|
MapView,
|
||||||
|
|
@ -45,6 +53,20 @@ const MAP_STYLE_DARK =
|
||||||
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
||||||
const DEFAULT_ZOOM = 14;
|
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>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -85,7 +107,19 @@ export function MapScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { mode } = useTheme();
|
const { mode } = useTheme();
|
||||||
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
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 { isAnonymous } = useAuth();
|
||||||
const [zones, setZones] = useState<Zone[]>([]);
|
const [zones, setZones] = useState<Zone[]>([]);
|
||||||
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
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);
|
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
||||||
// The native UserLocation dot has its own GPS feed — capture it so "My
|
// 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).
|
// 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
|
// 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".
|
// 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
|
// 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(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
void getParkedPin().then(setSpot);
|
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
|
// 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
|
// 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.)
|
// 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');
|
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Recenter on the live GPS fix (if available) and search there. Prefer the
|
// Recenter on the live GPS fix (if available) and search there.
|
||||||
// native map fix (the blue dot), then expo-location, then a forced refresh.
|
|
||||||
const goToMyLocation = async () => {
|
const goToMyLocation = async () => {
|
||||||
const c = nativeFix.current ?? coords ?? (await refresh());
|
const c = await bestFix();
|
||||||
if (!c) {
|
if (!c) {
|
||||||
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
|
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
|
||||||
return;
|
return;
|
||||||
|
|
@ -341,7 +395,7 @@ export function MapScreen() {
|
||||||
setStatus('Pin cancelled.');
|
setStatus('Pin cancelled.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const c = nativeFix.current ?? coords ?? (await refresh());
|
const c = await bestFix();
|
||||||
if (!c) {
|
if (!c) {
|
||||||
setPinning(true);
|
setPinning(true);
|
||||||
setStatus('No GPS fix — tap the map where you parked.');
|
setStatus('No GPS fix — tap the map where you parked.');
|
||||||
|
|
@ -458,6 +512,7 @@ export function MapScreen() {
|
||||||
nativeFix.current = {
|
nativeFix.current = {
|
||||||
latitude: loc.coords.latitude,
|
latitude: loc.coords.latitude,
|
||||||
longitude: loc.coords.longitude,
|
longitude: loc.coords.longitude,
|
||||||
|
at: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue