v0.6.2: show and manage on-phone sessions offline and signed out
All checks were successful
build-apk / build (push) Successful in 9m58s
All checks were successful
build-apk / build (push) Successful in 9m58s
A city-map timer started without signing in did not appear under Sessions. The tab returned "Sign in to see your sessions" before rendering anything, so a local session could never show; and even signed in it only ever listed ParkSmarter's sessions. A local timer is the one kind that has no server copy, which made it the one kind the screen could not display. - Sessions now leads with "Tracking on this phone": the live countdown with +1 hour and End, working with no account and no network, because that is the only place the session exists. - Recent local sessions are kept in a small on-device history (50 max). Without it a local session vanished the instant it ended — there is no server to ask. Paid ParkSmarter sessions are excluded so they don't appear twice. - The ParkSmarter half is layered on top when signed in and can fail independently: offline it reports that and keeps the local half visible, rather than the whole tab going blank. It also no longer leaves an unhandled rejection when the fetch throws (it had try/finally but no catch). - The card's second button follows the notification's rule: +1 hour for a local timer, Extend -> purchase screen for a bought session, since only one of those can honestly add time. History is written in endActiveParking() before the record is dropped, which also covers expiry — syncActiveParking() routes a lapsed session through the same call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7fc92d24e4
commit
6af3dad762
5 changed files with 309 additions and 56 deletions
|
|
@ -73,6 +73,11 @@ offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing no
|
||||||
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
|
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
|
||||||
edits the local timer and says so.
|
edits the local timer and says so.
|
||||||
|
|
||||||
|
The **Sessions** tab shows and manages these under *Tracking on this phone* — add an hour,
|
||||||
|
end it, and see recent ones — with no account and no network, because that is the only
|
||||||
|
place they exist. ParkSmarter's own sessions are layered on top when you're signed in, and
|
||||||
|
failing to reach them (offline, or signed out) never hides the local half.
|
||||||
|
|
||||||
The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m
|
The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m
|
||||||
(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF).
|
(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF).
|
||||||
Because a few metres is the difference between two sides of a street, **Account → Align city
|
Because a few metres is the difference between two sides of a street, **Account → Align city
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.6.1",
|
"version": "0.6.2",
|
||||||
"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": 22,
|
"versionCode": 23,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from '@/features/notifications/reminderPrefs';
|
} from '@/features/notifications/reminderPrefs';
|
||||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||||
import type { ParkingArea } from '@/api/parkingAreas';
|
import type { ParkingArea } from '@/api/parkingAreas';
|
||||||
|
import { recordLocalSession } from './localHistory';
|
||||||
import {
|
import {
|
||||||
clearActiveParking,
|
clearActiveParking,
|
||||||
getActiveParking,
|
getActiveParking,
|
||||||
|
|
@ -255,6 +256,10 @@ export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise<void>
|
||||||
* keeps running at the meter whether or not the app is showing it.
|
* keeps running at the meter whether or not the app is showing it.
|
||||||
*/
|
*/
|
||||||
export async function endActiveParking(): Promise<void> {
|
export async function endActiveParking(): Promise<void> {
|
||||||
|
// Write it to history before dropping it. A local session has no server copy, so
|
||||||
|
// if it isn't recorded here it is simply gone.
|
||||||
|
const current = await getActiveParking();
|
||||||
|
if (current) await recordLocalSession(current);
|
||||||
await clearActiveParking();
|
await clearActiveParking();
|
||||||
await clearNotification();
|
await clearNotification();
|
||||||
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||||
|
|
|
||||||
81
app/src/features/session/localHistory.ts
Normal file
81
app/src/features/session/localHistory.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import type { AreaKind } from '@/api/parkingAreas';
|
||||||
|
import type { ActiveParking, ParkedSpot, ParkingKind } from './activeParkingStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History for the sessions ParkSmarter never sees.
|
||||||
|
*
|
||||||
|
* A city-map timer or a free check-in exists only on this phone, so if it isn't
|
||||||
|
* recorded here it vanishes the moment it ends — there is no server to ask. Paid
|
||||||
|
* ParkSmarter sessions are deliberately excluded: those already come back from the
|
||||||
|
* account, and storing them too would show every one of them twice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY = 'ps_local_session_history';
|
||||||
|
/** Enough to cover months of parking without letting the record grow forever. */
|
||||||
|
const MAX = 50;
|
||||||
|
|
||||||
|
export interface LocalSessionRecord {
|
||||||
|
/** Start time doubles as the id — there is only ever one session at a time. */
|
||||||
|
id: string;
|
||||||
|
kind: ParkingKind;
|
||||||
|
zoneName: string;
|
||||||
|
areaId?: string;
|
||||||
|
areaKind?: AreaKind;
|
||||||
|
color?: string;
|
||||||
|
legend?: string;
|
||||||
|
startMs: number;
|
||||||
|
/** When it was due to end. */
|
||||||
|
plannedEndMs: number;
|
||||||
|
/** When it actually ended. */
|
||||||
|
endedAtMs: number;
|
||||||
|
/** True when the user ended it before the clock ran out. */
|
||||||
|
endedEarly: boolean;
|
||||||
|
spot?: ParkedSpot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when ParkSmarter has no record of this session, so we must keep our own. */
|
||||||
|
export function isLocalOnly(p: ActiveParking): boolean {
|
||||||
|
return !p.transactionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocalHistory(): Promise<LocalSessionRecord[]> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(raw) as LocalSessionRecord[];
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a finished local session. No-op for anything ParkSmarter already has. */
|
||||||
|
export async function recordLocalSession(p: ActiveParking): Promise<void> {
|
||||||
|
if (!isLocalOnly(p)) return;
|
||||||
|
const endedAtMs = Date.now();
|
||||||
|
const record: LocalSessionRecord = {
|
||||||
|
id: String(p.startMs),
|
||||||
|
kind: p.kind,
|
||||||
|
zoneName: p.zoneName,
|
||||||
|
areaId: p.area?.id,
|
||||||
|
areaKind: p.area?.kind,
|
||||||
|
color: p.area?.color,
|
||||||
|
legend: p.area?.legend,
|
||||||
|
startMs: p.startMs,
|
||||||
|
plannedEndMs: p.endMs,
|
||||||
|
endedAtMs,
|
||||||
|
endedEarly: endedAtMs < p.endMs - 60_000, // a minute's slack for timer wake-up
|
||||||
|
spot: p.spot,
|
||||||
|
};
|
||||||
|
const list = await getLocalHistory();
|
||||||
|
// Guard against double-recording: ending can be driven from the notification and
|
||||||
|
// the screen at nearly the same moment.
|
||||||
|
const deduped = list.filter((r) => r.id !== record.id);
|
||||||
|
deduped.unshift(record);
|
||||||
|
await AsyncStorage.setItem(KEY, JSON.stringify(deduped.slice(0, MAX)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLocalHistory(): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(KEY);
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,72 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { ps } from '@/api/client';
|
import { ps } from '@/api/client';
|
||||||
import { useAuth } from '@/auth/AuthContext';
|
import { useAuth } from '@/auth/AuthContext';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
import { endActiveParking, extendAreaParking } from '@/features/session/activeParking';
|
||||||
|
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
|
||||||
|
import {
|
||||||
|
getLocalHistory,
|
||||||
|
isLocalOnly,
|
||||||
|
type LocalSessionRecord,
|
||||||
|
} from '@/features/session/localHistory';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
function fmtClock(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(ms: number): string {
|
||||||
|
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtRemaining(ms: number): string {
|
||||||
|
const mins = Math.max(0, Math.round(ms / 60_000));
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSpan(from: number, to: number): string {
|
||||||
|
const mins = Math.max(0, Math.round((to - from) / 60_000));
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sessions, in two halves that must not depend on each other.
|
||||||
|
*
|
||||||
|
* Anything tracked on this phone — a city-map timer, a free check-in — is shown
|
||||||
|
* and managed with no network and no account, because that is the only place it
|
||||||
|
* exists. ParkSmarter's own sessions are layered on top when signed in, and a
|
||||||
|
* failure to reach them (offline, or simply not logged in) must never hide the
|
||||||
|
* local half.
|
||||||
|
*/
|
||||||
export function SessionsScreen() {
|
export function SessionsScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { isAnonymous, requireLogin } = useAuth();
|
const { isAnonymous, requireLogin } = useAuth();
|
||||||
|
|
||||||
|
const [local, setLocal] = useState<ActiveParking | null>(null);
|
||||||
|
const [history, setHistory] = useState<LocalSessionRecord[]>([]);
|
||||||
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);
|
||||||
|
const [remoteError, setRemoteError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
/** On-device only. Never awaits the network, so it works offline and signed out. */
|
||||||
|
const loadLocal = useCallback(async () => {
|
||||||
|
const [a, h] = await Promise.all([getActiveParking(), getLocalHistory()]);
|
||||||
|
setLocal(a && a.endMs > Date.now() ? a : null);
|
||||||
|
setHistory(h);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadRemote = useCallback(async () => {
|
||||||
if (isAnonymous) return;
|
if (isAnonymous) return;
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
try {
|
||||||
const [a, p] = await Promise.all([
|
const [a, p] = await Promise.all([
|
||||||
ps.getActiveParkingSessions(),
|
ps.getActiveParkingSessions(),
|
||||||
|
|
@ -28,10 +74,22 @@ export function SessionsScreen() {
|
||||||
]);
|
]);
|
||||||
setActive(a.ParkingSession ?? []);
|
setActive(a.ParkingSession ?? []);
|
||||||
setPast(p.Session ?? []);
|
setPast(p.Session ?? []);
|
||||||
|
setRemoteError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Offline or the API is unhappy. Say so quietly and keep the local half.
|
||||||
|
setRemoteError(e?.serverMessage ?? e?.message ?? 'Could not reach ParkSmarter.');
|
||||||
|
}
|
||||||
|
}, [isAnonymous]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
await loadLocal();
|
||||||
|
await loadRemote();
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [isAnonymous]);
|
}, [loadLocal, loadRemote]);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|
@ -39,24 +97,14 @@ export function SessionsScreen() {
|
||||||
}, [load]),
|
}, [load]),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAnonymous) {
|
// Keep "time left" honest while the screen sits open.
|
||||||
return (
|
useEffect(() => {
|
||||||
<View style={[styles.center, { backgroundColor: colors.bg }]}>
|
if (!local) return;
|
||||||
<Text style={[styles.zone, { color: colors.text, marginBottom: 8 }]}>
|
const id = setInterval(() => void loadLocal(), 30_000);
|
||||||
Sign in to see your sessions
|
return () => clearInterval(id);
|
||||||
</Text>
|
}, [local, loadLocal]);
|
||||||
<Text style={[styles.empty, { color: colors.subtext, textAlign: 'center', marginBottom: 16 }]}>
|
|
||||||
Your active and past parking sessions live in your ParkSmarter account.
|
const s = styles;
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.card, { backgroundColor: colors.primary, paddingHorizontal: 28 }]}
|
|
||||||
onPress={requireLogin}
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|
@ -64,52 +112,166 @@ export function SessionsScreen() {
|
||||||
contentContainerStyle={{ padding: 16 }}
|
contentContainerStyle={{ padding: 16 }}
|
||||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
||||||
>
|
>
|
||||||
<Text style={[styles.header, { color: colors.text }]}>Active</Text>
|
<Text style={[s.header, { color: colors.text }]}>Tracking on this phone</Text>
|
||||||
{active.length === 0 ? (
|
{local ? (
|
||||||
<Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
<View style={[s.card, { backgroundColor: colors.primary + '22' }]}>
|
||||||
|
<View style={s.chipRow}>
|
||||||
|
{local.area ? <View style={[s.swatch, { backgroundColor: local.area.color }]} /> : null}
|
||||||
|
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{local.zoneName}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[s.big, { color: colors.text }]}>
|
||||||
|
{fmtRemaining(local.endMs - Date.now())} left
|
||||||
|
</Text>
|
||||||
|
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||||
|
{local.area?.legend ? `${local.area.legend} · ` : ''}
|
||||||
|
{local.kind === 'free' ? 'Free' : 'Paid'} until {fmtClock(local.endMs)}
|
||||||
|
{local.spot ? (local.spot.manual ? ' · pin placed by hand' : ' · pinned from GPS') : ''}
|
||||||
|
</Text>
|
||||||
|
<View style={s.row}>
|
||||||
|
{/* Same split as the notification's second button: a local timer can be
|
||||||
|
nudged for free, but a bought session can only be extended by buying
|
||||||
|
more, so that one goes to the purchase screen instead of lying. */}
|
||||||
|
{isLocalOnly(local) ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, { borderColor: colors.border }]}
|
||||||
|
onPress={async () => {
|
||||||
|
await extendAreaParking();
|
||||||
|
await loadLocal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '700' }}>+1 hour</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : local.zone ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btn, { borderColor: colors.border }]}
|
||||||
|
onPress={() => navigation.navigate('StartSession', { zone: local.zone! })}
|
||||||
|
>
|
||||||
|
<Text style={{ color: colors.text, fontWeight: '700' }}>Extend</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btnFilled, { backgroundColor: colors.danger }]}
|
||||||
|
onPress={async () => {
|
||||||
|
await endActiveParking();
|
||||||
|
await loadLocal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>End</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={[s.hint, { color: colors.subtext }]}>
|
||||||
|
{isLocalOnly(local)
|
||||||
|
? 'Works offline — this timer lives on your phone, not on a server.'
|
||||||
|
: 'Ending stops the countdown here. Time you bought keeps running at the meter.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
active.map((s, i) => (
|
<Text style={[s.empty, { color: colors.subtext }]}>
|
||||||
|
Nothing being tracked. Start one from the map — “Park here”, or tap a coloured block.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Recent on this phone</Text>
|
||||||
|
{history.map((r) => (
|
||||||
|
<View key={r.id} style={[s.card, { backgroundColor: colors.card }]}>
|
||||||
|
<View style={s.chipRow}>
|
||||||
|
{r.color ? <View style={[s.swatch, { backgroundColor: r.color }]} /> : null}
|
||||||
|
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{r.zoneName}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||||
|
{fmtDate(r.startMs)} · {fmtClock(r.startMs)}–{fmtClock(r.endedAtMs)} ·{' '}
|
||||||
|
{fmtSpan(r.startMs, r.endedAtMs)}
|
||||||
|
{r.endedEarly ? ' · ended early' : ' · ran out'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isAnonymous ? (
|
||||||
|
<View style={[s.card, { backgroundColor: colors.card, marginTop: 20 }]}>
|
||||||
|
<Text style={[s.zone, { color: colors.text }]}>Paid ParkSmarter sessions</Text>
|
||||||
|
<Text style={[s.meta, { color: colors.subtext, marginBottom: 10 }]}>
|
||||||
|
Sessions you bought live in your ParkSmarter account. Sign in to see them here —
|
||||||
|
everything above stays on this phone either way.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
|
||||||
|
onPress={requireLogin}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Active (ParkSmarter)</Text>
|
||||||
|
{remoteError ? (
|
||||||
|
<Text style={[s.empty, { color: colors.subtext }]}>{remoteError} Pull to retry.</Text>
|
||||||
|
) : active.length === 0 ? (
|
||||||
|
<Text style={[s.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||||
|
) : (
|
||||||
|
active.map((sess, i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={i}
|
key={i}
|
||||||
style={[styles.card, { backgroundColor: colors.primary + '22' }]}
|
style={[s.card, { backgroundColor: colors.primary + '22' }]}
|
||||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
|
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'active' })}
|
||||||
>
|
>
|
||||||
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
|
<Text style={[s.zone, { color: colors.text }]}>{sess.ZoneName ?? 'Session'}</Text>
|
||||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||||
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''} › tap for details
|
{sess.SpaceName ?? sess.Space ?? ''} · ends{' '}
|
||||||
|
{sess.EndTimeDisplay ?? sess.EndTime ?? ''} › tap for details
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={[styles.header, { color: colors.text, marginTop: 20 }]}>History</Text>
|
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>History (ParkSmarter)</Text>
|
||||||
{past.length === 0 ? (
|
{remoteError ? (
|
||||||
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
<Text style={[s.empty, { color: colors.subtext }]}>Unavailable offline.</Text>
|
||||||
|
) : past.length === 0 ? (
|
||||||
|
<Text style={[s.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||||
) : (
|
) : (
|
||||||
past.map((s, i) => (
|
past.map((sess, i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={i}
|
key={i}
|
||||||
style={[styles.card, { backgroundColor: colors.card }]}
|
style={[s.card, { backgroundColor: colors.card }]}
|
||||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
|
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'past' })}
|
||||||
>
|
>
|
||||||
<Text style={[styles.zone, { color: colors.text }]}>
|
<Text style={[s.zone, { color: colors.text }]}>
|
||||||
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
|
{sess.Description ?? sess.Zone ?? sess.ZoneName ?? 'Session'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} › tap for receipt
|
{sess.StartTime ?? ''} · {sess.Amount != null ? `$${sess.Amount}` : ''} › tap for
|
||||||
|
receipt
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
|
|
||||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||||
empty: { marginBottom: 8 },
|
empty: { marginBottom: 8 },
|
||||||
card: { borderRadius: 10, padding: 14, marginBottom: 10 },
|
card: { borderRadius: 10, padding: 14, marginBottom: 10, gap: 4 },
|
||||||
|
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
swatch: { width: 20, height: 11, borderRadius: 3 },
|
||||||
zone: { fontSize: 16, fontWeight: '600' },
|
zone: { fontSize: 16, fontWeight: '600' },
|
||||||
|
big: { fontSize: 26, fontWeight: '700' },
|
||||||
meta: { fontSize: 13, marginTop: 2 },
|
meta: { fontSize: 13, marginTop: 2 },
|
||||||
|
hint: { fontSize: 12, marginTop: 6 },
|
||||||
|
row: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||||
|
btn: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
btnFilled: { flex: 1, borderRadius: 10, paddingVertical: 12, alignItems: 'center' },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue