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 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} />
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue