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
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue