BigBrainParking/app/src/screens/MapScreen.tsx
Erik 4217e88338
All checks were successful
build-apk / build (push) Successful in 9m54s
v0.6.3: hide the paid city lots from users without a ParkSmarter account
The green lots are the map's only paid category ("City lots - Paid hourly or
permit"); paying for them goes through ParkSmarter, so they are no use to
someone browsing without an account. Every other category is free street
parking with a posted time limit and needs nothing.

Gated by category rather than by an id list. Two lots were named (off Oak St
and off N 3rd Ave) and then the beach ones, which together is every green lot
on the map — and an id list would silently break the next time the map is
regenerated from a new PDF, since ids are positional.

- Paid lots are filtered out of the overlay when signed out, so they are
  neither drawn nor tappable.
- "Park here" still detects them, so standing in one explains that it needs an
  account instead of reporting no parking nearby.
- The area screen guards too, in case one is reached with a stale nav param.
- Server carries an optional per-area requiresAccount override for a lot that
  turns out to take payment another way. Null means "use the category
  default", so an unset value can't be confused with an explicit false.

The new column needs a real migration: CREATE TABLE IF NOT EXISTS does not add
a column to a table that already exists, so an already-deployed server would
have kept the old schema. Covered by a test that builds the pre-migration
table and then opens it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 06:02:23 +00:00

