Map: search the viewport, add My Location + status feedback; tab bar icons

- "Search this area" now searches the map's current center (getCenter), so it
  works with no GPS fix — pan anywhere and search. Fixes "search here does nothing".
- "My location" recenters the camera on the GPS fix and searches there.
- Status bar shows real feedback (N meters / none found / errors) instead of silent.
- Add bottom tab icons via @expo/vector-icons (map/qr/star/time).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-06 09:35:46 -07:00
parent d3d81a9de1
commit b31cb78592
2 changed files with 127 additions and 58 deletions

View file

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native'; import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { NavigationContainer, useNavigation } from '@react-navigation/native'; import { NavigationContainer, useNavigation } from '@react-navigation/native';
import { import {
createNativeStackNavigator, createNativeStackNavigator,
@ -41,10 +42,24 @@ function AboutButton() {
); );
} }
const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
Map: 'map',
Scan: 'qr-code',
Favorites: 'star',
Sessions: 'time',
};
function Tabs() { function Tabs() {
return ( return (
<Tab.Navigator <Tab.Navigator
screenOptions={{ headerShown: true, headerRight: () => <AboutButton /> }} screenOptions={({ route }) => ({
headerShown: true,
headerRight: () => <AboutButton />,
tabBarActiveTintColor: '#1e6f5c',
tabBarIcon: ({ color, size }) => (
<Ionicons name={TAB_ICONS[route.name]} size={size} color={color} />
),
})}
> >
<Tab.Screen name="Map" component={MapScreen} /> <Tab.Screen name="Map" component={MapScreen} />
<Tab.Screen name="Scan" component={ScanScreen} /> <Tab.Screen name="Scan" component={ScanScreen} />

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import { import {
@ -18,54 +18,105 @@ const MAP_STYLE =
(Constants.expoConfig?.extra?.mapStyleUrl as string) ?? (Constants.expoConfig?.extra?.mapStyleUrl as string) ??
'https://tiles.openfreemap.org/styles/liberty'; 'https://tiles.openfreemap.org/styles/liberty';
// Fallback view when we have no GPS and no cached location (continental US).
const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 };
const DEFAULT_ZOOM = 4;
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
export function MapScreen() { export function MapScreen() {
const navigation = useNavigation<Nav>(); const navigation = useNavigation<Nav>();
const { coords, refresh } = useLocation(); const { coords, refresh } = useLocation();
const [zones, setZones] = useState<Zone[]>([]); const [zones, setZones] = useState<Zone[]>([]);
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [center, setCenter] = useState<Coords | null>(null); const [initialCenter, setInitialCenter] = useState<Coords | null>(null);
const loadMeters = useCallback(async (c: Coords) => { const mapRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
// Seed the initial camera from a cached location so we don't strand at 0,0.
useEffect(() => {
(async () => {
const last = await getLastKnownSavedLocation();
setInitialCenter(last ?? coords ?? DEFAULT_CENTER);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const searchAt = useCallback(async (c: Coords, label: string) => {
setLoading(true); setLoading(true);
setStatus(`Searching ${label}`);
try { try {
const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude }); const res = await ps.getMetersByLocation({ latitude: c.latitude, longitude: c.longitude });
setZones((res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null)); const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
setCenter(c); setZones(found);
} catch { setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
} catch (e: any) {
setZones([]); setZones([]);
setStatus(
e?.status === 401
? 'Session expired — sign in again.'
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
// Auto-load meters once we have a current fix. // Search whatever the map is currently centered on (works with no GPS).
useEffect(() => { const searchThisArea = async () => {
if (coords && !center) void loadMeters(coords); try {
}, [coords, center, loadMeters]); const center = await mapRef.current?.getCenter?.(); // [lng, lat]
if (center && center.length === 2) {
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
return;
}
} catch {
/* fall through */
}
if (coords) await searchAt(coords, 'this area');
};
const searchHere = async () => { // Recenter on the live GPS fix (if available) and search there.
const goToMyLocation = async () => {
const c = coords ?? (await refresh()); const c = coords ?? (await refresh());
if (c) await loadMeters(c); if (!c) {
setStatus('No location available (GPS/location may be off).');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [c.longitude, c.latitude],
zoomLevel: 15,
animationDuration: 500,
});
await searchAt(c, 'you');
}; };
const searchLastKnown = async () => { const searchLastKnown = async () => {
const last = (await getLastKnownSavedLocation()) ?? coords; const last = (await getLastKnownSavedLocation()) ?? coords;
if (last) await loadMeters(last); if (!last) {
setStatus('No saved location yet — enable location once to cache it.');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [last.longitude, last.latitude],
zoomLevel: 15,
animationDuration: 500,
});
await searchAt(last, 'last location');
}; };
const initial = center ?? coords;
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* mapStyle / props are version-sensitive in @maplibre/maplibre-react-native */} <MapView ref={mapRef} style={styles.map} mapStyle={MAP_STYLE}>
<MapView style={styles.map} mapStyle={MAP_STYLE}> {initialCenter ? (
{initial ? (
<Camera <Camera
zoomLevel={15} ref={cameraRef}
centerCoordinate={[initial.longitude, initial.latitude]} defaultSettings={{
animationDuration={0} centerCoordinate: [initialCenter.longitude, initialCenter.latitude],
zoomLevel: initialCenter === DEFAULT_CENTER ? DEFAULT_ZOOM : 14,
}}
/> />
) : null} ) : null}
@ -78,13 +129,7 @@ export function MapScreen() {
> >
<TouchableOpacity <TouchableOpacity
onPress={() => navigation.navigate('MeterDetail', { zone: z })} onPress={() => navigation.navigate('MeterDetail', { zone: z })}
style={[ style={[styles.marker, { backgroundColor: z.BackgroundColor ?? '#1e6f5c' }]}
styles.marker,
{
backgroundColor: z.BackgroundColor ?? '#1e6f5c',
borderColor: '#ffffff',
},
]}
> >
<Text style={styles.markerText} numberOfLines={1}> <Text style={styles.markerText} numberOfLines={1}>
{z.ZoneName ?? 'Meter'} {z.ZoneName ?? 'Meter'}
@ -94,24 +139,24 @@ export function MapScreen() {
))} ))}
</MapView> </MapView>
<View style={styles.controls}> <View style={styles.statusBar}>
<TouchableOpacity style={styles.pillButton} onPress={searchHere}> {loading ? <ActivityIndicator color="#fff" style={{ marginRight: 8 }} /> : null}
<Text style={styles.pillText}>Search here</Text> <Text style={styles.statusText} numberOfLines={1}>
</TouchableOpacity> {status}
<TouchableOpacity style={styles.pillButton} onPress={searchLastKnown}> </Text>
<Text style={styles.pillText}>Near last location</Text>
</TouchableOpacity>
</View> </View>
{loading ? ( <View style={styles.controls}>
<View style={styles.loading}> <TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
<ActivityIndicator /> <Text style={styles.pillText}>Search this area</Text>
</View> </TouchableOpacity>
) : ( <TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
<View style={styles.countBadge}> <Text style={styles.pillText}>My location</Text>
<Text style={styles.countText}>{zones.length} meters</Text> </TouchableOpacity>
</View> <TouchableOpacity style={styles.pill} onPress={searchLastKnown}>
)} <Text style={styles.pillText}>Last</Text>
</TouchableOpacity>
</View>
</View> </View>
); );
} }
@ -124,32 +169,41 @@ const styles = StyleSheet.create({
paddingVertical: 4, paddingVertical: 4,
borderRadius: 8, borderRadius: 8,
borderWidth: 2, borderWidth: 2,
borderColor: '#fff',
maxWidth: 140, maxWidth: 140,
}, },
markerText: { color: '#fff', fontSize: 11, fontWeight: '700' }, markerText: { color: '#fff', fontSize: 11, fontWeight: '700' },
statusBar: {
position: 'absolute',
top: 12,
left: 12,
right: 12,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.65)',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 12,
},
statusText: { color: '#fff', fontSize: 13, flexShrink: 1 },
controls: { controls: {
position: 'absolute', position: 'absolute',
bottom: 24, bottom: 24,
alignSelf: 'center', alignSelf: 'center',
flexDirection: 'row', flexDirection: 'row',
gap: 10, gap: 8,
}, },
pillButton: { pill: {
backgroundColor: '#444',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
},
pillPrimary: {
backgroundColor: '#1e6f5c', backgroundColor: '#1e6f5c',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, paddingVertical: 10,
borderRadius: 22, borderRadius: 22,
}, },
pillText: { color: '#fff', fontWeight: '600' }, 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 },
}); });