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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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' }} />
|
||||
|
|
|
|||
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 { 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue