v0.1.8: tap a session for full detail + receipt

- Sessions list items are now tappable -> SessionDetailScreen.
- Past sessions fetch the full receipt (GET /api/ParkingReceipt): vehicle,
  start/end, time purchased, parking/fee/total, payment (MASTERCARD ••1234),
  auth code, meter, city, transaction id. Adds an "Email me this receipt" action.
- Active sessions show zone/space/start/ends/time-remaining.
- Confirm PastSession + ParkingReceipt types from a live DL-zone session (were
  UNCONFIRMED); fix past-session card to show Zone/Description, not blank.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 18:38:39 -07:00
parent 42a96b0cff
commit 4cb5478737
5 changed files with 243 additions and 25 deletions

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
"version": "0.1.7",
"version": "0.1.8",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
"versionCode": 7,
"versionCode": 8,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",

View file

@ -21,14 +21,16 @@ import { VehiclesScreen } from '@/screens/VehiclesScreen';
import { PaymentMethodsScreen } from '@/screens/PaymentMethodsScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { StartSessionScreen } from '@/screens/StartSessionScreen';
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
import { useTheme } from '@/theme/ThemeContext';
import type { Zone } from 'parksmarter-client';
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
export type RootStackParamList = {
Tabs: undefined;
MeterDetail: { zone: Zone };
StartSession: { zone: Zone };
SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' };
About: undefined;
Profile: undefined;
Vehicles: undefined;
@ -120,6 +122,11 @@ export function RootNavigator() {
component={StartSessionScreen}
options={{ title: 'Start session' }}
/>
<Stack.Screen
name="SessionDetail"
component={SessionDetailScreen}
options={{ title: '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' }} />

View file

@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import type { RouteProp } from '@react-navigation/native';
import { useRoute } from '@react-navigation/native';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { ParkingReceipt } from 'parksmarter-client';
type DetailRoute = RouteProp<RootStackParamList, 'SessionDetail'>;
const show = (v: unknown) => (v == null || v === '' ? '—' : String(v));
const dollars = (v: unknown) => (v == null || v === '' ? undefined : `$${v}`);
export function SessionDetailScreen() {
const { colors } = useTheme();
const { session, kind } = useRoute<DetailRoute>().params;
const s = session as Record<string, any>;
const tid = s.TransactionID;
const [receipt, setReceipt] = useState<ParkingReceipt | null>(null);
const [loading, setLoading] = useState(kind === 'past' && tid != null);
const [emailing, setEmailing] = useState(false);
// Past sessions have a full receipt (auth code, payment, amounts) — fetch it.
useEffect(() => {
if (kind !== 'past' || tid == null) return;
(async () => {
try {
const r = await ps.getParkingReceipt(tid);
if (r?.ParkingReceipt && (r as any).Response?.Status !== 'Error') {
setReceipt(r.ParkingReceipt);
}
} catch (e: any) {
logLine(`[RECEIPT] fetch failed tid=${tid}: ${e?.serverMessage ?? e?.message ?? e}`);
} finally {
setLoading(false);
}
})();
}, [kind, tid]);
const emailReceipt = async () => {
if (tid == null) return;
setEmailing(true);
try {
await ps.emailParkingReceipt(tid);
Alert.alert('Receipt sent', 'Emailed to your account address.');
} catch (e: any) {
Alert.alert('Couldnt email receipt', e?.serverMessage ?? e?.message ?? 'Please try again.');
} finally {
setEmailing(false);
}
};
const r = receipt;
const rows: Array<[string, string | undefined]> =
kind === 'past'
? [
['Vehicle', r?.Vehicle ?? s.VehicleNumber],
['Zone', s.Description ?? s.Zone ?? r?.MeterNumber],
['Space', r?.Space ?? s.Space],
['Started', r?.StartTime ?? s.StartTime],
['Ended', r?.EndTime ?? s.EndTime],
['Time purchased', s.TimePurchased ? `${s.TimePurchased} min` : undefined],
['Parking', r?.Amount ?? dollars(s.Amount)],
['Fee', r?.TransactionFee ?? dollars(s.TransactionFee)],
['Total', r?.Total],
[
'Payment',
[r?.PaymentDisplay ?? s.PaymentDisplay, r?.CC ?? (s.CardLastFour ? `••${s.CardLastFour}` : '')]
.filter(Boolean)
.join(' ') || undefined,
],
['Auth code', r?.AuthCode],
['City', s.City ?? r?.CustomerName],
['Transaction', tid != null ? String(tid) : undefined],
]
: [
['Vehicle', s.VehiclePlate ?? s.VehicleNumber],
['Zone', s.ZoneName ?? s.Zone],
['Space', s.SpaceName ?? s.Space],
['Started', s.StartTimeDisplay ?? s.StartTime],
['Ends', s.EndTimeDisplay ?? s.EndTime],
['Time remaining', s.TimeRemaining != null ? `${s.TimeRemaining} min` : undefined],
['Amount', dollars(s.Amount)],
];
if (loading) {
return (
<View style={[styles.center, { backgroundColor: colors.bg }]}>
<ActivityIndicator color={colors.primary} />
</View>
);
}
const title = s.Description ?? s.ZoneName ?? s.Zone ?? r?.MeterNumber ?? 'Session';
const visible = rows.filter(([, v]) => v != null && v !== '');
return (
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
{kind === 'active' ? (
<View style={[styles.badge, { backgroundColor: colors.primary + '22' }]}>
<Text style={{ color: colors.primary, fontWeight: '700' }}>Active session</Text>
</View>
) : null}
<View style={[styles.card, { backgroundColor: colors.card }]}>
{visible.map(([label, value], i) => (
<View
key={label}
style={[styles.row, i === visible.length - 1 && { borderBottomWidth: 0 }]}
>
<Text style={{ color: colors.subtext }}>{label}</Text>
<Text style={[styles.value, { color: colors.text }]}>{show(value)}</Text>
</View>
))}
</View>
{kind === 'past' && tid != null ? (
<TouchableOpacity
style={[styles.btn, { borderColor: colors.primary, opacity: emailing ? 0.6 : 1 }]}
disabled={emailing}
onPress={emailReceipt}
>
<Text style={{ color: colors.primary, fontWeight: '700' }}>
{emailing ? 'Sending…' : 'Email me this receipt'}
</Text>
</TouchableOpacity>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
title: { fontSize: 22, fontWeight: '700', marginBottom: 12 },
badge: { alignSelf: 'flex-start', borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginBottom: 12 },
card: { borderRadius: 12, padding: 4, paddingHorizontal: 14 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingVertical: 10,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#8883',
},
value: { fontWeight: '600', flexShrink: 1, textAlign: 'right', marginLeft: 12 },
btn: { marginTop: 16, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' },
});

View file

@ -1,12 +1,17 @@
import React, { useCallback, useState } from 'react';
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { ps } from '@/api/client';
import { useTheme } from '@/theme/ThemeContext';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { ActiveSession, PastSession } from 'parksmarter-client';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function SessionsScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const [active, setActive] = useState<ActiveSession[]>([]);
const [past, setPast] = useState<PastSession[]>([]);
const [refreshing, setRefreshing] = useState(false);
@ -42,12 +47,16 @@ export function SessionsScreen() {
<Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
) : (
active.map((s, i) => (
<View key={i} style={[styles.card, { backgroundColor: colors.primary + '22' }]}>
<TouchableOpacity
key={i}
style={[styles.card, { backgroundColor: colors.primary + '22' }]}
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
>
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''}
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''} tap for details
</Text>
</View>
</TouchableOpacity>
))
)}
@ -56,12 +65,18 @@ export function SessionsScreen() {
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
) : (
past.map((s, i) => (
<View key={i} style={[styles.card, { backgroundColor: colors.card }]}>
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
<Text style={[styles.meta, { color: colors.subtext }]}>
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''}
<TouchableOpacity
key={i}
style={[styles.card, { backgroundColor: colors.card }]}
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
>
<Text style={[styles.zone, { color: colors.text }]}>
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
</Text>
</View>
<Text style={[styles.meta, { color: colors.subtext }]}>
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} tap for receipt
</Text>
</TouchableOpacity>
))
)}
</ScrollView>