401 auto re-login, My Location via native GPS, edge-to-edge, brain-car icon

- Client: onUnauthorized hook; 401 clears the token and (in-app) bounces to sign-in
- Map: "My location" uses the native UserLocation fix + flyTo; safe-area insets on
  the floating status bar / controls
- Enable edgeToEdgeEnabled (Android 15 forces it) so headers/back-arrow sit correctly
- App icon: brain-car launcher + adaptive icon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 09:58:23 -07:00
parent b31cb78592
commit 65e9118806
9 changed files with 66 additions and 8 deletions

View file

@ -2,16 +2,19 @@ import Constants from 'expo-constants';
import * as Localization from 'expo-localization';
import { ParkSmarterClient, type EnvironmentName } from 'parksmarter-client';
import { secureTokenStore } from './secureTokenStore';
import { authBus } from '@/auth/authBus';
const env =
(Constants.expoConfig?.extra?.psEnvironment as EnvironmentName) ?? 'prodv2';
/**
* The single app-wide API client. React Native ships a global `fetch`, so no
* fetchImpl override is needed. Tokens persist in the OS keystore.
* fetchImpl override is needed. Tokens persist in the OS keystore. On a 401 the
* client clears the token and we bounce the user to sign-in via authBus.
*/
export const ps = new ParkSmarterClient({
environment: env,
tokens: secureTokenStore,
localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(),
onUnauthorized: () => authBus.onUnauthorized?.(),
});

View file

@ -6,6 +6,7 @@ import React, {
useState,
} from 'react';
import { ps } from '@/api/client';
import { authBus } from '@/auth/authBus';
import type { ApplicationValidityResponse } from 'parksmarter-client';
type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
@ -25,6 +26,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Any 401 from the API (expired/rotated token) bounces us back to sign-in.
useEffect(() => {
authBus.onUnauthorized = () => {
setError('Your session expired — please sign in again.');
setStatus('signedOut');
};
return () => {
authBus.onUnauthorized = undefined;
};
}, []);
// On launch: bootstrap (seeds SessionId + feature flags) and probe for an
// existing token by attempting an authenticated read.
useEffect(() => {

6
app/src/auth/authBus.ts Normal file
View file

@ -0,0 +1,6 @@
/**
* Tiny bridge so the API client (created at module load) can notify the React
* auth layer when a 401 happens, without a circular import. AuthProvider
* registers a handler; the client calls it via app/src/api/client.ts.
*/
export const authBus: { onUnauthorized?: () => void } = {};

View file

@ -9,6 +9,7 @@ import {
} from '@maplibre/maplibre-react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ps } from '@/api/client';
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
import type { RootStackParamList } from '@/navigation/RootNavigator';
@ -26,6 +27,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
export function MapScreen() {
const navigation = useNavigation<Nav>();
const insets = useSafeAreaInsets();
const { coords, refresh } = useLocation();
const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
@ -34,6 +36,9 @@ export function MapScreen() {
const mapRef = useRef<any>(null);
const cameraRef = useRef<any>(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);
// Seed the initial camera from a cached location so we don't strand at 0,0.
useEffect(() => {
@ -78,17 +83,18 @@ export function MapScreen() {
if (coords) await searchAt(coords, 'this area');
};
// Recenter on the live GPS fix (if available) and search there.
// 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.
const goToMyLocation = async () => {
const c = coords ?? (await refresh());
const c = nativeFix.current ?? coords ?? (await refresh());
if (!c) {
setStatus('No location available (GPS/location may be off).');
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [c.longitude, c.latitude],
zoomLevel: 15,
animationDuration: 500,
animationDuration: 700,
});
await searchAt(c, 'you');
};
@ -120,7 +126,18 @@ export function MapScreen() {
/>
) : null}
<UserLocation visible renderMode="native" />
<UserLocation
visible
renderMode="native"
onUpdate={(loc: any) => {
if (loc?.coords) {
nativeFix.current = {
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
};
}
}}
/>
{zones.map((z) => (
<MarkerView
@ -139,14 +156,14 @@ export function MapScreen() {
))}
</MapView>
<View style={styles.statusBar}>
<View style={[styles.statusBar, { top: insets.top + 12 }]}>
{loading ? <ActivityIndicator color="#fff" style={{ marginRight: 8 }} /> : null}
<Text style={styles.statusText} numberOfLines={1}>
{status}
</Text>
</View>
<View style={styles.controls}>
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
<Text style={styles.pillText}>Search this area</Text>
</TouchableOpacity>