Wire up start-session flow (variable-rate aware) + fix About dark mode

- New StartSession screen off MeterDetail: pick vehicle -> duration (from the live
  multi-estimate ladder, exact cost per option) -> card; detects a free prefix and
  shows "Free until X, then charged"; explicit Pay $X confirmation before charging
  via postStartParkingSession; schedules the local expiry reminder on success
- About screen themed (was black-on-dark in dark mode)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 11:39:19 -07:00
parent 9a5c9d445f
commit f044a9a511
4 changed files with 340 additions and 11 deletions

View file

@ -20,12 +20,14 @@ import { ProfileScreen } from '@/screens/ProfileScreen';
import { VehiclesScreen } from '@/screens/VehiclesScreen'; import { VehiclesScreen } from '@/screens/VehiclesScreen';
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen'; import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen'; import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { StartSessionScreen } from '@/screens/StartSessionScreen';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
import type { Zone } from 'parksmarter-client'; import type { Zone } from 'parksmarter-client';
export type RootStackParamList = { export type RootStackParamList = {
Tabs: undefined; Tabs: undefined;
MeterDetail: { zone: Zone }; MeterDetail: { zone: Zone };
StartSession: { zone: Zone };
About: undefined; About: undefined;
Profile: undefined; Profile: undefined;
Vehicles: undefined; Vehicles: undefined;
@ -111,6 +113,11 @@ export function RootNavigator() {
component={MeterDetailScreen} component={MeterDetailScreen}
options={{ title: 'Meter' }} options={{ title: 'Meter' }}
/> />
<Stack.Screen
name="StartSession"
component={StartSessionScreen}
options={{ title: 'Start session' }}
/>
<Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} /> <Stack.Screen name="About" component={AboutScreen} options={{ title: 'About' }} />
<Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} /> <Stack.Screen name="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} /> <Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />

View file

@ -10,10 +10,12 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { ps } from '@/api/client'; import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
const TAPS_TO_UNLOCK = 7; const TAPS_TO_UNLOCK = 7;
export function AboutScreen() { export function AboutScreen() {
const { colors } = useTheme();
const [about, setAbout] = useState<string | null>(null); const [about, setAbout] = useState<string | null>(null);
const [showJoel, setShowJoel] = useState(false); const [showJoel, setShowJoel] = useState(false);
const taps = useRef(0); const taps = useRef(0);
@ -37,14 +39,14 @@ export function AboutScreen() {
}; };
return ( return (
<ScrollView contentContainerStyle={styles.container}> <ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={styles.container}>
<Pressable onPress={onTitleTap}> <Pressable onPress={onTitleTap}>
<Text style={styles.title}>About BigBrainParking</Text> <Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
</Pressable> </Pressable>
<Text style={styles.body}>{about ?? 'Loading…'}</Text> <Text style={[styles.body, { color: colors.text }]}>{about ?? 'Loading…'}</Text>
<Text style={styles.meta}> <Text style={[styles.meta, { color: colors.subtext }]}>
Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group. Unofficial, de-Googled client for ParkSmarter. Not affiliated with IPS Group.
</Text> </Text>

View file

@ -1,7 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { RouteProp } from '@react-navigation/native'; import type { RouteProp } from '@react-navigation/native';
import { useRoute } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation/RootNavigator'; import type { RootStackParamList } from '@/navigation/RootNavigator';
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore'; import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
import { useTheme } from '@/theme/ThemeContext'; import { useTheme } from '@/theme/ThemeContext';
@ -39,8 +40,11 @@ function policyMeta(p: SpacePolicy): { title: string; rate?: string; color?: str
return { title, rate, color }; return { title, rate, color };
} }
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function MeterDetailScreen() { export function MeterDetailScreen() {
const { colors } = useTheme(); const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const { params } = useRoute<DetailRoute>(); const { params } = useRoute<DetailRoute>();
const z = params.zone; const z = params.zone;
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@ -122,12 +126,7 @@ export function MeterDetailScreen() {
<TouchableOpacity <TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary }]} style={[styles.button, { backgroundColor: colors.primary }]}
onPress={() => onPress={() => navigation.navigate('StartSession', { zone: z })}
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> <Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
</TouchableOpacity> </TouchableOpacity>

View file

@ -0,0 +1,321 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import { scheduleExpiryReminder } from '@/notifications/localReminders';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type {
CreditCardDetail,
ParkingDetail,
VehicleDetail,
} from 'parksmarter-client';
type SessionRoute = RouteProp<RootStackParamList, 'StartSession'>;
type Nav = NativeStackNavigationProp<RootStackParamList>;
function fmtDuration(min?: number): string {
if (!min) return '';
const h = Math.floor(min / 60);
const m = min % 60;
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
}
const money = (v?: string | number) => `$${Number(v ?? 0).toFixed(2)}`;
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
function parseApiTime(s?: string): Date | null {
if (!s) return null;
const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
if (!m) return null;
let hr = parseInt(m[4], 10);
const pm = /pm/i.test(m[6]);
if (pm && hr !== 12) hr += 12;
if (!pm && hr === 12) hr = 0;
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
}
export function StartSessionScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const { zone } = useRoute<SessionRoute>().params;
const space = zone.Spaces?.[0];
const [vehicles, setVehicles] = useState<VehicleDetail[]>([]);
const [cards, setCards] = useState<CreditCardDetail[]>([]);
const [vehicleId, setVehicleId] = useState<number | undefined>();
const [cardId, setCardId] = useState<number | undefined>();
const [ladder, setLadder] = useState<ParkingDetail[]>([]);
const [selIdx, setSelIdx] = useState(0);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
// Load the account's vehicles + cards and pick the defaults.
useEffect(() => {
ps.getUserDetail()
.then((u) => {
const vs = u.VehicleDetails ?? [];
const cs = u.CreditCardDetails ?? [];
setVehicles(vs);
setCards(cs);
const defV = vs.find((v) => String(v.IsDefault) === 'true') ?? vs[0];
const defC = cs.find((c) => String(c.CCDefault) === 'true') ?? cs[0];
setVehicleId(defV?.VehicleID as number | undefined);
setCardId(defC?.CCID as number | undefined);
})
.catch(() => {});
}, []);
// (Re)fetch the price ladder whenever the vehicle changes — rates can vary by vehicle.
const loadLadder = useCallback(async () => {
if (vehicleId == null || space?.SpaceId == null) return;
setLoading(true);
try {
const est = await ps.getParkingEstimateMulti({
zoneId: zone.ZoneId!,
spaceId: space.SpaceId!,
customerId: zone.CustomerId!,
vehicleId,
});
const details = est.ParkingDetail ?? [];
setLadder(details);
setSelIdx((i) => Math.min(i, Math.max(0, details.length - 1)));
} finally {
setLoading(false);
}
}, [vehicleId, space?.SpaceId, zone.ZoneId, zone.CustomerId]);
useEffect(() => {
void loadLadder();
}, [loadLadder]);
const selected = ladder[selIdx];
// Detect a free prefix (e.g., inside a "Free until 6:15 AM" policy window).
const freeInfo = useMemo(() => {
const firstPaid = ladder.findIndex((d) => Number(d.ParkingCost) > 0);
if (firstPaid > 0) {
return { until: ladder[firstPaid - 1]?.EndTime, minutes: ladder[firstPaid - 1]?.Minutes };
}
return null;
}, [ladder]);
const total = selected
? Number(selected.ParkingCost ?? 0) + Number(selected.TransactionFee ?? 0)
: 0;
const onPay = () => {
if (!selected || cardId == null || vehicleId == null) {
Alert.alert('Missing info', 'Pick a vehicle, duration, and card first.');
return;
}
Alert.alert(
'Confirm payment',
`Park at ${zone.ZoneName} for ${fmtDuration(selected.Minutes)}.\n\n` +
`Parking ${money(selected.ParkingCost)}` +
(Number(selected.TransactionFee) > 0 ? ` + fee ${money(selected.TransactionFee)}` : '') +
`\nTotal: ${money(total)}\n\nThis charges your card now.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: `Pay ${money(total)}`, style: 'default', onPress: doStart },
],
);
};
const doStart = async () => {
if (!selected) return;
setPaying(true);
try {
const res = await ps.startParkingSession({
creditCardId: cardId!,
vehicleId: vehicleId!,
zoneId: zone.ZoneId!,
spaceId: space!.SpaceId!,
customerId: zone.CustomerId!,
startTime: selected.StartTime!,
endTime: selected.EndTime!,
minutesToPurchase: selected.Minutes!,
parkingCost: Number(selected.ParkingCost ?? 0),
transactionFee: Number(selected.TransactionFee ?? 0),
minCreditAmount: zone.MinimumAmount,
meterTypeId: zone.MeterTypeId!,
});
// Schedule the local expiry reminder from the purchased end time.
const end = parseApiTime(selected.EndTime);
if (end) {
await scheduleExpiryReminder({
transactionId: (res as any)?.TransactionID ?? Date.now(),
zoneName: zone.ZoneName ?? 'Parking',
endTime: end,
});
}
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
{ text: 'OK', onPress: () => navigation.navigate('Tabs') },
]);
} catch (e: any) {
Alert.alert('Could not start session', e?.serverMessage ?? e?.message ?? 'error');
} finally {
setPaying(false);
}
};
if (loading && !ladder.length) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
const Chip = ({
active,
label,
onPress,
}: {
active: boolean;
label: string;
onPress: () => void;
}) => (
<TouchableOpacity
onPress={onPress}
style={[
styles.chip,
{ borderColor: active ? colors.primary : colors.border, backgroundColor: active ? colors.primary : colors.card },
]}
>
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600' }}>{label}</Text>
</TouchableOpacity>
);
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
<Text style={[styles.zone, { color: colors.text }]}>{zone.ZoneName}</Text>
<Text style={[styles.sub, { color: colors.subtext }]}>
{zone.ZoneLocation ?? space?.SpaceName ?? ''}
</Text>
<Text style={[styles.label, { color: colors.subtext }]}>Vehicle</Text>
<View style={styles.chipRow}>
{vehicles.map((v) => (
<Chip
key={String(v.VehicleID)}
active={v.VehicleID === vehicleId}
label={`${v.VehicleAlias || v.VehiclePlate} `}
onPress={() => setVehicleId(v.VehicleID as number)}
/>
))}
</View>
<Text style={[styles.label, { color: colors.subtext }]}>Duration</Text>
<FlatList
horizontal
data={ladder}
keyExtractor={(d, i) => String(d.Minutes ?? i)}
showsHorizontalScrollIndicator={false}
renderItem={({ item, index }) => (
<Chip
active={index === selIdx}
label={`${fmtDuration(item.Minutes)} · ${money(item.ParkingCost)}`}
onPress={() => setSelIdx(index)}
/>
)}
/>
<Text style={[styles.label, { color: colors.subtext }]}>Card</Text>
<View style={styles.chipRow}>
{cards.map((c) => (
<Chip
key={String(c.CCID)}
active={c.CCID === cardId}
label={`${c.CCAlias || 'Card'} ••${c.CCLastFour ?? ''}`}
onPress={() => setCardId(c.CCID as number)}
/>
))}
{cards.length === 0 ? (
<Text style={{ color: colors.subtext }}>No cards on file.</Text>
) : null}
</View>
{freeInfo ? (
<View style={[styles.freeBanner, { backgroundColor: '#e8f5e9' }]}>
<Text style={{ color: '#2e7d32', fontWeight: '600' }}>
Free until {freeInfo.until} ({fmtDuration(freeInfo.minutes)}), then charged.
</Text>
</View>
) : null}
{selected ? (
<View style={[styles.quote, { backgroundColor: colors.card }]}>
<Row label="Duration" value={fmtDuration(selected.Minutes)} colors={colors} />
<Row label="Ends" value={selected.EndTime ?? ''} colors={colors} />
<Row label="Parking" value={money(selected.ParkingCost)} colors={colors} />
{Number(selected.TransactionFee) > 0 ? (
<Row label="Fee" value={money(selected.TransactionFee)} colors={colors} />
) : null}
<Row label="Total" value={money(total)} bold colors={colors} />
</View>
) : null}
<TouchableOpacity
style={[styles.payBtn, { backgroundColor: colors.primary, opacity: paying ? 0.6 : 1 }]}
disabled={paying || !selected}
onPress={onPay}
>
<Text style={styles.payText}>
{paying ? 'Starting…' : `Pay ${money(total)} & start`}
</Text>
</TouchableOpacity>
</ScrollView>
);
}
function Row({
label,
value,
bold,
colors,
}: {
label: string;
value: string;
bold?: boolean;
colors: { text: string; subtext: string };
}) {
return (
<View style={styles.quoteRow}>
<Text style={{ color: colors.subtext }}>{label}</Text>
<Text style={{ color: colors.text, fontWeight: bold ? '800' : '600', fontSize: bold ? 18 : 15 }}>
{value}
</Text>
</View>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
zone: { fontSize: 24, fontWeight: '700' },
sub: { marginBottom: 8 },
label: { fontSize: 13, fontWeight: '600', marginTop: 18, marginBottom: 8 },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
borderWidth: 1.5,
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 9,
marginRight: 8,
},
freeBanner: { borderRadius: 10, padding: 12, marginTop: 16 },
quote: { borderRadius: 12, padding: 16, marginTop: 16 },
quoteRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 5 },
payBtn: { borderRadius: 12, padding: 18, alignItems: 'center', marginTop: 20 },
payText: { color: '#fff', fontWeight: '800', fontSize: 17 },
});