diff --git a/README.md b/README.md index 008270b..444c391 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,19 @@ The **City map** layer on the Map tab is the City of Sandpoint's printed *Downto Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend: 2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all. -**None of it touches ParkSmarter.** The areas live in the local database (bundled with the -app, refreshed from the zone-labels server, cached on-device), the countdown is the phone's -own clock, and the notification is the same foreground service every other session uses. So -tracking your time on a city spot works with no account, no signal, no payment, and in -Anonymous Mode. Two ways to start: +**The free areas never touch ParkSmarter.** They live in the local database (bundled with +the app, refreshed from the zone-labels server, cached on-device), the countdown is the +phone's own clock, and the notification is the same foreground service every other session +uses. So tracking your time on a free city spot works with no account, no signal, no +payment, and in Anonymous Mode. + +The **green city lots are the exception** — they're the map's only paid category, and paying +for them means ParkSmarter. They're hidden entirely when you're not signed in, since parking +you can't actually buy is worse than no parking at all. (Standing in one and tapping "Park +here" says so rather than reporting nothing nearby.) A single lot can be flipped back via +the server's `requiresAccount` field if it turns out to take payment another way. + +Two ways to start: - **Park here** — pins your car from GPS and works out which area you're in. No GPS fix (garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin @@ -73,6 +81,11 @@ offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing no second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it edits the local timer and says so. +The **Sessions** tab shows and manages these under *Tracking on this phone* — add an hour, +end it, and see recent ones — with no account and no network, because that is the only +place they exist. ParkSmarter's own sessions are layered on top when you're signed in, and +failing to reach them (offline, or signed out) never hides the local half. + The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m (see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF). Because a few metres is the difference between two sides of a street, **Account → Align city diff --git a/app/app.json b/app/app.json index ffcaf4d..361913f 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.4", "orientation": "portrait", "userInterfaceStyle": "automatic", "newArchEnabled": true, "icon": "./assets/icon.png", "android": { "package": "top.mowden.bigbrainparking", - "versionCode": 21, + "versionCode": 25, "edgeToEdgeEnabled": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/app/src/api/parkingAreas.ts b/app/src/api/parkingAreas.ts index 0200636..6aad019 100644 --- a/app/src/api/parkingAreas.ts +++ b/app/src/api/parkingAreas.ts @@ -40,6 +40,11 @@ export interface ParkingArea { color: string; shape: 'line' | 'polygon'; geometry: AreaGeometry; + /** + * Overrides the by-category default in [areaRequiresAccount]. Only set this to + * correct a specific lot — e.g. one that turns out to be kiosk- or permit-only. + */ + requiresAccount?: boolean; } export interface AreaData { @@ -76,6 +81,19 @@ export function areaIsFree(kind: AreaKind): boolean { return kind !== 'green_lot'; } +/** + * Whether you need a ParkSmarter account to park here. + * + * The city lots are the map's only paid category ("City lots — Paid hourly or + * permit"); paying for them means ParkSmarter, so they're no use to someone + * browsing without an account. Everything else is free with a posted time limit + * and needs nothing. A single lot can override this if it turns out to take + * payment some other way. + */ +export function areaRequiresAccount(area: ParkingArea): boolean { + return area.requiresAccount ?? area.kind === 'green_lot'; +} + /** * Durations offered when starting tracking, the posted limit first. * 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/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/features/session/activeParking.ts b/app/src/features/session/activeParking.ts index 456a040..373b351 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'; @@ -19,6 +20,7 @@ import { } from '@/features/notifications/reminderPrefs'; import { logLine } from '@/features/diagnostics/fileLogger'; import type { ParkingArea } from '@/api/parkingAreas'; +import { recordLocalSession } from './localHistory'; import { clearActiveParking, getActiveParking, @@ -254,6 +256,10 @@ export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise * keeps running at the meter whether or not the app is showing it. */ export async function endActiveParking(): Promise { + // Write it to history before dropping it. A local session has no server copy, so + // if it isn't recorded here it is simply gone. + const current = await getActiveParking(); + if (current) await recordLocalSession(current); await clearActiveParking(); await clearNotification(); await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {}); @@ -326,8 +332,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/features/session/localHistory.ts b/app/src/features/session/localHistory.ts new file mode 100644 index 0000000..0ce68cc --- /dev/null +++ b/app/src/features/session/localHistory.ts @@ -0,0 +1,81 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { AreaKind } from '@/api/parkingAreas'; +import type { ActiveParking, ParkedSpot, ParkingKind } from './activeParkingStore'; + +/** + * History for the sessions ParkSmarter never sees. + * + * A city-map timer or a free check-in exists only on this phone, so if it isn't + * recorded here it vanishes the moment it ends — there is no server to ask. Paid + * ParkSmarter sessions are deliberately excluded: those already come back from the + * account, and storing them too would show every one of them twice. + */ + +const KEY = 'ps_local_session_history'; +/** Enough to cover months of parking without letting the record grow forever. */ +const MAX = 50; + +export interface LocalSessionRecord { + /** Start time doubles as the id — there is only ever one session at a time. */ + id: string; + kind: ParkingKind; + zoneName: string; + areaId?: string; + areaKind?: AreaKind; + color?: string; + legend?: string; + startMs: number; + /** When it was due to end. */ + plannedEndMs: number; + /** When it actually ended. */ + endedAtMs: number; + /** True when the user ended it before the clock ran out. */ + endedEarly: boolean; + spot?: ParkedSpot; +} + +/** True when ParkSmarter has no record of this session, so we must keep our own. */ +export function isLocalOnly(p: ActiveParking): boolean { + return !p.transactionId; +} + +export async function getLocalHistory(): Promise { + const raw = await AsyncStorage.getItem(KEY); + if (!raw) return []; + try { + const list = JSON.parse(raw) as LocalSessionRecord[]; + return Array.isArray(list) ? list : []; + } catch { + return []; + } +} + +/** Record a finished local session. No-op for anything ParkSmarter already has. */ +export async function recordLocalSession(p: ActiveParking): Promise { + if (!isLocalOnly(p)) return; + const endedAtMs = Date.now(); + const record: LocalSessionRecord = { + id: String(p.startMs), + kind: p.kind, + zoneName: p.zoneName, + areaId: p.area?.id, + areaKind: p.area?.kind, + color: p.area?.color, + legend: p.area?.legend, + startMs: p.startMs, + plannedEndMs: p.endMs, + endedAtMs, + endedEarly: endedAtMs < p.endMs - 60_000, // a minute's slack for timer wake-up + spot: p.spot, + }; + const list = await getLocalHistory(); + // Guard against double-recording: ending can be driven from the notification and + // the screen at nearly the same moment. + const deduped = list.filter((r) => r.id !== record.id); + deduped.unshift(record); + await AsyncStorage.setItem(KEY, JSON.stringify(deduped.slice(0, MAX))); +} + +export async function clearLocalHistory(): Promise { + await AsyncStorage.removeItem(KEY); +} diff --git a/app/src/screens/CityAreaScreen.tsx b/app/src/screens/CityAreaScreen.tsx index 713c05e..007eb97 100644 --- a/app/src/screens/CityAreaScreen.tsx +++ b/app/src/screens/CityAreaScreen.tsx @@ -5,7 +5,8 @@ import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/nativ import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '@/navigation/RootNavigator'; import { useTheme } from '@/theme/ThemeContext'; -import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas'; +import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas'; +import { useAuth } from '@/auth/AuthContext'; import { endActiveParking, extendAreaParking, @@ -43,6 +44,7 @@ export function CityAreaScreen() { const { area, spot } = useRoute().params; const navigation = useNavigation