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:
parent
d3d81a9de1
commit
b31cb78592
2 changed files with 127 additions and 58 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { NavigationContainer, useNavigation } from '@react-navigation/native';
|
||||
import {
|
||||
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() {
|
||||
return (
|
||||
<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="Scan" component={ScanScreen} />
|
||||
|
|
|
|||
|
|
@ -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 Constants from 'expo-constants';
|
||||
import {
|
||||
|
|
@ -18,54 +18,105 @@ const MAP_STYLE =
|
|||
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
|
||||
'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>;
|
||||
|
||||
export function MapScreen() {
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { coords, refresh } = useLocation();
|
||||
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 [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);
|
||||
setStatus(`Searching ${label}…`);
|
||||
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 {
|
||||
const found = (res.Zones ?? []).filter((z) => z.Lat != null && z.Long != null);
|
||||
setZones(found);
|
||||
setStatus(found.length ? `${found.length} meters near ${label}` : `No meters found ${label}`);
|
||||
} catch (e: any) {
|
||||
setZones([]);
|
||||
setStatus(
|
||||
e?.status === 401
|
||||
? 'Session expired — sign in again.'
|
||||
: `Search failed: ${e?.serverMessage ?? e?.message ?? 'error'}`,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-load meters once we have a current fix.
|
||||
useEffect(() => {
|
||||
if (coords && !center) void loadMeters(coords);
|
||||
}, [coords, center, loadMeters]);
|
||||
// Search whatever the map is currently centered on (works with no GPS).
|
||||
const searchThisArea = async () => {
|
||||
try {
|
||||
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());
|
||||
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 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 (
|
||||
<View style={styles.container}>
|
||||
{/* mapStyle / props are version-sensitive in @maplibre/maplibre-react-native */}
|
||||
<MapView style={styles.map} mapStyle={MAP_STYLE}>
|
||||
{initial ? (
|
||||
<MapView ref={mapRef} style={styles.map} mapStyle={MAP_STYLE}>
|
||||
{initialCenter ? (
|
||||
<Camera
|
||||
zoomLevel={15}
|
||||
centerCoordinate={[initial.longitude, initial.latitude]}
|
||||
animationDuration={0}
|
||||
ref={cameraRef}
|
||||
defaultSettings={{
|
||||
centerCoordinate: [initialCenter.longitude, initialCenter.latitude],
|
||||
zoomLevel: initialCenter === DEFAULT_CENTER ? DEFAULT_ZOOM : 14,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -78,13 +129,7 @@ export function MapScreen() {
|
|||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.navigate('MeterDetail', { zone: z })}
|
||||
style={[
|
||||
styles.marker,
|
||||
{
|
||||
backgroundColor: z.BackgroundColor ?? '#1e6f5c',
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
]}
|
||||
style={[styles.marker, { backgroundColor: z.BackgroundColor ?? '#1e6f5c' }]}
|
||||
>
|
||||
<Text style={styles.markerText} numberOfLines={1}>
|
||||
{z.ZoneName ?? 'Meter'}
|
||||
|
|
@ -94,24 +139,24 @@ export function MapScreen() {
|
|||
))}
|
||||
</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 style={styles.statusBar}>
|
||||
{loading ? <ActivityIndicator color="#fff" style={{ marginRight: 8 }} /> : null}
|
||||
<Text style={styles.statusText} numberOfLines={1}>
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.countBadge}>
|
||||
<Text style={styles.countText}>{zones.length} meters</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.controls}>
|
||||
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
|
||||
<Text style={styles.pillText}>Search this area</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
|
||||
<Text style={styles.pillText}>My location</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pill} onPress={searchLastKnown}>
|
||||
<Text style={styles.pillText}>Last</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -124,32 +169,41 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
maxWidth: 140,
|
||||
},
|
||||
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: {
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
gap: 8,
|
||||
},
|
||||
pillButton: {
|
||||
pill: {
|
||||
backgroundColor: '#444',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
pillPrimary: {
|
||||
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 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue