Detect failed logins; fix map camera recenter, marker contrast, dark map tiles
- Client: server returns 200 + Status:"Error" on bad credentials; loginWith* now throw LoginError with the server message instead of faking a signed-in state - Map: uncontrolled camera positioned once on load + explicit actions only, so Search/marker-tap/re-renders no longer snap back to the user's location - Markers: always-dark high-contrast bubble + zone-colored dot (some zones report a white BackgroundColor -> was white-on-white) - Dark mode now switches map tiles to CARTO dark-matter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2f9f0f604a
commit
fb3d1cdf3a
4 changed files with 96 additions and 26 deletions
|
|
@ -10,14 +10,18 @@ import {
|
|||
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 =
|
||||
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 };
|
||||
|
|
@ -25,9 +29,23 @@ 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”.');
|
||||
|
|
@ -36,11 +54,15 @@ export function MapScreen() {
|
|||
|
||||
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 from a cached location so we don't strand at 0,0.
|
||||
// Seed the initial camera target from a cached location so we don't strand at 0,0.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const last = await getLastKnownSavedLocation();
|
||||
|
|
@ -49,6 +71,18 @@ export function MapScreen() {
|
|||
// 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}…`);
|
||||
|
|
@ -115,16 +149,15 @@ export function MapScreen() {
|
|||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MapView ref={mapRef} style={styles.map} mapStyle={MAP_STYLE}>
|
||||
{initialCenter ? (
|
||||
<Camera
|
||||
ref={cameraRef}
|
||||
defaultSettings={{
|
||||
centerCoordinate: [initialCenter.longitude, initialCenter.latitude],
|
||||
zoomLevel: initialCenter === DEFAULT_CENTER ? DEFAULT_ZOOM : 14,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
style={styles.map}
|
||||
mapStyle={mapStyle}
|
||||
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} />
|
||||
|
||||
<UserLocation
|
||||
visible
|
||||
|
|
@ -143,13 +176,23 @@ export function MapScreen() {
|
|||
<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 })}
|
||||
style={[styles.marker, { backgroundColor: z.BackgroundColor ?? '#1e6f5c' }]}
|
||||
activeOpacity={0.8}
|
||||
style={styles.marker}
|
||||
>
|
||||
{/* Small dot in the zone's own color, but the label bubble is always
|
||||
high-contrast (some zones report a white BackgroundColor). */}
|
||||
<View
|
||||
style={[
|
||||
styles.markerDot,
|
||||
{ backgroundColor: normalizeColor(z.BackgroundColor) ?? '#1e6f5c' },
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.markerText} numberOfLines={1}>
|
||||
{z.ZoneName ?? 'Meter'}
|
||||
{z.ZoneName ?? z.ScannerCode ?? 'Meter'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</MarkerView>
|
||||
|
|
@ -182,14 +225,19 @@ const styles = StyleSheet.create({
|
|||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
marker: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
maxWidth: 140,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 9,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.85)',
|
||||
backgroundColor: 'rgba(17,24,22,0.92)', // always dark so the label is readable
|
||||
maxWidth: 150,
|
||||
},
|
||||
markerText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||||
markerDot: { width: 9, height: 9, borderRadius: 5 },
|
||||
markerText: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||
statusBar: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue