mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
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:
1 parent
a41f8ae6f8
commit
63c082bb8f
5 files changed
+144
-43
No files matched your search
@@ -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},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
<ClaimTimerView
|
||||
cache={claimTarget}
|
||||
isClaiming={isClaiming}
|
||||
onClaimSuccess={handleClaim}
|
||||
showClaimedPopup={claimedPopupVisible}
|
||||
/>
|
||||
{!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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user