BigBrainParking/app/src/screens/MapScreen.tsx
Hank 1dede50995 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>
2026-07-06 08:33:47 -07:00

155 lines
4.6 KiB
TypeScript

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