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:
parent
9a5c9d445f
commit
f044a9a511
4 changed files with 340 additions and 11 deletions
|
|
@ -20,12 +20,14 @@ import { ProfileScreen } from '@/screens/ProfileScreen';
|
|||
import { VehiclesScreen } from '@/screens/VehiclesScreen';
|
||||
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
|
||||
import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Tabs: undefined;
|
||||
MeterDetail: { zone: Zone };
|
||||
StartSession: { zone: Zone };
|
||||
About: undefined;
|
||||
Profile: undefined;
|
||||
Vehicles: undefined;
|
||||
|
|
@ -111,6 +113,11 @@ export function RootNavigator() {
|
|||
component={MeterDetailScreen}
|
||||
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="Profile" component={ProfileScreen} options={{ title: 'Profile' }} />
|
||||
<Stack.Screen name="Vehicles" component={VehiclesScreen} options={{ title: 'Vehicles' }} />
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import {
|
|||
View,
|
||||
} from 'react-native';
|
||||
import { ps } from '@/api/client';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
|
||||
const TAPS_TO_UNLOCK = 7;
|
||||
|
||||
export function AboutScreen() {
|
||||
const { colors } = useTheme();
|
||||
const [about, setAbout] = useState<string | null>(null);
|
||||
const [showJoel, setShowJoel] = useState(false);
|
||||
const taps = useRef(0);
|
||||
|
|
@ -37,14 +39,14 @@ export function AboutScreen() {
|
|||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container}>
|
||||
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={styles.container}>
|
||||
<Pressable onPress={onTitleTap}>
|
||||
<Text style={styles.title}>About BigBrainParking</Text>
|
||||
<Text style={[styles.title, { color: colors.text }]}>About BigBrainParking</Text>
|
||||
</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.
|
||||
</Text>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
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 { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
|
|
@ -39,8 +40,11 @@ function policyMeta(p: SpacePolicy): { title: string; rate?: string; color?: str
|
|||
return { title, rate, color };
|
||||
}
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function MeterDetailScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { params } = useRoute<DetailRoute>();
|
||||
const z = params.zone;
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
|
@ -122,12 +126,7 @@ export function MeterDetailScreen() {
|
|||
|
||||
<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.',
|
||||
)
|
||||
}
|
||||
onPress={() => navigation.navigate('StartSession', { zone: z })}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: '#fff' }]}>Start parking session</Text>
|
||||
</TouchableOpacity>
|
||||
|
|
|
|||
321
app/src/screens/StartSessionScreen.tsx
Normal file
321
app/src/screens/StartSessionScreen.tsx
Normal 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 },
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue