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", "name": "BigBrainParking",
"slug": "bigbrainparking", "slug": "bigbrainparking",
"scheme": "bigbrainparking", "scheme": "bigbrainparking",
"version": "0.1.7", "version": "0.1.8",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"android": { "android": {
"package": "top.mowden.bigbrainparking", "package": "top.mowden.bigbrainparking",
"versionCode": 7, "versionCode": 8,
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",

View file

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

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

View file

@ -504,17 +504,34 @@ export interface ActiveSessionsResponse {
[key: string]: unknown; [key: string]: unknown;
} }
/** UNCONFIRMED element shape (no past sessions on the test account). */ /** CONFIRMED via a live DL-zone session (fields observed on GET /api/Session). */
export interface PastSession { export interface PastSession {
TransactionID?: number | string; TransactionID?: number | string;
ZoneName?: string; Zone?: string;
ZoneID?: number | string;
Space?: string;
SpaceID?: number | string;
StartTime?: string; StartTime?: string;
EndTime?: string; EndTime?: string;
TimePurchased?: number | string;
VehicleNumber?: string;
Amount?: number | string; Amount?: number | string;
PaymentType?: string; TransactionFee?: number | string;
CardFirstSix?: string;
CardLastFour?: string;
PaymentDisplay?: string; PaymentDisplay?: string;
VehiclePlate?: string; CustomerID?: number | string;
IsPaid?: boolean; CustomerName?: string;
City?: string;
Description?: string;
MeterTypeId?: number;
Lat?: number | string;
Long?: number | string;
IsFavorite?: boolean;
FavoriteID?: number | string;
Logo?: string;
/** Legacy/alt field some views used; prefer `Zone`. */
ZoneName?: string;
[key: string]: unknown; [key: string]: unknown;
} }
@ -534,21 +551,41 @@ export interface PastSessionsParams {
/* Receipts */ /* Receipts */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** CONFIRMED via a live DL-zone receipt (GET /api/ParkingReceipt). */
export interface ParkingReceipt { export interface ParkingReceipt {
TransactionID?: number | string; TransactionID?: number | string;
ZoneName?: string; CustomerID?: number | string;
CustomerName?: string;
Space?: string;
SpaceID?: number | string;
MeterNumber?: string;
MeterTypeId?: number;
StartTime?: string; StartTime?: string;
EndTime?: string; EndTime?: string;
Amount?: number | string;
Total?: number | string;
TotalCost?: number | string;
TransactionFee?: number | string;
PaymentType?: string; PaymentType?: string;
VehiclePlate?: string; PaymentDisplay?: string;
CardType?: string | null;
/** Masked card, e.g. "****1234". */
CC?: string;
AuthCode?: string;
VehicleID?: number | string;
Vehicle?: string;
/** Display strings, e.g. "$0.10". */
Amount?: string;
AmountCharged?: string;
TransactionFee?: string;
Total?: string;
/** Numeric equivalents. */
AmountValue?: number;
TransactionFeeValue?: number;
TotalValue?: number;
Logo?: string;
BackgroundColor?: string;
ForegroundColor?: string;
ZoneName?: string;
[key: string]: unknown; [key: string]: unknown;
} }
/** UNCONFIRMED (no receipts on the test account) — field names from static analysis. */
export interface ReceiptResponse { export interface ReceiptResponse {
ParkingReceipt?: ParkingReceipt; ParkingReceipt?: ParkingReceipt;
Response?: ResponseEnvelope; Response?: ResponseEnvelope;