From e3564bea08abb702eb45e41aaf1c62c84ac4e349 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Thu, 9 Apr 2026 10:23:36 +0100 Subject: [PATCH] Refactor API endpoints: update subgroup membership references to subgroup members and streamline cache visibility logic based on selected cache ID. --- README.md | 28 +++++++-- src/components/gameplay/PlayerMapView.js | 45 ++------------ src/components/layout/BottomNavbar.js | 4 +- src/components/screens/ExpandedMapScreen.js | 66 +++------------------ src/components/screens/MapScreen.js | 44 +++----------- src/hooks/useGameHook.js | 28 ++++----- src/hooks/usePlayerGame.js | 47 +++++---------- 7 files changed, 73 insertions(+), 189 deletions(-) diff --git a/README.md b/README.md index dee5871..c7483a3 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,32 @@ Treasure Hunt mobile app built with React Native and Expo. ```bash npm i -npx expo start --android +npm start ``` ## Notes - The app accesses game/auth data through the centralized API endpoint via `src/components/API/API.js`. -- Navigation is handled with React Navigation native stack. -- Sometimes you might have to switch the API Endpoint URL. This is just Because of the fact that when I'm using the app I can't access the API endpoint, from the global URL. Therefore, I need my local API Server's IP as the URL -- Just Uncomment the `API_ENDPOINT` variable in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default]. +- IF encountering network issues, ensure the API endpoint is set to 'http://api.bobby.ip-ddns.com' and not 'http://192.168.8.100:3000'. +- The reason for 'http://192.168.8.100:3000' is due to the API server being on my (Mudabbir's) home network. I need to use local ip due to loop back restrictions with home network(needed for security). +- Just uncomment line 1 in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default]. + + +# DB Structure: + +API endpoint: `http://api.bobby.ip-ddns.com/` + +### Tables: +- `users` +- `teams` +- `team-members` +- `subgroups` +- `subgroup-members` +- `groups` +- `game-types` +- `db` +- `caches` +- `admin-waitlist` + +#### Append Table to API URL to view table's json data on browser. +I.e: `http://api.bobby.ip-ddns.com/users` or `http://api.bobby.ip-ddns.com/subgroups-members` \ No newline at end of file diff --git a/src/components/gameplay/PlayerMapView.js b/src/components/gameplay/PlayerMapView.js index 7718dfd..5ff1f4b 100644 --- a/src/components/gameplay/PlayerMapView.js +++ b/src/components/gameplay/PlayerMapView.js @@ -1,14 +1,8 @@ import React from 'react'; -import {StyleSheet, View} from 'react-native'; import MapView, {Marker, Polygon} from 'react-native-maps'; import {getFovCone} from '../../utils/geoMath'; const PlayerMapView = ({userLocation, visibleCaches, heading}) => { -// Initialisation ------------- -// State ---------------------- -// Handlers ------------------- -// View ----------------------- - if (!userLocation) return null; const coneCoords = (heading !== null && heading !== undefined) @@ -26,25 +20,14 @@ const PlayerMapView = ({userLocation, visibleCaches, heading}) => { pitchEnabled={false} showsUserLocation > - {/* Cache pin markers — appear temporarily when the cache is in the FOV - cone AND within claim distance. Uses a custom View so the pin - renders identically on iOS (Google Maps) and Android. - tracksViewChanges must be true so Android captures the custom - View as a bitmap on the first render cycle. */} {(visibleCaches || []).map((cache) => ( - - - - + /> ))} - {/* FOV heading cone */} {coneCoords && ( { ); }; -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; + + diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js index 48f33bf..3838578 100644 --- a/src/components/layout/BottomNavbar.js +++ b/src/components/layout/BottomNavbar.js @@ -39,10 +39,10 @@ const BottomNavbar = ({navigation, routeName}) => { style: 'destructive', onPress: async () => { const s = getSession(); - const endpoint = `${API_BASE_URL}/subgroup-memberships?Uid=${s.currentUid}&Gid=${s.currentGid}`; + const endpoint = `${API_BASE_URL}/subgroup-members?Uid=${s.currentUid}&Gid=${s.currentGid}`; const res = await API.get(endpoint); if (res.isSuccess && res.result.length > 0) { - await API.delete(`${API_BASE_URL}/subgroup-memberships/${res.result[0].id}`); + await API.delete(`${API_BASE_URL}/subgroup-members/${res.result[0].id}`); } clearGameSession(); navigation.reset({index: 0, routes: [{name: 'Game'}]}); diff --git a/src/components/screens/ExpandedMapScreen.js b/src/components/screens/ExpandedMapScreen.js index ffeccf9..8396083 100644 --- a/src/components/screens/ExpandedMapScreen.js +++ b/src/components/screens/ExpandedMapScreen.js @@ -8,20 +8,13 @@ import {getFovCone, isInClaimCone} from '../../utils/geoMath'; const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01}; const ExpandedMapScreen = ({route}) => { -// Initialisation ------------ - - const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation} = route.params || {}; + const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation, selectedCacheId} = route.params || {}; const caches = cacheStr ? JSON.parse(cacheStr) : []; const claimDistance = routeClaimDistance || 20; -// State ---------------------- - - // Seed with the location passed from MapScreen so the map opens centred on the user immediately const [userLocation, setUserLocation] = useState(routeLocation || null); const [heading, setHeading] = useState(null); -// Handlers ------------------- - useEffect(() => { let locationSub; let headingSub; @@ -30,7 +23,6 @@ const ExpandedMapScreen = ({route}) => { const {status} = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') return; - // Only do a fresh one-shot fix if MapScreen didn't supply a location if (!routeLocation) { const last = await Location.getLastKnownPositionAsync(); if (last) { @@ -46,7 +38,6 @@ const ExpandedMapScreen = ({route}) => { (next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}), ); - // Use OS-fused heading from expo-location headingSub = await Location.watchHeadingAsync((headingData) => { const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading; setHeading(raw); @@ -64,17 +55,11 @@ const ExpandedMapScreen = ({route}) => { ? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01} : DEFAULT_REGION; -// View ----------------------- - const hasHeading = heading !== null; - const coneCoords = (userLocation && hasHeading) - ? getFovCone(userLocation, heading) - : null; + const coneCoords = (userLocation && hasHeading) ? getFovCone(userLocation, heading) : null; - // For players — heading is required so the player must point their device - // towards a cache for it to appear (same logic as the main MapScreen) - const visiblePlayerCaches = (!isAdmin && userLocation && hasHeading) - ? caches.filter((c) => isInClaimCone(heading, userLocation, c.coordinates, claimDistance)) + const visiblePlayerCaches = (!isAdmin && userLocation && hasHeading && selectedCacheId) + ? caches.filter((c) => c.id === selectedCacheId && isInClaimCone(heading, userLocation, c.coordinates, claimDistance)) : []; return ( @@ -86,21 +71,9 @@ const ExpandedMapScreen = ({route}) => { initialRegion={region} showsUserLocation > - {/* Admins see all cache pin locations. - tracksViewChanges must be true so Android captures the - custom View as a bitmap on the first render cycle. */} {isAdmin && caches.map((cache) => ( - - - - - + { /> ))} - {/* Players — pin markers appear when cache is in FOV cone + within range. - tracksViewChanges must be true so Android captures the - custom View as a bitmap on the first render cycle. */} {!isAdmin && visiblePlayerCaches.map((cache) => ( - - - - + /> ))} - {/* Heading FOV cone */} {coneCoords && ( { 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; + + diff --git a/src/components/screens/MapScreen.js b/src/components/screens/MapScreen.js index c2f851b..d01b55d 100644 --- a/src/components/screens/MapScreen.js +++ b/src/components/screens/MapScreen.js @@ -22,8 +22,7 @@ const MapScreen = ({navigation}) => { const session = getSession(); const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, createPrivateGame, getLobby, getUser, getSubgroups} = useGameHook(); - // Stable ref to getCaches so loadCaches never gets a new identity on re-render. - // This prevents the useEffect/useFocusEffect from looping infinitely. + // Stable ref to prevent infinite re-render loops const getCachesRef = useRef(getCaches); getCachesRef.current = getCaches; @@ -49,19 +48,12 @@ const MapScreen = ({navigation}) => { const [newName, setNewName] = useState(''); const [newClue, setNewClue] = useState(''); - // Global claim distance comes from the group's CacheTriggerMeters setting const claimDistance = groupInfo?.CacheTriggerMeters || 20; - 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(() => [], []); - // Filter out caches already claimed by the current user's team (or by the user if no team) + // Caches not yet claimed by the current user's team (or user if no team) const activeCachesForPlayer = useMemo(() => { if (!isPlayer) return EMPTY_CACHES; return cacheRecords.filter((cache) => { @@ -73,8 +65,6 @@ const MapScreen = ({navigation}) => { }); }, [cacheRecords, isPlayer, session.currentTid, session.currentUid, EMPTY_CACHES]); - // When requiresTeam is true pass null/empty so usePlayerGame is inert. - // selectedCacheId is the 5th param — only that cache can trigger the claim countdown. const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame( (isPlayer && !requiresTeam) ? userLocation : null, (isPlayer && !requiresTeam) ? heading : null, @@ -98,9 +88,6 @@ const MapScreen = ({navigation}) => { // Default member subgroup SGid for cache creation (first non-admin subgroup) const defaultMemberSGid = subgroups.find((sg) => !sg.IsAdminGroup)?.SGid || null; - // Always call getSession() fresh inside the callback so we never read a - // stale currentGid from the closure — matches the server's /caches endpoint. - // Uses getCachesRef so this callback has a stable identity (no infinite loop). const loadCaches = useCallback(async () => { const currentGid = getSession().currentGid; if (!currentGid) { @@ -114,18 +101,15 @@ const MapScreen = ({navigation}) => { // Run once on mount useEffect(() => { loadCaches(); }, [loadCaches]); - // Re-fetch caches every time this screen gains focus so the list is always - // up-to-date after navigating away and back (e.g. Settings → Map) + // Re-fetch caches when screen gains focus useFocusEffect( useCallback(() => { loadCaches(); }, [loadCaches]) ); - // Auto-select the first unclaimed cache for the player when caches load + // Auto-select first unclaimed cache when list loads useEffect(() => { if (!isPlayer || activeCachesForPlayer.length === 0) return; - // If persisted selection still exists in the unclaimed list, keep it if (selectedCacheId && activeCachesForPlayer.some((c) => c.id === selectedCacheId)) return; - // Otherwise auto-select the first unclaimed cache const firstId = activeCachesForPlayer[0].id; setSelectedCacheIdState(firstId); setSelectedCache(firstId); @@ -145,7 +129,6 @@ const MapScreen = ({navigation}) => { return; } - // Try cached position first for instant load, fall back to fresh fix const last = await Location.getLastKnownPositionAsync(); if (last) { setUserLocation({latitude: last.coords.latitude, longitude: last.coords.longitude}); @@ -160,7 +143,6 @@ const MapScreen = ({navigation}) => { (next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}), ); - // Use OS-fused heading from expo-location headingSub = await Location.watchHeadingAsync((headingData) => { const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading; setHeading(raw); @@ -176,13 +158,10 @@ const MapScreen = ({navigation}) => { const handleJoinGame = async () => { if (!gameCode.trim()) return; - // Force uppercase just in case a lowercase code is pasted const result = await joinPrivateGame({JoinCode: gameCode.trim().toUpperCase(), Uid: session.currentUid}); if (!result) return; setSessionGroup(result.Gid, result.SGid); - // Refresh user from server so we pick up any existing TGid - // (handles re-joining a game while still in a team) const freshUser = await getUser(session.currentUid); if (freshUser) { setSessionUser(freshUser); @@ -221,14 +200,12 @@ const MapScreen = ({navigation}) => { }); setIsClaiming(false); if (result) { - // Show "Cache Claimed!" popup for 3 seconds setClaimedPopupVisible(true); setTimeout(() => setClaimedPopupVisible(false), 3000); } await loadCaches(); }, [session.currentGid, session.currentUid, session.currentTid, loadCaches]); - // Admin — open create form const handleCreateCachePress = () => { const fallback = userLocation || {latitude: mapRegion.latitude, longitude: mapRegion.longitude}; setEditingCacheId(null); @@ -238,7 +215,6 @@ const MapScreen = ({navigation}) => { setIsCreating(true); }; - // Admin — open edit form const handleEditCache = (cache) => { setEditingCacheId(cache.id); setNewCoord(cache.coordinates); @@ -247,14 +223,12 @@ const MapScreen = ({navigation}) => { setIsCreating(true); }; - // Admin — delete cache const handleDeleteCache = async (cache) => { if (!session.currentGid) return; await deleteCache(session.currentGid, cache.id); await loadCaches(); }; - // Admin — save create or edit const handleSaveCache = async () => { if (!newCoord || !newClue.trim() || !session.currentGid) return; const payload = { @@ -277,7 +251,6 @@ const MapScreen = ({navigation}) => { setEditingCacheId(null); }; - // Navigate to expanded map view, passing the live location so it opens centred on the user const handleExpandMap = () => { navigation.navigate('ExpandedMapScreen', { isAdmin, @@ -285,16 +258,15 @@ const MapScreen = ({navigation}) => { heading: heading || 0, claimDistance, userLocation: userLocation || null, + selectedCacheId, }); }; - // Player — select cache (persist selection); only allow selecting unclaimed caches const handleSelectCache = (cache) => { setSelectedCacheIdState(cache.id); setSelectedCache(cache.id); }; - // Helper — check whether a cache has been claimed by the current user's team (or user) const isCacheClaimedByMe = (cache) => { const claims = cache.Claims || []; if (session.currentTid) return claims.some((c) => c.Tid === session.currentTid); @@ -438,8 +410,7 @@ const MapScreen = ({navigation}) => { ); } - // Admin — normal view (map + create button + cache list) - // Uses initialRegion so manual zoom/pan is not reset by location updates + // Admin view if (isAdmin) { return ( @@ -523,8 +494,7 @@ const MapScreen = ({navigation}) => { ); } - // Player in game — the claim target is the selected cache (only it can trigger the countdown) - const claimTarget = visibleCaches.find((c) => c.id === selectedCacheId) || null; + const claimTarget = visibleCaches[0] || null; return ( diff --git a/src/hooks/useGameHook.js b/src/hooks/useGameHook.js index f7a10d9..f222521 100644 --- a/src/hooks/useGameHook.js +++ b/src/hooks/useGameHook.js @@ -6,7 +6,7 @@ const useGameHook = () => { const usersEndpoint = `${API_BASE_URL}/users`; const groupsEndpoint = `${API_BASE_URL}/groups`; const subgroupsEndpoint = `${API_BASE_URL}/subgroups`; - const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`; + const subgroupMembersEndpoint = `${API_BASE_URL}/subgroup-members`; const teamsEndpoint = `${API_BASE_URL}/teams`; const teamMembersEndpoint = `${API_BASE_URL}/team-members`; const cachesEndpoint = `${API_BASE_URL}/caches`; @@ -67,19 +67,19 @@ const useGameHook = () => { return response.isSuccess; }; - // Subgroup memberships + // Subgroup members const joinPrivateGame = async (payload) => { - const response = await API.post(subgroupMembershipsEndpoint, payload); + const response = await API.post(subgroupMembersEndpoint, payload); return response.isSuccess ? response.result : null; }; const getGroupMembers = async (gid) => { - const response = await API.get(`${subgroupMembershipsEndpoint}?Gid=${gid}`); + const response = await API.get(`${subgroupMembersEndpoint}?Gid=${gid}`); return response.isSuccess ? response.result : []; }; const removeMember = async (membershipId) => { - const response = await API.delete(`${subgroupMembershipsEndpoint}/${membershipId}`); + const response = await API.delete(`${subgroupMembersEndpoint}/${membershipId}`); return response.isSuccess; }; @@ -125,7 +125,6 @@ const useGameHook = () => { return response.isSuccess; }; - // Caches (standalone resource) const getCaches = async (gid, sgid = null) => { let url = `${cachesEndpoint}?Gid=${gid}`; if (sgid !== null) url += `&SGid=${sgid}`; @@ -134,14 +133,7 @@ const useGameHook = () => { }; const upsertCache = async (payload) => { - if (payload.cacheId) { - const response = await API.put( - `${cachesEndpoint}/${payload.cacheId}`, - payload, - ); - return response.isSuccess ? response.result : null; - } - const response = await API.post(cachesEndpoint, { + const data = { Gid: payload.gid, SGid: payload.subgroupId, Title: payload.name, @@ -149,11 +141,15 @@ const useGameHook = () => { Latitude: payload.latitude, Longitude: payload.longitude, TriggerMeters: payload.radius, - }); + }; + if (payload.cacheId) { + const response = await API.put(`${cachesEndpoint}/${payload.cacheId}`, data); + return response.isSuccess ? response.result : null; + } + const response = await API.post(cachesEndpoint, data); return response.isSuccess ? response.result : null; }; - // Claim a cache — team-aware: prevents same team from claiming again const claimCache = async (payload) => { const response = await API.post( `${cachesEndpoint}/${payload.cacheId}/claim`, diff --git a/src/hooks/usePlayerGame.js b/src/hooks/usePlayerGame.js index 66b50e0..85b5370 100644 --- a/src/hooks/usePlayerGame.js +++ b/src/hooks/usePlayerGame.js @@ -2,52 +2,35 @@ import {useEffect, useRef, useState} from 'react'; import {isInClaimCone} from '../utils/geoMath'; const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance, selectedCacheId) => { -// State ---------------------- - const [visibleCaches, setVisibleCaches] = useState([]); const [isClaiming, setIsClaiming] = useState(false); - const prevIdsRef = useRef(''); + const prevIdRef = useRef(''); -// Handlers ------------------- - - // Determine which caches fall inside the FOV claim cone useEffect(() => { - if (!playerLocation || playerHeading === null || playerHeading === undefined) { - if (prevIdsRef.current !== '') { - prevIdsRef.current = ''; + if (!playerLocation || playerHeading === null || playerHeading === undefined || !selectedCacheId) { + if (prevIdRef.current !== '') { + prevIdRef.current = ''; setVisibleCaches([]); + setIsClaiming(false); } return; } - const inCone = (activeCaches || []).filter((cache) => - isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance), - ); + const selected = (activeCaches || []).find((c) => c.id === selectedCacheId); + const visible = selected && isInClaimCone(playerHeading, playerLocation, selected.coordinates, claimDistance); + const newId = visible ? String(selected.id) : ''; - // Only update state when the set of visible caches actually changes - const newIds = inCone.map((c) => c.id).join(','); - if (newIds !== prevIdsRef.current) { - prevIdsRef.current = newIds; - setVisibleCaches(inCone); + if (newId !== prevIdRef.current) { + prevIdRef.current = newId; + setVisibleCaches(visible ? [selected] : []); + setIsClaiming(Boolean(visible)); } - }, [playerLocation, playerHeading, activeCaches, claimDistance]); - - // Claiming requires ALL four criteria: - // 1. Cache not already claimed by player's team (handled by activeCaches filter) - // 2. Cache within claim distance (handled by isInClaimCone) - // 3. Player looking at the cache (in FOV cone) (handled by isInClaimCone) - // 4. Cache is the one currently selected in list (checked here) - useEffect(() => { - const canClaim = Boolean( - selectedCacheId && visibleCaches.some((c) => c.id === selectedCacheId), - ); - setIsClaiming(canClaim); - }, [visibleCaches, selectedCacheId]); - -// Return --------------------- + }, [playerLocation, playerHeading, activeCaches, claimDistance, selectedCacheId]); return {visibleCaches, isClaiming, setIsClaiming}; }; export default usePlayerGame; + +