Fix map recenter (root cause), login logo/theming, policy pricing display

- ROOT CAUSE: MapLibre Camera re-applies its last setCamera() stop on every
  re-render, so a search (setZones) snapped the map back to the last programmatic
  target. Memoize the Camera + UserLocation elements so search re-renders never
  touch them; only the marker ShapeSource updates. (Confirmed via [CAM] trace.)
- Login: brain-car logo + full theming so text is readable in light/dark
- Meter policies: negative Rate is a sentinel (RateType No Parking=-1 / Free=-3),
  not a price; show DisplayString + colored type dot, real $/hr only when positive.
  Fix zone rate "$ 4.00" formatting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 11:14:23 -07:00
parent 648acb95cb
commit a4150555ae
3 changed files with 83 additions and 61 deletions

View file

@ -1,18 +1,20 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
Image,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useAuth } from '@/auth/AuthContext';
import { useTheme } from '@/theme/ThemeContext';
export function LoginScreen() {
const { login, error } = useAuth();
const { colors } = useTheme();
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
@ -30,23 +32,32 @@ export function LoginScreen() {
return (
<KeyboardAvoidingView
style={styles.container}
style={[styles.container, { backgroundColor: colors.bg }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.title}>BigBrainParking</Text>
<Text style={styles.subtitle}>Sign in with your phone number</Text>
<Image
source={require('../../assets/icon.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={[styles.title, { color: colors.text }]}>BigBrainParking</Text>
<Text style={[styles.subtitle, { color: colors.subtext }]}>
Sign in with your phone number
</Text>
<TextInput
style={styles.input}
style={[styles.input, { color: colors.text, borderColor: colors.border, backgroundColor: colors.card }]}
placeholder="Phone number"
placeholderTextColor={colors.subtext}
keyboardType="phone-pad"
autoComplete="tel"
value={phone}
onChangeText={setPhone}
/>
<TextInput
style={styles.input}
style={[styles.input, { color: colors.text, borderColor: colors.border, backgroundColor: colors.card }]}
placeholder="Password"
placeholderTextColor={colors.subtext}
secureTextEntry
value={password}
onChangeText={setPassword}
@ -55,7 +66,7 @@ export function LoginScreen() {
{error ? <Text style={styles.error}>{error}</Text> : null}
<TouchableOpacity
style={[styles.button, busy && styles.buttonDisabled]}
style={[styles.button, { backgroundColor: colors.primary }, busy && styles.buttonDisabled]}
disabled={busy}
onPress={onSubmit}
>
@ -66,10 +77,9 @@ export function LoginScreen() {
)}
</TouchableOpacity>
<View style={{ height: 12 }} />
<Text style={styles.hint}>
Forgot your password? Use the official app or a reset SMS this build reuses
the same account.
<Text style={[styles.hint, { color: colors.subtext }]}>
Forgot your password? Reset it via the official app this build reuses the same
account.
</Text>
</KeyboardAvoidingView>
);
@ -77,24 +87,19 @@ export function LoginScreen() {
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 32, fontWeight: '700', textAlign: 'center' },
subtitle: { fontSize: 15, color: '#666', textAlign: 'center', marginBottom: 24 },
logo: { width: 112, height: 112, alignSelf: 'center', marginBottom: 12, borderRadius: 24 },
title: { fontSize: 32, fontWeight: '800', textAlign: 'center' },
subtitle: { fontSize: 15, textAlign: 'center', marginBottom: 24 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 10,
padding: 14,
fontSize: 16,
marginBottom: 12,
},
error: { color: '#c0392b', marginBottom: 12 },
button: {
backgroundColor: '#1e6f5c',
borderRadius: 10,
padding: 16,
alignItems: 'center',
},
error: { color: '#e0574a', marginBottom: 12, fontWeight: '600', textAlign: 'center' },
button: { borderRadius: 10, padding: 16, alignItems: 'center' },
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
hint: { color: '#888', fontSize: 12, textAlign: 'center' },
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
hint: { fontSize: 12, textAlign: 'center', marginTop: 16 },
});

View file

@ -178,6 +178,29 @@ export function MapScreen() {
}
};
// MapLibre's Camera re-applies its last imperative setCamera() stop whenever it
// re-renders — so a search re-render (setZones) would snap the map back to the
// last programmatic target. Memoize these elements so search re-renders never
// touch them; only the marker ShapeSource below updates.
const cameraEl = useMemo(() => <Camera ref={cameraRef} />, []);
const userLocationEl = useMemo(
() => (
<UserLocation
visible
renderMode="normal"
onUpdate={(loc: any) => {
if (loc?.coords) {
nativeFix.current = {
latitude: loc.coords.latitude,
longitude: loc.coords.longitude,
};
}
}}
/>
),
[],
);
return (
<View style={styles.container}>
<MapView
@ -198,25 +221,8 @@ export function MapScreen() {
)
}
>
{/* 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,
};
}
}}
/>
{cameraEl}
{userLocationEl}
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
<CircleLayer

View file

@ -16,22 +16,27 @@ function fmtMaxTime(m?: number): string {
return `${m} min`;
}
/** Zone rate comes as a string that sometimes already includes a '$'. */
/** Zone rate comes as a string that sometimes already includes a '$' and spaces. */
function fmtRate(r?: string): string | undefined {
if (r == null) return undefined;
return '$' + String(r).replace(/^\$+/, '');
return '$' + String(r).replace(/^\$\s*/, '').trim();
}
function policyLine(p: SpacePolicy): { title: string; sub: string } {
/**
* Policies are time-of-day windows. A NEGATIVE `Rate` is a sentinel, not a price:
* the meaning is in `RateType` (e.g. "No Parking", "Free"); only a positive Rate
* is a real $/hr charge.
*/
function policyMeta(p: SpacePolicy): { title: string; rate?: string; color?: string } {
const rt = (p.RateType ?? '').toLowerCase();
const color = rt.includes('no parking')
? '#c0392b'
: rt.includes('free')
? '#2e7d32'
: undefined;
const title = p.DisplayString || p.MessageHeader || p.RateType || 'Rate policy';
const parts: string[] = [];
if (p.StartTimeDisplay && p.EndTimeDisplay) {
parts.push(`${p.StartTimeDisplay}${p.EndTimeDisplay}`);
}
// Negative/zero rates are sentinels ("no charge" / "n/a") — only show real ones.
if (typeof p.Rate === 'number' && p.Rate > 0) parts.push(`$${p.Rate}/hr`);
if (p.MaxTime != null) parts.push(`${fmtMaxTime(p.MaxTime)} max`);
return { title, sub: parts.join(' · ') };
const rate = typeof p.Rate === 'number' && p.Rate > 0 ? `$${p.Rate}/hr` : undefined;
return { title, rate, color };
}
export function MeterDetailScreen() {
@ -86,13 +91,18 @@ export function MeterDetailScreen() {
{firstSpace?.Policies?.length ? (
<View style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.cardTitle, { color: colors.text }]}>Rate policies</Text>
{firstSpace.Policies.slice(0, 8).map((p, i) => {
const { title, sub } = policyLine(p);
{firstSpace.Policies.slice(0, 10).map((p, i) => {
const { title, rate, color } = policyMeta(p);
return (
<View key={i} style={styles.policyRow}>
<Text style={[styles.policyTitle, { color: colors.text }]}>{title}</Text>
{sub ? (
<Text style={[styles.policySub, { color: colors.subtext }]}>{sub}</Text>
<View
style={[styles.policyDot, { backgroundColor: color ?? colors.primary }]}
/>
<Text style={[styles.policyTitle, { color: colors.text }]} numberOfLines={2}>
{title}
</Text>
{rate ? (
<Text style={[styles.policyRate, { color: colors.subtext }]}>{rate}</Text>
) : null}
</View>
);
@ -134,9 +144,10 @@ const styles = StyleSheet.create({
fieldValue: { fontSize: 16, fontWeight: '600', marginTop: 2 },
card: { borderRadius: 10, padding: 14, marginTop: 16 },
cardTitle: { fontWeight: '700', marginBottom: 8, fontSize: 15 },
policyRow: { marginBottom: 8 },
policyTitle: { fontSize: 14, fontWeight: '600' },
policySub: { fontSize: 12, marginTop: 1 },
policyRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 10 },
policyDot: { width: 9, height: 9, borderRadius: 5 },
policyTitle: { fontSize: 14, fontWeight: '600', flex: 1 },
policyRate: { fontSize: 13, fontWeight: '600' },
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
buttonText: { fontWeight: '600', fontSize: 16 },
});