Initial commit: parksmarter-client + BigBrainParking app
Reverse-engineered ParkSmarter API client (TypeScript, live-verified) plus a de-Googled Expo/React Native app for GrapheneOS: MapLibre meter map with GPS, VisionCamera QR kiosk scanning with save/share, local session-expiry reminders, UnifiedPush wiring, and Gitea CI to publish signed APKs for Obtainium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1dede50995
39 changed files with 3671 additions and 0 deletions
84
app/src/screens/FavoritesScreen.tsx
Normal file
84
app/src/screens/FavoritesScreen.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { FlatList, 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 {
|
||||
listKiosks,
|
||||
removeKiosk,
|
||||
shareKiosk,
|
||||
type SavedKiosk,
|
||||
} from '@/features/favorites/favoritesStore';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function FavoritesScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const [items, setItems] = useState<SavedKiosk[]>([]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void listKiosks().then(setItems);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const open = async (k: SavedKiosk) => {
|
||||
// Re-fetch the live zone (rates/occupancy change) before showing detail.
|
||||
const res = k.scannerCode
|
||||
? await ps.getMetersByScannerCode(k.scannerCode)
|
||||
: k.terminalSerNo
|
||||
? await ps.getMetersBySerialNumber(k.terminalSerNo)
|
||||
: null;
|
||||
const zone = res?.Zones?.[0];
|
||||
if (zone) navigation.navigate('MeterDetail', { zone });
|
||||
};
|
||||
|
||||
const remove = async (k: SavedKiosk) => setItems(await removeKiosk(k.key));
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
data={items}
|
||||
keyExtractor={(k) => k.key}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.empty}>
|
||||
No saved kiosks yet. Scan a kiosk QR and tap “Save kiosk”.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<TouchableOpacity style={{ flex: 1 }} onPress={() => open(item)}>
|
||||
<Text style={styles.name}>{item.zoneName ?? item.key}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{item.scannerCode ? `Code ${item.scannerCode}` : `Serial ${item.terminalSerNo}`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.action} onPress={() => shareKiosk(item)}>
|
||||
<Text style={styles.actionText}>Share</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.action} onPress={() => remove(item)}>
|
||||
<Text style={[styles.actionText, { color: '#c0392b' }]}>Remove</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
empty: { color: '#888', textAlign: 'center', marginTop: 48 },
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f4f4f4',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
marginBottom: 10,
|
||||
gap: 10,
|
||||
},
|
||||
name: { fontSize: 16, fontWeight: '600' },
|
||||
meta: { color: '#777', fontSize: 12, marginTop: 2 },
|
||||
action: { paddingHorizontal: 6, paddingVertical: 4 },
|
||||
actionText: { color: '#1e6f5c', fontWeight: '600' },
|
||||
});
|
||||
100
app/src/screens/LoginScreen.tsx
Normal file
100
app/src/screens/LoginScreen.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
|
||||
export function LoginScreen() {
|
||||
const { login, error } = useAuth();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await login(phone.replace(/\D/g, ''), password);
|
||||
} catch {
|
||||
// error surfaced via context
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<Text style={styles.title}>BigBrainParking</Text>
|
||||
<Text style={styles.subtitle}>Sign in with your phone number</Text>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Phone number"
|
||||
keyboardType="phone-pad"
|
||||
autoComplete="tel"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, busy && styles.buttonDisabled]}
|
||||
disabled={busy}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Sign In</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: 12 }} />
|
||||
<Text style={styles.hint}>
|
||||
Forgot your password? Use the official app or a reset SMS — this build reuses
|
||||
the same account.
|
||||
</Text>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 24, justifyContent: 'center' },
|
||||
title: { fontSize: 32, fontWeight: '700', textAlign: 'center' },
|
||||
subtitle: { fontSize: 15, color: '#666', textAlign: 'center', marginBottom: 24 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
error: { color: '#c0392b', marginBottom: 12 },
|
||||
button: {
|
||||
backgroundColor: '#1e6f5c',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonDisabled: { opacity: 0.6 },
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
hint: { color: '#888', fontSize: 12, textAlign: 'center' },
|
||||
});
|
||||
155
app/src/screens/MapScreen.tsx
Normal file
155
app/src/screens/MapScreen.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import {
|
||||
MapView,
|
||||
Camera,
|
||||
MarkerView,
|
||||
UserLocation,
|
||||
} from '@maplibre/maplibre-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useLocation, getLastKnownSavedLocation, type Coords } from '@/features/location/useLocation';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
const MAP_STYLE =
|
||||
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
|
||||
'https://tiles.openfreemap.org/styles/liberty';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function MapScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { coords, refresh } = useLocation();
|
||||
const [zones, setZones] = useState<Zone[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [center, setCenter] = useState<Coords | null>(null);
|
||||
|
||||
const loadMeters = useCallback(async (c: Coords) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
|
||||
setZones((res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null));
|
||||
setCenter(c);
|
||||
} catch {
|
||||
setZones([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-load meters once we have a current fix.
|
||||
useEffect(() => {
|
||||
if (coords && !center) void loadMeters(coords);
|
||||
}, [coords, center, loadMeters]);
|
||||
|
||||
const searchHere = async () => {
|
||||
const c = coords ?? (await refresh());
|
||||
if (c) await loadMeters(c);
|
||||
};
|
||||
|
||||
const searchLastKnown = async () => {
|
||||
const last = (await getLastKnownSavedLocation()) ?? coords;
|
||||
if (last) await loadMeters(last);
|
||||
};
|
||||
|
||||
const initial = center ?? coords;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* mapStyle / props are version-sensitive in @maplibre/maplibre-react-native */}
|
||||
<MapView style={styles.map} mapStyle={MAP_STYLE}>
|
||||
{initial ? (
|
||||
<Camera
|
||||
zoomLevel={15}
|
||||
centerCoordinate={[initial.longitude, initial.latitude]}
|
||||
animationDuration={0}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<UserLocation visible renderMode="native" />
|
||||
|
||||
{zones.map((z) => (
|
||||
<MarkerView
|
||||
key={String(z.ZoneId ?? z.ScannerCode ?? z.TerminalSerNo)}
|
||||
coordinate={[z.Long as number, z.Lat as number]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('MeterDetail', { zone: z })}
|
||||
style={[
|
||||
styles.marker,
|
||||
{
|
||||
backgroundColor: z.BackgroundColor ?? '#1e6f5c',
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.markerText} numberOfLines={1}>
|
||||
{z.ZoneName ?? 'Meter'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</MarkerView>
|
||||
))}
|
||||
</MapView>
|
||||
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity style={styles.pillButton} onPress={searchHere}>
|
||||
<Text style={styles.pillText}>Search here</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pillButton} onPress={searchLastKnown}>
|
||||
<Text style={styles.pillText}>Near last location</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.countBadge}>
|
||||
<Text style={styles.countText}>{zones.length} meters</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
map: { flex: 1 },
|
||||
marker: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
maxWidth: 140,
|
||||
},
|
||||
markerText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
pillButton: {
|
||||
backgroundColor: '#1e6f5c',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
pillText: { color: '#fff', fontWeight: '600' },
|
||||
loading: { position: 'absolute', top: 16, right: 16 },
|
||||
countBadge: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
countText: { color: '#fff', fontSize: 12 },
|
||||
});
|
||||
112
app/src/screens/MeterDetailScreen.tsx
Normal file
112
app/src/screens/MeterDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { useRoute } from '@react-navigation/native';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||
|
||||
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
|
||||
|
||||
export function MeterDetailScreen() {
|
||||
const { params } = useRoute<DetailRoute>();
|
||||
const z = params.zone;
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
const onSave = async () => {
|
||||
await saveKiosk(toSavedKiosk(z));
|
||||
setSaved(true);
|
||||
Alert.alert('Saved', `${z.ZoneName ?? 'Kiosk'} added to your saved kiosks.`);
|
||||
};
|
||||
|
||||
const firstSpace = z.Spaces?.[0];
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={{ padding: 16 }}>
|
||||
<Text style={styles.title}>{z.ZoneName ?? 'Parking meter'}</Text>
|
||||
{z.ZoneLocation ? <Text style={styles.sub}>{z.ZoneLocation}</Text> : null}
|
||||
|
||||
<View style={styles.row}>
|
||||
<Field label="Scanner code" value={z.ScannerCode} />
|
||||
<Field label="Serial" value={z.TerminalSerNo} />
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Field label="Rate" value={z.Rate != null ? `$${z.Rate}` : undefined} />
|
||||
<Field
|
||||
label="Max time"
|
||||
value={z.MaxTime != null ? `${z.MaxTime} min` : undefined}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Field
|
||||
label="Occupancy"
|
||||
value={z.PercentageFull != null ? `${z.PercentageFull}% full` : undefined}
|
||||
/>
|
||||
<Field label="Spaces" value={z.Spaces ? String(z.Spaces.length) : undefined} />
|
||||
</View>
|
||||
|
||||
{firstSpace?.Policies?.length ? (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Rate policies</Text>
|
||||
{firstSpace.Policies.slice(0, 6).map((p, i) => (
|
||||
<Text key={i} style={styles.policy}>
|
||||
• {p.DisplayString ?? p.RateType ?? 'Policy'}
|
||||
{p.Rate != null ? ` — $${p.Rate}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, saved && styles.buttonSaved]}
|
||||
onPress={onSave}
|
||||
disabled={saved}
|
||||
>
|
||||
<Text style={styles.buttonText}>{saved ? 'Saved ✓' : 'Save kiosk'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.buttonPrimary]}
|
||||
onPress={() =>
|
||||
Alert.alert(
|
||||
'Start session',
|
||||
'Starting a paid session is a real charge — this flow (vehicle + duration + card selection) is wired to postStartParkingSession and will be enabled after review.',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text style={styles.buttonText}>Start parking session</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<Text style={styles.fieldValue}>{value ?? '—'}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
title: { fontSize: 24, fontWeight: '700' },
|
||||
sub: { color: '#666', marginBottom: 12 },
|
||||
row: { flexDirection: 'row', gap: 12, marginTop: 8 },
|
||||
field: { flex: 1, backgroundColor: '#f2f2f2', borderRadius: 10, padding: 12 },
|
||||
fieldLabel: { fontSize: 12, color: '#888' },
|
||||
fieldValue: { fontSize: 16, fontWeight: '600', marginTop: 2 },
|
||||
card: { backgroundColor: '#f7f7f7', borderRadius: 10, padding: 14, marginTop: 16 },
|
||||
cardTitle: { fontWeight: '700', marginBottom: 6 },
|
||||
policy: { color: '#444', marginBottom: 2 },
|
||||
button: {
|
||||
marginTop: 16,
|
||||
backgroundColor: '#555',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonPrimary: { backgroundColor: '#1e6f5c' },
|
||||
buttonSaved: { backgroundColor: '#2e7d32' },
|
||||
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||
});
|
||||
120
app/src/screens/ScanScreen.tsx
Normal file
120
app/src/screens/ScanScreen.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import {
|
||||
Camera,
|
||||
useCameraDevice,
|
||||
useCameraPermission,
|
||||
useCodeScanner,
|
||||
} from 'react-native-vision-camera';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
/**
|
||||
* Scan a kiosk QR code (its `ScannerCode`), look the meter up via the API, and
|
||||
* jump to the detail screen where it can be saved/shared.
|
||||
* Uses on-device VisionCamera code scanning — no Google Play Services.
|
||||
*/
|
||||
export function ScanScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { hasPermission, requestPermission } = useCameraPermission();
|
||||
const device = useCameraDevice('back');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const lock = useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasPermission) void requestPermission();
|
||||
}, [hasPermission, requestPermission]);
|
||||
|
||||
const onScanned = useCallback(
|
||||
async (code: string) => {
|
||||
if (lock.current) return;
|
||||
lock.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await ps.getMetersByScannerCode(code);
|
||||
const zone = res.Zones?.[0];
|
||||
if (zone) navigation.navigate('MeterDetail', { zone });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setTimeout(() => (lock.current = false), 1500);
|
||||
}
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
const codeScanner = useCodeScanner({
|
||||
codeTypes: ['qr', 'ean-13', 'code-128'],
|
||||
onCodeScanned: (codes) => {
|
||||
const value = codes[0]?.value;
|
||||
if (value) void onScanned(value);
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.msg}>Camera permission is needed to scan kiosks.</Text>
|
||||
<TouchableOpacity style={styles.button} onPress={requestPermission}>
|
||||
<Text style={styles.buttonText}>Grant camera access</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!device) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.msg}>No camera available.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Camera style={StyleSheet.absoluteFill} device={device} isActive codeScanner={codeScanner} />
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.reticle} />
|
||||
<Text style={styles.hint}>Point at the QR code on the parking kiosk</Text>
|
||||
</View>
|
||||
{busy ? (
|
||||
<View style={styles.busy}>
|
||||
<ActivityIndicator color="#fff" />
|
||||
<Text style={styles.busyText}>Looking up meter…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#000' },
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
|
||||
msg: { fontSize: 16, textAlign: 'center', marginBottom: 16 },
|
||||
overlay: { ...StyleSheet.absoluteFillObject, alignItems: 'center', justifyContent: 'center' },
|
||||
reticle: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderWidth: 3,
|
||||
borderColor: '#fff',
|
||||
borderRadius: 16,
|
||||
},
|
||||
hint: { color: '#fff', marginTop: 16, fontSize: 14 },
|
||||
button: { backgroundColor: '#1e6f5c', padding: 14, borderRadius: 10 },
|
||||
buttonText: { color: '#fff', fontWeight: '600' },
|
||||
busy: {
|
||||
position: 'absolute',
|
||||
bottom: 40,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
busyText: { color: '#fff' },
|
||||
});
|
||||
75
app/src/screens/SessionsScreen.tsx
Normal file
75
app/src/screens/SessionsScreen.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { ps } from '@/api/client';
|
||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||
|
||||
export function SessionsScreen() {
|
||||
const [active, setActive] = useState<ActiveSession[]>([]);
|
||||
const [past, setPast] = useState<PastSession[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
ps.getActiveParkingSessions(),
|
||||
ps.getPastParkingSessions({ currentPage: 1, pageSize: 20 }),
|
||||
]);
|
||||
setActive(a.ParkingSession ?? []);
|
||||
setPast(p.Session ?? []);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void load();
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 16 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
||||
>
|
||||
<Text style={styles.header}>Active</Text>
|
||||
{active.length === 0 ? (
|
||||
<Text style={styles.empty}>No active sessions.</Text>
|
||||
) : (
|
||||
active.map((s, i) => (
|
||||
<View key={i} style={[styles.card, styles.activeCard]}>
|
||||
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
<Text style={[styles.header, { marginTop: 20 }]}>History</Text>
|
||||
{past.length === 0 ? (
|
||||
<Text style={styles.empty}>No past sessions.</Text>
|
||||
) : (
|
||||
past.map((s, i) => (
|
||||
<View key={i} style={styles.card}>
|
||||
<Text style={styles.zone}>{s.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={styles.meta}>
|
||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
empty: { color: '#888', marginBottom: 8 },
|
||||
card: { backgroundColor: '#f4f4f4', borderRadius: 10, padding: 14, marginBottom: 10 },
|
||||
activeCard: { backgroundColor: '#e8f5e9' },
|
||||
zone: { fontSize: 16, fontWeight: '600' },
|
||||
meta: { color: '#777', fontSize: 13, marginTop: 2 },
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue