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": { "extra": {
"psEnvironment": "prodv2", "psEnvironment": "prodv2",
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty", "mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
"unifiedPushDefaultDistributor": "io.heckel.ntfy", "unifiedPushDefaultDistributor": "io.heckel.ntfy",
"debugHttp": true "debugHttp": true
} }

View file

@ -10,14 +10,18 @@ import {
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import { ps } from '@/api/client'; import { ps } from '@/api/client';
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation'; import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
import type { RootStackParamList } from '@/navigation/RootNavigator'; import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { Zone } from 'parksmarter-client'; import type { Zone } from 'parksmarter-client';
const MAP_STYLE = const MAP_STYLE_LIGHT =
(Constants.expoConfig?.extra?.mapStyleUrl as string) ?? (Constants.expoConfig?.extra?.mapStyleUrl as string) ??
'https://tiles.openfreemap.org/styles/liberty'; '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). // 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_CENTER: Coords = { latitude: 39.5, longitude: -98.35 };
@ -25,9 +29,23 @@ const DEFAULT_ZOOM = 4;
type Nav = NativeStackNavigationProp<RootStackParamList>; 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() { export function MapScreen() {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { mode } = useTheme();
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
const { coords, refresh } = useLocation(); const { coords, refresh } = useLocation();
const [zones, setZones] = useState<Zone[]>([]); const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.'); 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 mapRef = useRef<any>(null);
const cameraRef = 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 // 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). // location" works even when expo-location can't get a fix (e.g. indoors).
const nativeFix = useRef<Coords | null>(null); 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(() => { useEffect(() => {
(async () => { (async () => {
const last = await getLastKnownSavedLocation(); const last = await getLastKnownSavedLocation();
@ -49,6 +71,18 @@ export function MapScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps // 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) => { const searchAt = useCallback(async (c: Coords, label: string) => {
setLoading(true); setLoading(true);
setStatus(`Searching ${label}`); setStatus(`Searching ${label}`);
@ -115,16 +149,15 @@ export function MapScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<MapView ref={mapRef} style={styles.map} mapStyle={MAP_STYLE}> <MapView
{initialCenter ? ( ref={mapRef}
<Camera style={styles.map}
ref={cameraRef} mapStyle={mapStyle}
defaultSettings={{ onDidFinishLoadingMap={() => setMapReady(true)}
centerCoordinate: [initialCenter.longitude, initialCenter.latitude], >
zoomLevel: initialCenter === DEFAULT_CENTER ? DEFAULT_ZOOM : 14, {/* 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} />
) : null}
<UserLocation <UserLocation
visible visible
@ -143,13 +176,23 @@ export function MapScreen() {
<MarkerView <MarkerView
key={String(z.ZoneId ?? z.ScannerCode ?? z.TerminalSerNo)} key={String(z.ZoneId ?? z.ScannerCode ?? z.TerminalSerNo)}
coordinate={[z.Long as number, z.Lat as number]} coordinate={[z.Long as number, z.Lat as number]}
anchor={{ x: 0.5, y: 1 }}
> >
<TouchableOpacity <TouchableOpacity
onPress={() => navigation.navigate('MeterDetail', { zone: z })} 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}> <Text style={styles.markerText} numberOfLines={1}>
{z.ZoneName ?? 'Meter'} {z.ZoneName ?? z.ScannerCode ?? 'Meter'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</MarkerView> </MarkerView>
@ -182,14 +225,19 @@ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
map: { flex: 1 }, map: { flex: 1 },
marker: { marker: {
paddingHorizontal: 8, flexDirection: 'row',
paddingVertical: 4, alignItems: 'center',
borderRadius: 8, gap: 5,
borderWidth: 2, paddingHorizontal: 9,
borderColor: '#fff', paddingVertical: 5,
maxWidth: 140, 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: { statusBar: {
position: 'absolute', position: 'absolute',
top: 12, top: 12,

View file

@ -51,6 +51,14 @@ export interface ParkSmarterClientOptions {
logSink?: (line: string) => void; 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( function resolveEnvironment(
env: EnvironmentName | Environment | undefined, env: EnvironmentName | Environment | undefined,
): Environment { ): Environment {
@ -115,6 +123,21 @@ export class ParkSmarterClient {
/* Auth */ /* 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. */ /** POST /api/Auth — phone + password login. Persists Auth_Token & SessionId. */
async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> { async loginWithPhone(params: T.LoginWithPhoneParams): Promise<T.AuthResponse> {
const res = await this.http.request<T.AuthResponse>({ const res = await this.http.request<T.AuthResponse>({
@ -123,8 +146,7 @@ export class ParkSmarterClient {
body: { UserName: params.phoneNumber, Password: params.password }, body: { UserName: params.phoneNumber, Password: params.password },
includeAuthToken: false, includeAuthToken: false,
}); });
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token); return this.finishLogin(res.data);
return res.data;
} }
/** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */ /** POST /api/Auth — Sign in with Apple. Persists Auth_Token & SessionId. */
@ -140,8 +162,7 @@ export class ParkSmarterClient {
}, },
includeAuthToken: false, includeAuthToken: false,
}); });
if (res.data?.Auth_Token) await this.tokens.setAuthToken(res.data.Auth_Token); return this.finishLogin(res.data);
return res.data;
} }
/** Re-authenticate using a cached Auth_Token (sets it, then callers can bootstrap). */ /** 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 type { ParkSmarterClientOptions } from './client.js';
export { export {
ENVIRONMENTS, ENVIRONMENTS,