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:
Hank 2026-07-06 10:27:41 -07:00
parent 2f9f0f604a
commit fb3d1cdf3a
4 changed files with 96 additions and 26 deletions

View file

@ -44,6 +44,7 @@
"extra": {
"psEnvironment": "prodv2",
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
"unifiedPushDefaultDistributor": "io.heckel.ntfy",
"debugHttp": true
}

View file

@ -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,

View file

@ -51,6 +51,14 @@ export interface ParkSmarterClientOptions {
logSink?: (line: string) => void;
}
/** Thrown when credentials are rejected (the server signals this via a 200 + Status:"Error"). */
export class LoginError extends Error {
constructor(message: string) {
super(message);
this.name = 'LoginError';
}
}
function resolveEnvironment(
env: EnvironmentName | Environment | undefined,
): Environment {
@ -115,6 +123,21 @@ export class ParkSmarterClient {
/* Auth */
/* ============================================================== */
/**
* The server returns HTTP 200 even for a FAILED login, signalling the failure
* only via `Status: "Error"` + a null `Auth_Token`. Detect that here so callers
* get a real error instead of a phantom "signed-in" state.
*/
private finishLogin(data: T.AuthResponse): Promise<T.AuthResponse> {
const token = typeof data?.Auth_Token === 'string' ? data.Auth_Token : '';
if (!token || data?.Status === 'Error') {
throw new LoginError(data?.Message || 'Invalid login or password.');
}
return this.tokens.setAuthToken
? Promise.resolve(this.tokens.setAuthToken(token)).then(() => data)
: Promise.resolve(data);
}
/** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */
async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({
@ -123,8 +146,7 @@ export class ParkSmarterClient {
body: { UserName: params.phoneNumber, Password: params.password },
includeAuthToken: false,
});
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
return res.data;
return this.finishLogin(res.data);
}
/** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */
@ -140,8 +162,7 @@ export class ParkSmarterClient {
},
includeAuthToken: false,
});
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token);
return res.data;
return this.finishLogin(res.data);
}
/** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */

View file

@ -1,4 +1,4 @@
export { ParkSmarterClient } from './client.js';
export { ParkSmarterClient, LoginError } from './client.js';
export type { ParkSmarterClientOptions } from './client.js';
export {
ENVIRONMENTS,