Meter screen: theme it + fix rate/max-time/policy display; add camera trace logs

- MeterDetail now themed (fixes unreadable black headings in dark mode)
- Rate no longer double-'$'; MaxTime 0 -> "Unlimited"; policies show a clean
  title + time window and hide sentinel (<=0) rates
- Temporary [CAM] logging on the map to trace the persistent recenter-on-search

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 11:05:42 -07:00
parent 3837200d89
commit 648acb95cb
2 changed files with 95 additions and 49 deletions

View file

@ -76,6 +76,7 @@ export function MapScreen() {
useEffect(() => {
if (mapReady && initialCenter && !positioned.current) {
positioned.current = true;
console.log('[CAM] initial-positioning ->', JSON.stringify(initialCenter));
cameraRef.current?.setCamera?.({
centerCoordinate: [initialCenter.longitude, initialCenter.latitude],
zoomLevel: initialCenter === DEFAULT_CENTER ? DEFAULT_ZOOM : 14,
@ -108,13 +109,15 @@ export function MapScreen() {
const searchThisArea = async () => {
try {
const center = await mapRef.current?.getCenter?.(); // [lng, lat]
console.log('[CAM] searchThisArea getCenter=', JSON.stringify(center));
if (center && center.length === 2) {
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
return;
}
} catch {
/* fall through */
} catch (err) {
console.log('[CAM] getCenter threw', String(err));
}
console.log('[CAM] searchThisArea FALLBACK coords=', JSON.stringify(coords));
if (coords) await searchAt(coords, 'this area');
};
@ -126,6 +129,7 @@ export function MapScreen() {
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
return;
}
console.log('[CAM] goToMyLocation setCamera ->', JSON.stringify(c));
cameraRef.current?.setCamera?.({
centerCoordinate: [c.longitude, c.latitude],
zoomLevel: 15,
@ -140,6 +144,7 @@ export function MapScreen() {
setStatus('No saved location yet — enable location once to cache it.');
return;
}
console.log('[CAM] searchLastKnown setCamera ->', JSON.stringify(last));
cameraRef.current?.setCamera?.({
centerCoordinate: [last.longitude, last.latitude],
zoomLevel: 15,
@ -180,7 +185,18 @@ export function MapScreen() {
style={styles.map}
mapStyle={mapStyle}
rotateEnabled={false}
onDidFinishLoadingMap={() => setMapReady(true)}
onDidFinishLoadingMap={() => {
console.log('[CAM] onDidFinishLoadingMap (mapReady already', mapReady, ')');
setMapReady(true);
}}
onRegionDidChange={(f: any) =>
console.log(
'[CAM] regionDidChange center=',
JSON.stringify(f?.geometry?.coordinates),
'userInteraction=',
f?.properties?.isUserInteraction,
)
}
>
{/* Uncontrolled camera; we position it imperatively (once on load, then
only on explicit user actions) so re-renders never move the map. */}

View file

@ -4,10 +4,38 @@ import type { RouteProp } from '@react-navigation/native';
import { useRoute } from '@react-navigation/native';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
import { useTheme } from '@/theme/ThemeContext';
import type { SpacePolicy } from 'parksmarter-client';
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
/** MaxTime of 0 means no cap. */
function fmtMaxTime(m?: number): string {
if (m == null) return '—';
if (m <= 0) return 'Unlimited';
return `${m} min`;
}
/** Zone rate comes as a string that sometimes already includes a '$'. */
function fmtRate(r?: string): string | undefined {
if (r == null) return undefined;
return '$' + String(r).replace(/^\$+/, '');
}
function policyLine(p: SpacePolicy): { title: string; sub: string } {
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(' · ') };
}
export function MeterDetailScreen() {
const { colors } = useTheme();
const { params } = useRoute<DetailRoute>();
const z = params.zone;
const [saved, setSaved] = useState(false);
@ -20,21 +48,32 @@ export function MeterDetailScreen() {
const firstSpace = z.Spaces?.[0];
const Field = ({ label, value }: { label: string; value?: string }) => (
<View style={[styles.field, { backgroundColor: colors.card }]}>
<Text style={[styles.fieldLabel, { color: colors.subtext }]}>{label}</Text>
<Text style={[styles.fieldValue, { color: colors.text }]}>{value ?? '—'}</Text>
</View>
);
return (
<ScrollView style={styles.container} contentContainerStyle={{ padding: 16 }}>
<Text style={styles.title}>{z.ZoneName ?? 'Parking meter'}</Text>
{z.ZoneLocation ? <Text style={styles.sub}>{z.ZoneLocation}</Text> : null}
<ScrollView
style={{ backgroundColor: colors.bg }}
contentContainerStyle={{ padding: 16 }}
>
<Text style={[styles.title, { color: colors.text }]}>
{z.ZoneName ?? 'Parking meter'}
</Text>
{z.ZoneLocation ? (
<Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text>
) : null}
<View style={styles.row}>
<Field label="Scanner code" value={z.ScannerCode} />
<Field label="Serial" value={z.TerminalSerNo} />
</View>
<View style={styles.row}>
<Field label="Rate" value={z.Rate != null ? `$${z.Rate}` : undefined} />
<Field
label="Max time"
value={z.MaxTime != null ? `${z.MaxTime} min` : undefined}
/>
<Field label="Rate" value={fmtRate(z.Rate)} />
<Field label="Max time" value={fmtMaxTime(z.MaxTime)} />
</View>
<View style={styles.row}>
<Field
@ -45,27 +84,34 @@ export function MeterDetailScreen() {
</View>
{firstSpace?.Policies?.length ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>Rate policies</Text>
{firstSpace.Policies.slice(0, 6).map((p, i) => (
<Text key={i} style={styles.policy}>
{p.DisplayString ?? p.RateType ?? 'Policy'}
{p.Rate != null ? `$${p.Rate}` : ''}
</Text>
))}
<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);
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>
) : null}
</View>
);
})}
</View>
) : null}
<TouchableOpacity
style={[styles.button, saved && styles.buttonSaved]}
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
onPress={onSave}
disabled={saved}
>
<Text style={styles.buttonText}>{saved ? 'Saved ✓' : 'Save kiosk'}</Text>
<Text style={[styles.buttonText, { color: saved ? '#fff' : colors.text }]}>
{saved ? 'Saved ✓' : 'Save kiosk'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.buttonPrimary]}
style={[styles.button, { backgroundColor: colors.primary }]}
onPress={() =>
Alert.alert(
'Start session',
@ -73,40 +119,24 @@ export function MeterDetailScreen() {
)
}
>
<Text style={styles.buttonText}>Start parking session</Text>
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Field({ label, value }: { label: string; value?: string }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<Text style={styles.fieldValue}>{value ?? '—'}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
title: { fontSize: 24, fontWeight: '700' },
sub: { color: '#666', marginBottom: 12 },
sub: { marginBottom: 12 },
row: { flexDirection: 'row', gap: 12, marginTop: 8 },
field: { flex: 1, backgroundColor: '#f2f2f2', borderRadius: 10, padding: 12 },
fieldLabel: { fontSize: 12, color: '#888' },
field: { flex: 1, borderRadius: 10, padding: 12 },
fieldLabel: { fontSize: 12 },
fieldValue: { fontSize: 16, fontWeight: '600', marginTop: 2 },
card: { backgroundColor: '#f7f7f7', borderRadius: 10, padding: 14, marginTop: 16 },
cardTitle: { fontWeight: '700', marginBottom: 6 },
policy: { color: '#444', marginBottom: 2 },
button: {
marginTop: 16,
backgroundColor: '#555',
borderRadius: 10,
padding: 16,
alignItems: 'center',
},
buttonPrimary: { backgroundColor: '#1e6f5c' },
buttonSaved: { backgroundColor: '#2e7d32' },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
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 },
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
buttonText: { fontWeight: '600', fontSize: 16 },
});