650 lines
23 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Constants from 'expo-constants';
import {
MapView,
Camera,
ShapeSource,
CircleLayer,
FillLayer,
LineLayer,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import { ps } from '@/api/client';
import { useLocation, type Coords } from '@/features/location/useLocation';
import { useAuth } from '@/auth/AuthContext';
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
import {
areaRequiresAccount,
getAdjustedAreas,
refreshAreas,
type ParkingArea,
} from '@/api/parkingAreas';
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
import {
getParkedPin,
setParkedPin,
type ParkedSpot,
} from '@/features/session/activeParkingStore';
import { pinParkedSpot } from '@/features/session/activeParking';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { Zone } from 'parksmarter-client';
const MAP_STYLE_LIGHT =
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
'https://tiles.openfreemap.org/styles/liberty';
const MAP_STYLE_DARK =
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// Default view when we have no last-session lot: all of downtown Sandpoint, ID.
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
const DEFAULT_ZOOM = 14;
type Nav = NativeStackNavigationProp<RootStackParamList>;
/**
* How far from a city-map area a parked pin can be and still be taken as "that's
* where I am". A block is ~170 m, a street ~12 m wide; 40 m picks the right side
* of the right street without silently matching a spot two blocks away.
*/
const AREA_SNAP_METERS = 40;
/** Nearest city-map area to a point, or null if nothing is close enough. */
function areaAt(point: LonLat, areas: ParkingArea[]): ParkingArea | null {
let best: ParkingArea | null = null;
let bestDist = AREA_SNAP_METERS;
for (const a of areas) {
const d = distanceToGeometry(point, a.geometry);
if (d < bestDist) {
best = a;
bestDist = d;
}
}
return best;
}
/** Coerce the server's zone color (hex string, color name, or numeric) into a usable color. */
function normalizeColor(v: unknown): string | null {
if (typeof v === 'number') return '#' + (v & 0xffffff).toString(16).padStart(6, '0');
if (typeof v === 'string') {
const s = v.trim();
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return s;
if (/^\d+$/.test(s)) return '#' + (Number(s) & 0xffffff).toString(16).padStart(6, '0');
if (/^[a-z]+$/i.test(s)) return s; // named color
}
return null;
}
export function MapScreen() {
const navigation = useNavigation<Nav>();
const insets = useSafeAreaInsets();
const { mode } = useTheme();
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
const { coords, refresh } = useLocation();
const { isAnonymous } = useAuth();
const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
const [loading, setLoading] = useState(false);
const [initialCenter, setInitialCenter] = useState<Coords | null>(null);
// The city's printed parking map, georeferenced. Local geography — loading and
// tapping these never touches the ParkSmarter API.
const [areas, setAreas] = useState<ParkingArea[]>([]);
const [showAreas, setShowAreas] = useState(true);
// Set while waiting for the user to tap where they parked (the GPS-less path).
const [pinning, setPinning] = useState(false);
const [spot, setSpot] = useState<ParkedSpot | null>(null);
const mapRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const [mapReady, setMapReady] = useState(false);
// We position the camera exactly once, then never auto-recenter — otherwise
// re-renders (from searching) would snap the map back and fight the user's pan.
const positioned = useRef(false);
// The live viewport, tracked from region changes. MapLibre's native camera
// re-applies its last setCamera() stop on any re-render, so before a search we
// overwrite that stop with the current view (zero-duration) — otherwise the
// results re-render snaps the map back to the previous programmatic target.
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);
// 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<Coords | null> => {
// 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<string, any> | undefined;
const lat = Number(s?.Lat);
const lng = Number(s?.Long);
if (s && Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
return { latitude: lat, longitude: lng };
}
} catch {
/* 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.
useEffect(() => {
(async () => {
const lot = await lastSessionLot();
setInitialCenter(lot ?? DEFAULT_CENTER);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// One-time initial positioning, once both the map and a target are ready.
useEffect(() => {
if (mapReady && initialCenter && !positioned.current) {
positioned.current = true;
cameraRef.current?.setCamera?.({
centerCoordinate: [initialCenter.longitude, initialCenter.latitude],
zoomLevel: initialCenter === DEFAULT_CENTER ? DEFAULT_ZOOM : 14,
animationDuration: 0,
});
}
}, [mapReady, initialCenter]);
const searchAt = useCallback(
async (c: Coords, label: string) => {
setLoading(true);
setStatus(`Searching ${label}`);
try {
if (isAnonymous) {
// No ParkSmarter login — read areas from our mirror (all of them; the
// covered area is small). The device GPS is never sent.
const all = await getMirrorZones();
const found = all.filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} areas` : 'No areas mirrored yet — sign in to load them.');
return;
}
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
setZones(found);
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
void syncZones(found); // admin-only; no-ops otherwise
} catch (e: any) {
setZones([]);
setStatus(
e?.status === 401
? 'Session expired — sign in again.'
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
);
} finally {
setLoading(false);
}
},
[isAnonymous],
);
// Anonymous: load the mirrored areas once on open (no ParkSmarter needed).
useEffect(() => {
if (isAnonymous) void searchAt(DEFAULT_CENTER, 'Sandpoint');
}, [isAnonymous, searchAt]);
// City parking map: draw the cached/bundled copy immediately so the overlay is
// there offline, then quietly refresh from the server behind it.
useEffect(() => {
let alive = true;
(async () => {
const local = await getAdjustedAreas();
if (alive) setAreas(local.areas);
try {
await refreshAreas();
const fresh = await getAdjustedAreas();
if (alive) setAreas(fresh.areas);
} catch {
/* offline or unseeded — the local copy is already drawn */
}
})();
return () => {
alive = false;
};
}, []);
// Re-read the pin on every focus: the session may have ended on another screen
// (or from the notification), which clears it.
useFocusEffect(
useCallback(() => {
void getParkedPin().then(setSpot);
}, []),
);
// 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.)
const searchThisArea = async () => {
let center: [number, number] | null = null;
try {
const c = await mapRef.current?.getCenter?.(); // [lng, lat]
if (Array.isArray(c) && c.length === 2) center = [c[0], c[1]];
} catch {
/* fall through */
}
// Fallback is the tracked viewport center — deliberately NOT the GPS fix.
if (!center && viewRef.current) center = viewRef.current.center;
if (!center) {
setStatus('Move the map, then tap “Search this area”.');
return;
}
// Commit the current view as the camera's stop so the results re-render
// doesn't revert to the last programmatic (e.g. "My location") target.
cameraRef.current?.setCamera?.({ centerCoordinate: center, animationDuration: 0 });
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.
const goToMyLocation = async () => {
const c = nativeFix.current ?? coords ?? (await refresh());
if (!c) {
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [c.longitude, c.latitude],
zoomLevel: 15,
animationDuration: 700,
});
await searchAt(c, 'you');
};
// 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();
if (!lot) {
setStatus('No past parking session with a location yet.');
setLoading(false);
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [lot.longitude, lot.latitude],
zoomLevel: 12.5, // ~a couple miles across
animationDuration: 600,
});
await searchAt(lot, 'your last lot');
};
/* -------------------------------------------------- parking-map interaction */
/**
* The paid city lots are ParkSmarter-only, so they're hidden from someone
* browsing without an account — showing parking you can't actually buy is worse
* than not showing it.
*/
const hidden = useCallback(
(a: ParkingArea) => isAnonymous && areaRequiresAccount(a),
[isAnonymous],
);
/** What actually gets drawn and tapped. */
const visibleAreas = useMemo(() => areas.filter((a) => !hidden(a)), [areas, hidden]);
/** Open an area, carrying the pin along if we have one. */
const openArea = useCallback(
(area: ParkingArea, at?: ParkedSpot) => {
navigation.navigate('CityArea', { area, spot: at });
},
[navigation],
);
/** Drop the pin at `c`, work out which area that is, and open it. */
const pinAt = useCallback(
(c: Coords, manual: boolean) => {
const at: ParkedSpot = { latitude: c.latitude, longitude: c.longitude, manual };
setSpot(at);
// Persist immediately — the pin is worth keeping even if you never start a
// timer, and even if you back out of the screen we're about to open.
void pinParkedSpot(at);
// Detect against every area, including the ones hidden from this user, so
// standing in a paid lot gets an explanation rather than "nothing found".
const found = areaAt([c.longitude, c.latitude], areas);
if (found && hidden(found)) {
setStatus(`Pinned. ${found.name} is a paid city lot — sign in to park there.`);
} else if (found) {
setStatus(`Parked at ${found.name}`);
openArea(found, at);
} else {
// Pin still stands — you parked somewhere, it's just not on the city map.
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
}
},
[areas, hidden, openArea],
);
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
// garage, indoors, GPS off) fall back to letting the user tap the spot — the
// pin is the point, so it must not depend on the GPS working.
const parkHere = async () => {
if (pinning) {
setPinning(false);
setStatus('Pin cancelled.');
return;
}
const c = nativeFix.current ?? coords ?? (await refresh());
if (!c) {
setPinning(true);
setStatus('No GPS fix — tap the map where you parked.');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [c.longitude, c.latitude],
zoomLevel: 17,
animationDuration: 500,
});
pinAt(c, false);
};
/** A tap on the map: places the manual pin, but only while we asked for one. */
const onMapPress = (e: any) => {
if (!pinning) return;
const c = e?.geometry?.coordinates;
if (!Array.isArray(c) || c.length !== 2) return;
setPinning(false);
pinAt({ latitude: c[1], longitude: c[0] }, true);
};
/** A tap on your own pin: the only way to take it down without ending a session. */
const onSpotPress = () => {
if (!spot) return;
Alert.alert('Your car', 'Remove the parked pin?', [
{ text: 'Keep', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => {
setSpot(null);
void setParkedPin(null);
setStatus('Pin removed.');
},
},
]);
};
/** A tap on a coloured segment: the other start flow, no pin involved. */
const onAreaPress = (e: any) => {
const id = e?.features?.[0]?.properties?.id;
const found = visibleAreas.find((a) => a.id === id);
if (found) openArea(found, spot ?? undefined);
};
const areaFeatures = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: visibleAreas.map((a) => ({
type: 'Feature' as const,
id: a.id,
geometry: a.geometry,
properties: { id: a.id, color: a.color, kind: a.kind },
})),
}),
[visibleAreas],
);
const spotFeature = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: spot
? [
{
type: 'Feature' as const,
id: 'parked',
geometry: { type: 'Point' as const, coordinates: [spot.longitude, spot.latitude] },
properties: {},
},
]
: [],
}),
[spot],
);
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
// than React MarkerViews, which floated and thrashed the camera.
const meterFeatures = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: zones.map((z, i) => ({
type: 'Feature' as const,
id: String(z.ZoneId ?? z.ScannerCode ?? i),
geometry: {
type: 'Point' as const,
coordinates: [z.Long as number, z.Lat as number],
},
properties: { index: i, color: normalizeColor(z.BackgroundColor) ?? '#1e6f5c' },
})),
}),
[zones],
);
const onPinPress = (e: any) => {
const idx = e?.features?.[0]?.properties?.index;
if (typeof idx === 'number' && zones[idx]) {
navigation.navigate('MeterDetail', { zone: zones[idx] });
}
};
// MapLibre's Camera re-applies its last imperative setCamera() stop whenever it
// re-renders — so a search re-render (setZones) would snap the map back to the
// last programmatic target. Memoize these elements so search re-renders never
// touch them; only the marker ShapeSource below updates.
const cameraEl = useMemo(() => <Camera ref={cameraRef} />, []);
const userLocationEl = useMemo(
() => (
<UserLocation
visible
renderMode="normal"
onUpdate={(loc: any) => {
if (loc?.coords) {
nativeFix.current = {
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
};
}
}}
/>
),
[],
);
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={styles.map}
mapStyle={mapStyle}
rotateEnabled={false}
onDidFinishLoadingMap={() => setMapReady(true)}
onPress={onMapPress}
onRegionDidChange={(f: any) => {
const c = f?.geometry?.coordinates;
const z = f?.properties?.zoomLevel;
if (Array.isArray(c) && c.length === 2) {
viewRef.current = { center: [c[0], c[1]], zoom: typeof z === 'number' ? z : 14 };
}
}}
>
{cameraEl}
{userLocationEl}
{/* The city parking map, under the meter pins so pins stay tappable. */}
{showAreas ? (
<ShapeSource id="city-areas" shape={areaFeatures} onPress={onAreaPress}>
<FillLayer
id="city-area-fills"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
/>
<LineLayer
id="city-area-outlines"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ lineColor: ['get', 'color'], lineWidth: 1.5, lineOpacity: 0.9 }}
/>
{/* Street segments, scaled with zoom so they read as painted kerb. */}
<LineLayer
id="city-area-lines"
filter={['==', ['geometry-type'], 'LineString']}
style={{
lineColor: ['get', 'color'],
lineOpacity: 0.95,
lineCap: 'round',
lineWidth: ['interpolate', ['linear'], ['zoom'], 12, 2, 15, 5, 18, 11],
}}
/>
{/* A fat, near-invisible line purely to make thin segments tappable —
a 5 px kerb stripe is far too small a target for a fingertip. */}
<LineLayer
id="city-area-touch"
filter={['==', ['geometry-type'], 'LineString']}
style={{ lineColor: '#000000', lineOpacity: 0.01, lineWidth: 24 }}
/>
</ShapeSource>
) : null}
{/* Where the car is. Drawn above everything — it's the thing you came back for. */}
<ShapeSource id="parked-spot" shape={spotFeature} onPress={onSpotPress}>
<CircleLayer
id="parked-halo"
style={{ circleColor: '#1e6f5c', circleOpacity: 0.25, circleRadius: 18 }}
/>
<CircleLayer
id="parked-dot"
style={{
circleColor: '#1e6f5c',
circleStrokeColor: '#ffffff',
circleStrokeWidth: 3,
circleRadius: 8,
}}
/>
</ShapeSource>
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
<CircleLayer
id="meter-circles"
style={{
circleColor: ['get', 'color'],
circleStrokeColor: '#ffffff',
circleStrokeWidth: 2,
circlePitchAlignment: 'map',
circleRadius: [
'interpolate',
['linear'],
['zoom'],
10,
5,
16,
9,
],
}}
/>
</ShapeSource>
</MapView>
<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, { bottom: insets.bottom + 24 }]}>
<View style={styles.row}>
<TouchableOpacity
style={pinning ? styles.pillActive : styles.pillPrimary}
onPress={parkHere}
>
<Text style={styles.pillText}>{pinning ? 'Tap the map…' : 'Park here'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={showAreas ? styles.pillOn : styles.pill}
onPress={() => setShowAreas((v) => !v)}
>
<Text style={styles.pillText}>City map</Text>
</TouchableOpacity>
</View>
<View style={styles.row}>
<TouchableOpacity style={styles.pill} onPress={searchThisArea}>
<Text style={styles.pillText}>Search this area</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
<Text style={styles.pillText}>My location</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={searchLastSessionLot}>
<Text style={styles.pillText}>Last lot</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
statusBar: {
position: 'absolute',
top: 12,
left: 12,
right: 12,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.65)',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 12,
},
statusText: { color: '#fff', fontSize: 13, flexShrink: 1 },
controls: {
position: 'absolute',
bottom: 24,
alignSelf: 'center',
alignItems: 'center',
gap: 8,
},
row: { flexDirection: 'row', gap: 8 },
pill: {
backgroundColor: '#444',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
},
pillPrimary: {
backgroundColor: '#1e6f5c',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
/** Waiting for the user to tap where they parked. */
pillActive: {
backgroundColor: '#c07a12',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
pillOn: {
backgroundColor: '#2f6f60',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
},
pillText: { color: '#fff', fontWeight: '600' },
});