- 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>
142 lines
5.4 KiB
TypeScript
142 lines
5.4 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
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);
|
||
|
||
const onSave = async () => {
|
||
await saveKiosk(toSavedKiosk(z));
|
||
setSaved(true);
|
||
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
|
||
};
|
||
|
||
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={{ 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={fmtRate(z.Rate)} />
|
||
<Field label="Max time" value={fmtMaxTime(z.MaxTime)} />
|
||
</View>
|
||
<View style={styles.row}>
|
||
<Field
|
||
label="Occupancy"
|
||
value={z.PercentageFull != null ? `${z.PercentageFull}% full` : undefined}
|
||
/>
|
||
<Field label="Spaces" value={z.Spaces ? String(z.Spaces.length) : undefined} />
|
||
</View>
|
||
|
||
{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);
|
||
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, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
|
||
onPress={onSave}
|
||
disabled={saved}
|
||
>
|
||
<Text style={[styles.buttonText, { color: saved ? '#fff' : colors.text }]}>
|
||
{saved ? 'Saved ✓' : 'Save kiosk'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity
|
||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||
onPress={() =>
|
||
Alert.alert(
|
||
'Start session',
|
||
'Starting a paid session is a real charge — this flow (vehicle + duration + card selection) is wired to postStartParkingSession and will be enabled after review.',
|
||
)
|
||
}
|
||
>
|
||
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
|
||
</TouchableOpacity>
|
||
</ScrollView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
title: { fontSize: 24, fontWeight: '700' },
|
||
sub: { marginBottom: 12 },
|
||
row: { flexDirection: 'row', gap: 12, marginTop: 8 },
|
||
field: { flex: 1, borderRadius: 10, padding: 12 },
|
||
fieldLabel: { fontSize: 12 },
|
||
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 },
|
||
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
|
||
buttonText: { fontWeight: '600', fontSize: 16 },
|
||
});
|