diff --git a/app/app.json b/app/app.json index ffcaf4d..0f67bfb 100644 --- a/app/app.json +++ b/app/app.json @@ -3,14 +3,14 @@ "name": "BigBrainParking", "slug": "bigbrainparking", "scheme": "bigbrainparking", - "version": "0.6.0", + "version": "0.6.1", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 21, + "versionCode": 22, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/auth/AuthContext.tsx b/app/src/auth/AuthContext.tsx index 1d94e82..4f894a4 100644 --- a/app/src/auth/AuthContext.tsx +++ b/app/src/auth/AuthContext.tsx @@ -31,9 +31,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [validity, setValidity] = useState(null); const [error, setError] = useState(null); + // Let the non-React modules see the mode. Kept in sync here rather than read + // from storage: anonymous mode is deliberately not persisted across restarts. + useEffect(() => { + authBus.isAnonymous = status === 'anonymous'; + }, [status]); + // Any 401 from the API (expired/rotated token) bounces us back to sign-in. useEffect(() => { authBus.onUnauthorized = () => { + // ...except in Anonymous Mode, where there is no session to expire. A 401 + // there just means something asked ParkSmarter a question it had no + // business asking, and bouncing to sign-in would make the app unusable + // without an account — which is the whole point of the mode. Read at call + // time, so this stays correct as the status changes. + if (authBus.isAnonymous) return; setError('Your session expired — please sign in again.'); setStatus('signedOut'); }; diff --git a/app/src/auth/authBus.ts b/app/src/auth/authBus.ts index 075d9fd..02c1a3b 100644 --- a/app/src/auth/authBus.ts +++ b/app/src/auth/authBus.ts @@ -2,5 +2,13 @@ * 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. + * + * `isAnonymous` mirrors the auth status for the non-React modules that need it. + * It matters because the 401 hook is global: it fires on every unauthorized + * response whether or not the caller caught the error, so without this a single + * stray ParkSmarter call in Anonymous Mode throws the user to the sign-in screen. */ -export const authBus: { onUnauthorized?: () => void } = {}; +export const authBus: { + onUnauthorized?: () => void; + isAnonymous: boolean; +} = { isAnonymous: false }; diff --git a/app/src/features/session/activeParking.ts b/app/src/features/session/activeParking.ts index 456a040..61a2e4d 100644 --- a/app/src/features/session/activeParking.ts +++ b/app/src/features/session/activeParking.ts @@ -5,6 +5,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import * as Notifications from 'expo-notifications'; import type { Zone } from 'parksmarter-client'; import { ps } from '@/api/client'; +import { authBus } from '@/auth/authBus'; import { parseApiTime } from '@/api/parseTime'; import type { LabelKind } from '@/api/zoneLabels'; import type { RootStackParamList } from '@/navigation/RootNavigator'; @@ -326,8 +327,9 @@ export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promis } // A city-map session is never on the ParkSmarter server, so don't let a stale - // server session overwrite it. - if (!current) current = await discoverPaidSession(); + // server session overwrite it. In Anonymous Mode there is no account to ask at + // all — asking anyway would 401 on every single foreground. + if (!current && !authBus.isAnonymous) current = await discoverPaidSession(); if (current) { await postNotification(current); diff --git a/app/src/screens/MapScreen.tsx b/app/src/screens/MapScreen.tsx index 36da5d1..eaf81b8 100644 --- a/app/src/screens/MapScreen.tsx +++ b/app/src/screens/MapScreen.tsx @@ -113,6 +113,9 @@ export function MapScreen() { // 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". const lastSessionLot = useCallback(async (): Promise => { + // No account, no session history — and asking anyway 401s, which the global + // handler would turn into a bogus "session expired" bounce. + if (isAnonymous) return null; try { const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 }); const s = past.Session?.[0] as Record | undefined; @@ -125,7 +128,7 @@ export function MapScreen() { /* ignore */ } return null; - }, []); + }, [isAnonymous]); // Open on your last parking lot — NOT your GPS. Your location is only ever sent // to the API when you explicitly tap "My location", so we never auto-center on it. @@ -257,6 +260,10 @@ export function MapScreen() { // Center on the LAST SESSION's parking lot (the meter's own coordinate from // history — never your GPS) and search around it with a ~couple-mile view. const searchLastSessionLot = async () => { + if (isAnonymous) { + setStatus('Sign in to use your last parking lot — it comes from your account history.'); + return; + } setLoading(true); setStatus('Finding your last parking lot…'); const lot = await lastSessionLot();