Enhance map functionality: improve cache marker rendering for consistency across platforms, especially for iphone users, add fallback for proximity detection when heading is unavailable, and refactor visibility logic for better performance

This commit is contained in:
bobbert committed 2026-04-09 08:44:37 +01:00
1 parent a41f8ae6f8
commit 63c082bb8f
5 files changed
+138 -37

No files matched your search

+14 -4
View File
@@ -2,7 +2,7 @@ import {StyleSheet, Text, View} from 'react-native';
import Card from '../UI/Card';
import {Button} from '../UI/Button';
const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete, onSelect}) => {
const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete, onSelect, disabled}) => {
// Initialisation -------------
const displayName = cache.name || cache.clue || 'Unnamed Cache';
@@ -12,16 +12,16 @@ const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete,
// View -----------------------
return (
<Card style={[isSelected && styles.selectedCard, isClaimed && styles.claimedCard]}>
<Card style={[isSelected && styles.selectedCard, isClaimed && styles.claimedCard, disabled && styles.disabledCard]}>
<View style={styles.topRow}>
<View style={styles.nameWrap}>
<Text style={[styles.name, isClaimed && styles.claimedText]} numberOfLines={1}>{displayName}</Text>
<Text style={[styles.name, isClaimed && styles.claimedText, disabled && styles.disabledText]} numberOfLines={1}>{displayName}</Text>
{cache.name ? (
<Text style={styles.clue} numberOfLines={1}>{cache.clue}</Text>
) : null}
</View>
{/* Players see team-specific claim status; admins see no status */}
{!isAdmin && (
{!isAdmin && !disabled && (
<Text style={[styles.status, {color: isClaimed ? '#9ca3af' : '#16a34a'}]}>
{isClaimed ? 'Claimed' : 'Available'}
</Text>
@@ -43,6 +43,14 @@ const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete,
styleLabel={styles.btnLabel}
/>
</>
) : disabled ? (
<Button
label="Join a Team First"
onClick={() => {}}
disabled={true}
styleButton={styles.claimedBtn}
styleLabel={styles.claimedBtnLabel}
/>
) : isClaimed ? (
<Button
label="Already Claimed"
@@ -123,6 +131,8 @@ const styles = StyleSheet.create({
selectedBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
selectedCard: {borderColor: '#2563eb', borderWidth: 2},
claimedCard: {opacity: 0.5},
disabledCard: {opacity: 0.45},
disabledText: {color: '#9ca3af'},
btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
});
+44 -6
View File
@@ -1,4 +1,5 @@
import React from 'react';
import {Platform, StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import {getFovCone} from '../../utils/geoMath';
@@ -10,9 +11,8 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
if (!userLocation) return null;
const coneCoords = (heading !== null && heading !== undefined)
? getFovCone(userLocation, heading)
: null;
const hasHeading = heading !== null && heading !== undefined;
const coneCoords = hasHeading ? getFovCone(userLocation, heading) : null;
return (
<MapView
@@ -24,14 +24,20 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
pitchEnabled={false}
showsUserLocation
>
{/* Show cache markers for all caches currently inside the FOV cone */}
{/* Cache markers — uses a custom View instead of pinColor so the
marker renders identically on Apple Maps (iOS) and Google Maps */}
{(visibleCaches || []).map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
title={cache.clue}
pinColor="#facc15"
/>
anchor={{x: 0.5, y: 0.5}}
tracksViewChanges={Platform.OS === 'ios'}
>
<View style={styles.markerOuter}>
<View style={styles.markerInner} />
</View>
</Marker>
<Circle
center={cache.coordinates}
radius={claimDistance}
@@ -40,6 +46,7 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
/>
</React.Fragment>
))}
{/* FOV cone when heading is available */}
{coneCoords && (
<Polygon
coordinates={coneCoords}
@@ -48,8 +55,39 @@ const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) =>
strokeWidth={1}
/>
)}
{/* Proximity circle when heading is NOT available (iOS fallback) */}
{!hasHeading && claimDistance > 0 && (
<Circle
center={userLocation}
radius={claimDistance}
fillColor="rgba(66,133,244,0.12)"
strokeColor="rgba(66,133,244,0.40)"
/>
)}
</MapView>
);
};
const styles = StyleSheet.create({
markerOuter: {
width: 26,
height: 26,
alignItems: 'center',
justifyContent: 'center',
},
markerInner: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#f59e0b',
borderWidth: 2.5,
borderColor: '#ffffff',
shadowColor: '#000000',
shadowOffset: {width: 0, height: 1},
shadowOpacity: 0.3,
shadowRadius: 2,
elevation: 3,
},
});
export default PlayerMapView;
+50 -11
View File
@@ -1,9 +1,9 @@
import React, {useEffect, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import {Platform, StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import * as Location from 'expo-location';
import Screen from '../layout/Screen';
import {getFovCone, isInClaimCone} from '../../utils/geoMath';
import {getFovCone, isInClaimCone, isWithinRadius} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
@@ -66,13 +66,19 @@ const ExpandedMapScreen = ({route}) => {
// View -----------------------
const coneCoords = (userLocation && heading !== null)
const hasHeading = heading !== null;
const coneCoords = (userLocation && hasHeading)
? getFovCone(userLocation, heading)
: null;
// For players, show caches that currently fall within the FOV cone
const visiblePlayerCaches = (!isAdmin && userLocation && heading !== null)
? caches.filter((c) => isInClaimCone(heading, userLocation, c.coordinates, claimDistance))
// For players — when heading is available use FOV cone, otherwise fall back
// to proximity-only so the expanded map works on iPhones without compass data
const visiblePlayerCaches = (!isAdmin && userLocation)
? caches.filter((c) =>
hasHeading
? isInClaimCone(heading, userLocation, c.coordinates, claimDistance)
: isWithinRadius(userLocation, c.coordinates, claimDistance),
)
: [];
return (
@@ -89,8 +95,13 @@ const ExpandedMapScreen = ({route}) => {
<Marker
coordinate={cache.coordinates}
title={cache.name || cache.clue}
pinColor="#2563eb"
/>
anchor={{x: 0.5, y: 0.5}}
tracksViewChanges={Platform.OS === 'ios'}
>
<View style={styles.adminMarkerOuter}>
<View style={styles.adminMarkerInner} />
</View>
</Marker>
<Circle
center={cache.coordinates}
radius={claimDistance}
@@ -99,14 +110,19 @@ const ExpandedMapScreen = ({route}) => {
/>
</React.Fragment>
))}
{/* Players only see caches that are inside the FOV cone */}
{/* Players — custom View markers for Apple Maps compatibility */}
{!isAdmin && visiblePlayerCaches.map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
title={cache.clue}
pinColor="#facc15"
/>
anchor={{x: 0.5, y: 0.5}}
tracksViewChanges={Platform.OS === 'ios'}
>
<View style={styles.cacheMarkerOuter}>
<View style={styles.cacheMarkerInner} />
</View>
</Marker>
<Circle
center={cache.coordinates}
radius={claimDistance}
@@ -124,6 +140,15 @@ const ExpandedMapScreen = ({route}) => {
strokeWidth={1}
/>
)}
{/* Proximity circle when heading is unavailable (iOS fallback) */}
{!isAdmin && userLocation && !hasHeading && claimDistance > 0 && (
<Circle
center={userLocation}
radius={claimDistance}
fillColor="rgba(66,133,244,0.12)"
strokeColor="rgba(66,133,244,0.40)"
/>
)}
</MapView>
</View>
</Screen>
@@ -133,6 +158,20 @@ const ExpandedMapScreen = ({route}) => {
const styles = StyleSheet.create({
container: {padding: 0},
mapWrap: {flex: 1},
// Admin cache markers — blue dot
adminMarkerOuter: {width: 26, height: 26, alignItems: 'center', justifyContent: 'center'},
adminMarkerInner: {
width: 18, height: 18, borderRadius: 9,
backgroundColor: '#2563eb', borderWidth: 2.5, borderColor: '#ffffff',
shadowColor: '#000', shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.3, shadowRadius: 2, elevation: 3,
},
// Player cache markers — orange dot
cacheMarkerOuter: {width: 26, height: 26, alignItems: 'center', justifyContent: 'center'},
cacheMarkerInner: {
width: 18, height: 18, borderRadius: 9,
backgroundColor: '#f59e0b', borderWidth: 2.5, borderColor: '#ffffff',
shadowColor: '#000', shadowOffset: {width: 0, height: 1}, shadowOpacity: 0.3, shadowRadius: 2, elevation: 3,
},
});
export default ExpandedMapScreen;
+14 -8
View File
@@ -54,6 +54,9 @@ const MapScreen = ({navigation}) => {
const isPlayer = inGame && !isAdmin;
// Teams are required but the player hasn't joined one — block all claiming
const requiresTeam = Boolean(isPlayer && groupInfo?.TeamsEnabled && !session.currentTid);
// Stable empty array — avoids creating a new reference on every render
// which would trigger an infinite re-render loop in usePlayerGame
const EMPTY_CACHES = useMemo(() => [], []);
@@ -70,10 +73,11 @@ const MapScreen = ({navigation}) => {
});
}, [cacheRecords, isPlayer, session.currentTid, session.currentUid, EMPTY_CACHES]);
// When requiresTeam is true pass null/empty so usePlayerGame is inert
const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null,
isPlayer ? heading : null,
activeCachesForPlayer,
(isPlayer && !requiresTeam) ? userLocation : null,
(isPlayer && !requiresTeam) ? heading : null,
requiresTeam ? EMPTY_CACHES : activeCachesForPlayer,
claimDistance,
);
@@ -516,13 +520,12 @@ const MapScreen = ({navigation}) => {
}
// Player in game — use the first visible cache for the claim timer
const teamsWarning = groupInfo?.TeamsEnabled && !session.currentTid;
const claimTarget = visibleCaches.length > 0 ? visibleCaches[0] : null;
return (
<Screen style={styles.containerMap}>
{teamsWarning && (
<Text style={styles.warning}>You are not in a team. Teams are required for this game!</Text>
{requiresTeam && (
<Text style={styles.warning}>You are not in a team. Join a team to start claiming caches!</Text>
)}
<View style={styles.mapContainer}>
<PlayerMapView
@@ -535,12 +538,14 @@ const MapScreen = ({navigation}) => {
<Text style={styles.expandIcon}>⛶</Text>
</Pressable>
</View>
{!requiresTeam && (
<ClaimTimerView
cache={claimTarget}
isClaiming={isClaiming}
onClaimSuccess={handleClaim}
showClaimedPopup={claimedPopupVisible}
/>
)}
<View style={styles.cacheSection}>
<ScrollView>
{cacheRecords.map((cache) => {
@@ -551,8 +556,9 @@ const MapScreen = ({navigation}) => {
cache={cache}
isAdmin={false}
isClaimed={claimed}
isSelected={!claimed && selectedCacheId === cache.id}
onSelect={claimed ? undefined : handleSelectCache}
isSelected={!claimed && !requiresTeam && selectedCacheId === cache.id}
onSelect={(claimed || requiresTeam) ? undefined : handleSelectCache}
disabled={requiresTeam}
/>
);
})}
+16 -8
View File
@@ -1,5 +1,5 @@
import {useEffect, useRef, useState} from 'react';
import {isInClaimCone} from '../utils/geoMath';
import {isInClaimCone, isWithinRadius} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
// State ----------------------
@@ -11,7 +11,8 @@ const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistanc
// Handlers -------------------
useEffect(() => {
if (!playerLocation || playerHeading === null || playerHeading === undefined) {
// No location at all — clear everything
if (!playerLocation) {
if (prevIdsRef.current !== '') {
prevIdsRef.current = '';
setVisibleCaches([]);
@@ -20,17 +21,24 @@ const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistanc
return;
}
// Find all caches that fall inside the claim cone
const inCone = (activeCaches || []).filter((cache) =>
isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
const hasHeading = playerHeading !== null && playerHeading !== undefined;
// When heading is available use the full FOV cone check.
// When heading is NOT available (common on iOS / Apple Maps where the
// compass may be delayed or unavailable) fall back to proximity-only so
// iPhone users can still discover and claim caches within range.
const nearby = (activeCaches || []).filter((cache) =>
hasHeading
? isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
: isWithinRadius(playerLocation, cache.coordinates, claimDistance),
);
// Only update state when the set of visible caches actually changes
const newIds = inCone.map((c) => c.id).join(',');
const newIds = nearby.map((c) => c.id).join(',');
if (newIds !== prevIdsRef.current) {
prevIdsRef.current = newIds;
setVisibleCaches(inCone);
setIsClaiming(inCone.length > 0);
setVisibleCaches(nearby);
setIsClaiming(nearby.length > 0);
}
}, [playerLocation, playerHeading, activeCaches, claimDistance]);