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:
parent
648acb95cb
commit
a4150555ae
3 changed files with 83 additions and 61 deletions
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue