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:
parent
42a96b0cff
commit
4cb5478737
5 changed files with 243 additions and 25 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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' }} />
|
||||||
|
|
|
||||||
159
app/src/screens/SessionDetailScreen.tsx
Normal file
159
app/src/screens/SessionDetailScreen.tsx
Normal 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('Couldn’t 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' },
|
||||||
|
});
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue