- 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>
266 lines
9.3 KiB
TypeScript
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' },
|
|
});
|