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>
155 lines
4.6 KiB
TypeScript
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 },
|
|
});
|