import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, StyleSheet, Text, 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 { Button, ButtonTray } from "../../UI/Button"; import ClaimTimerView from "../../gameplay/ClaimTimerView"; import CacheList from "../../../entity/cache/CacheList"; import useGlobalHook from "../../../hooks/useGlobalHook"; import usePlayerGame from "../../../hooks/usePlayerGame"; import { getSession } from "../../../hooks/SessionStore"; import { getFovCone, isInClaimCone } from "../../../utils/geoMath"; import { GAME_MODE } from "../../../utils/gameConstants"; const DEFAULT_REGION = { latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05, }; const GLOBAL_CLAIM_DISTANCE_METERS = 50; const GlobalMapScreen = ({ navigation, route }) => { // Initialisations --------------------- const session = getSession(); const routeEvent = route?.params?.event ?? null; const eventId = route?.params?.eventId ?? routeEvent?.EventID ?? session.currentGlobalEventId; const { getCachesByEvent, getFindsByPlayer, claimCache } = useGlobalHook(); const globalApiRef = useRef({ getCachesByEvent, getFindsByPlayer, claimCache, }); globalApiRef.current = { getCachesByEvent, getFindsByPlayer, claimCache, }; // State ------------------------------- const [caches, setCaches] = useState([]); const [foundCacheIds, setFoundCacheIds] = useState([]); const [userLocation, setUserLocation] = useState(null); const [heading, setHeading] = useState(null); const [isLoading, setIsLoading] = useState(true); const [activeTab, setActiveTab] = useState("map"); const [error, setError] = useState(""); const [selectedCacheId, setSelectedCacheId] = useState( route?.params?.selectedCacheId ?? route?.params?.cache?.CacheID ?? null, ); const [claimedPopupVisible, setClaimedPopupVisible] = useState(false); const [claimedPopupMessage, setClaimedPopupMessage] = useState("Cache Claimed!"); const claimableCaches = useMemo( () => (caches || []) .filter((cache) => !foundCacheIds.includes(String(cache.CacheID))) .map((cache) => ({ id: cache.CacheID, clue: cache.CacheClue || cache.CacheName, name: cache.CacheName, coordinates: { latitude: cache.CacheLatitude, longitude: cache.CacheLongitude, }, raw: cache, })), [caches, foundCacheIds], ); const { visibleCaches, isClaiming, setIsClaiming } = usePlayerGame( userLocation, heading, claimableCaches, GLOBAL_CLAIM_DISTANCE_METERS, selectedCacheId, false, ); // Handlers ---------------------------- const loadData = useCallback( async (options = {}) => { if (!eventId) return; setIsLoading(true); setError(""); const [cacheData, findsData] = await Promise.all([ globalApiRef.current.getCachesByEvent(eventId, options), session.currentGlobalPlayerId ? globalApiRef.current.getFindsByPlayer( session.currentGlobalPlayerId, options, ) : Promise.resolve([]), ]); setCaches(cacheData || []); setFoundCacheIds((findsData || []).map((f) => String(f.FindCacheID))); setIsLoading(false); }, [eventId, session.currentGlobalPlayerId], ); const handleClaim = useCallback( async (cacheId) => { if (!session.currentGlobalPlayerId) { setIsClaiming(false); return; } const alreadyFound = foundCacheIds.some( (id) => String(id) === String(cacheId), ); if (alreadyFound) { setIsClaiming(false); return; } const claimedCache = (caches || []).find( (cache) => String(cache.CacheID) === String(cacheId), ); const result = await globalApiRef.current.claimCache( session.currentGlobalPlayerId, claimedCache, ); setIsClaiming(false); if (!result || result.error) { setError(`Failed to claim cache: ${result?.error || "Unknown error"}`); return; } // Optimistically mark as found — no loadData() to avoid overwriting // this state with a stale server response. setFoundCacheIds((prev) => [...prev, String(cacheId)]); const cacheName = claimedCache?.CacheName || "Cache"; setClaimedPopupMessage( `${cacheName} claimed! You gained +${result.points} points.`, ); setClaimedPopupVisible(true); setTimeout(() => setClaimedPopupVisible(false), 3000); }, [caches, foundCacheIds, session.currentGlobalPlayerId, setIsClaiming], ); const handleCacheSelect = (cache) => { setSelectedCacheId(cache.CacheID); setActiveTab("map"); }; const resolveClaimTarget = useCallback(() => { if (!userLocation || heading === null || heading === undefined) { return null; } return ( claimableCaches.find((cache) => isInClaimCone( heading, userLocation, cache.coordinates, GLOBAL_CLAIM_DISTANCE_METERS, ), ) || null ); }, [claimableCaches, heading, userLocation]); const handleOpenSelectedCache = () => { const selected = (caches || []).find( (cache) => cache.CacheID === selectedCacheId, ); if (!selected) return; navigation.navigate("GlobalCacheViewScreen", { cache: selected, event: routeEvent, eventId, isFound: foundCacheIds.includes(String(selected.CacheID)), }); }; useEffect(() => { if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) { navigation.replace("MapScreen"); return; } if (!session.currentGlobalPlayerId) { navigation.replace("GlobalEventsScreen"); return; } if (!eventId) { navigation.replace("GlobalEventsScreen"); } }, [ eventId, navigation, session.currentGameMode, session.currentGlobalPlayerId, session.isBusiness, ]); useEffect(() => { if ( !eventId || session.currentGameMode !== GAME_MODE.GLOBAL || !session.currentGlobalPlayerId ) { return; } loadData(); }, [ eventId, loadData, session.currentGameMode, session.currentGlobalPlayerId, ]); useEffect(() => { const routeSelected = route?.params?.selectedCacheId; if (!routeSelected) return; setSelectedCacheId(routeSelected); }, [route?.params?.selectedCacheId]); useEffect(() => { if (route?.params?.cache?.CacheID) { setSelectedCacheId(route.params.cache.CacheID); } }, [route?.params?.cache?.CacheID]); useEffect(() => { if (selectedCacheId) return; setSelectedCacheId( route?.params?.cache?.CacheID ?? claimableCaches[0]?.id ?? null, ); }, [claimableCaches, route?.params?.cache?.CacheID, selectedCacheId]); useEffect(() => { let locationSub; let headingSub; const start = async () => { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== "granted") { setError("Location permission denied"); return; } const last = await Location.getLastKnownPositionAsync(); if (last) { setUserLocation({ latitude: last.coords.latitude, longitude: last.coords.longitude, }); } else { const pos = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Low, }); setUserLocation({ latitude: pos.coords.latitude, longitude: pos.coords.longitude, }); } locationSub = await Location.watchPositionAsync( { accuracy: Location.Accuracy.Balanced, distanceInterval: 3, timeInterval: 1500, }, (pos) => setUserLocation({ latitude: pos.coords.latitude, longitude: pos.coords.longitude, }), ); headingSub = await Location.watchHeadingAsync((headingData) => { const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading; setHeading(raw); }); }; start(); return () => { if (locationSub) locationSub.remove(); if (headingSub) headingSub.remove(); }; }, []); const mapRegion = userLocation ? { ...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01 } : DEFAULT_REGION; const claimTarget = resolveClaimTarget() || visibleCaches[0] || null; // View -------------------------------- if (isLoading) { return ( ); } return ( setActiveTab("map")} /> setActiveTab("list")} /> {error ? {error} : null} {activeTab === "map" ? ( {(caches || []) .filter((cache) => !foundCacheIds.includes(String(cache.CacheID))) .map((cache) => { const selected = selectedCacheId === cache.CacheID; return ( setSelectedCacheId(cache.CacheID)} onCalloutPress={() => navigation.navigate("GlobalCacheViewScreen", { cache, event: routeEvent, eventId, isFound: false, }) } /> ); })} {claimTarget ? ( ) : null} {userLocation && heading !== null && heading !== undefined ? ( ) : null} ) : ( !foundCacheIds.includes(String(cache.CacheID)), )} foundCacheIds={foundCacheIds} onSelect={handleCacheSelect} /> )}