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:
Hank 2026-07-06 08:33:47 -07:00
commit 1dede50995
39 changed files with 3671 additions and 0 deletions

View 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' },
});

View 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' },
});

View 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 },
});

View 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 },
});

View 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' },
});

View 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 },
});