BigBrainParking/app/src/screens/MapScreen.tsx
Hank b0c343ab20 Map: pin markers -> parking flow; stop native user-location camera tracking
- Markers are now compact colored pins (no long label bubbles); tapping one opens
  the meter/parking flow for that zone
- UserLocation renderMode "native" -> "normal": the native location component was
  tracking the camera back to the user on re-render / marker tap. Normal mode draws
  the dot in the JS layer with no camera coupling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:38:39 -07:00

266 lines
9.3 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import {
MapView,
Camera,
MarkerView,
UserLocation,
} 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 { useTheme } from '@/theme/ThemeContext';
import { ps } from '@/api/client';
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
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';
// Fallback view when we have no GPS and no cached location (continental US).
const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 };
const DEFAULT_ZOOM = 4;
type Nav = NativeStackNavigationProp<RootStackParamList>;
/** 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 [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);
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 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 target from a cached location so we don't strand at 0,0.
useEffect(() => {
(async () => {
const last = await getLastKnownSavedLocation();
setInitialCenter(last ?? coords ?? 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 {
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}`);
} catch (e: any) {
setZones([]);
setStatus(
e?.status === 401
? 'Session expired — sign in again.'
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
);
} finally {
setLoading(false);
}
}, []);
// Search whatever the map is currently centered on (works with no GPS).
const searchThisArea = async () => {
try {
const center = await mapRef.current?.getCenter?.(); // [lng, lat]
if (center && center.length === 2) {
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
return;
}
} catch {
/* fall through */
}
if (coords) await searchAt(coords, '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');
};
const searchLastKnown = async () => {
const last = (await getLastKnownSavedLocation()) ?? coords;
if (!last) {
setStatus('No saved location yet — enable location once to cache it.');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [last.longitude, last.latitude],
zoomLevel: 15,
animationDuration: 500,
});
await searchAt(last, 'last location');
};
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={styles.map}
mapStyle={mapStyle}
rotateEnabled={false}
onDidFinishLoadingMap={() => setMapReady(true)}
>
{/* Uncontrolled camera; we position it imperatively (once on load, then
only on explicit user actions) so re-renders never move the map. */}
<Camera ref={cameraRef} />
{/* renderMode="normal" draws the dot in the JS layer and does NOT engage
the native location component, which was tracking the camera back to
the user on re-render. */}
<UserLocation
visible
renderMode="normal"
onUpdate={(loc: any) => {
if (loc?.coords) {
nativeFix.current = {
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
};
}
}}
/>
{zones.map((z) => (
<MarkerView
key={String(z.ZoneId ?? z.ScannerCode ?? z.TerminalSerNo)}
coordinate={[z.Long as number, z.Lat as number]}
anchor={{ x: 0.5, y: 1 }}
>
<TouchableOpacity
onPress={() => navigation.navigate('MeterDetail', { zone: z })}
activeOpacity={0.7}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons
name="location-sharp"
size={34}
color={normalizeColor(z.BackgroundColor) ?? '#1e6f5c'}
style={styles.pinShadow}
/>
</TouchableOpacity>
</MarkerView>
))}
</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 }]}>
<TouchableOpacity style={styles.pillPrimary} 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={searchLastKnown}>
<Text style={styles.pillText}>Last</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
// White halo so the colored pin stays visible on both light and dark tiles.
pinShadow: {
textShadowColor: 'rgba(255,255,255,0.9)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 3,
},
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',
flexDirection: 'row',
gap: 8,
},
pill: {
backgroundColor: '#444',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
},
pillPrimary: {
backgroundColor: '#1e6f5c',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
pillText: { color: '#fff', fontWeight: '600' },
});